Spaces:
Sleeping
Sleeping
Initial modernized KAPO runtime upload
Browse files- .env +31 -0
- Dockerfile +13 -0
- README.md +21 -10
- bootstrap_space_runtime.py +111 -0
- brain_server/.gitattributes +35 -0
- brain_server/Dockerfile +28 -0
- brain_server/README.md +10 -0
- brain_server/agents/__init__.py +1 -0
- brain_server/agents/auto_heal_agent.py +22 -0
- brain_server/agents/memory_agent.py +51 -0
- brain_server/agents/planner_agent.py +88 -0
- brain_server/agents/reasoning_agent.py +20 -0
- brain_server/agents/supervisor_agent.py +38 -0
- brain_server/agents/tool_selector_agent.py +103 -0
- brain_server/api/__init__.py +1 -0
- brain_server/api/deps.py +159 -0
- brain_server/api/firebase_store.py +356 -0
- brain_server/api/main.py +2356 -0
- brain_server/api/routes_analyze.py +48 -0
- brain_server/api/routes_execute.py +120 -0
- brain_server/api/routes_plan.py +67 -0
- brain_server/config/config.yaml +26 -0
- brain_server/config/logging.yaml +22 -0
- brain_server/kaggle_bootstrap.py +26 -0
- brain_server/langgraph/__init__.py +1 -0
- brain_server/langgraph/agent_prompts.py +87 -0
- brain_server/langgraph/graph_definition.py +46 -0
- brain_server/memory/__init__.py +1 -0
- brain_server/memory/episodic_db.py +91 -0
- brain_server/memory/knowledge_vector.py +84 -0
- brain_server/memory/short_term.py +37 -0
- brain_server/rag/__init__.py +1 -0
- brain_server/rag/loader.py +19 -0
- brain_server/rag/retriever.py +14 -0
- brain_server/requirements.txt +27 -0
- kapo.env +31 -0
- requirements.txt +9 -0
- shared/__init__.py +1 -0
- shared/google_drive_state.py +409 -0
- shared/remote_env.py +102 -0
.env
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BRAIN_AUTO_NGROK=0
|
| 2 |
+
BRAIN_AUTO_PUBLISH_URL_ON_STARTUP=0
|
| 3 |
+
BRAIN_LANGUAGES=ar,en
|
| 4 |
+
BRAIN_PLATFORM_NAME=ai_coder_main
|
| 5 |
+
BRAIN_PROVIDER=huggingface
|
| 6 |
+
BRAIN_REUSE_PUBLIC_URL_ON_RESTART=0
|
| 7 |
+
BRAIN_ROLES=coding,planner,fallback
|
| 8 |
+
BRAIN_TEMPLATE=hf-space-cpu
|
| 9 |
+
BRAIN_TUNNEL_PROVIDER=none
|
| 10 |
+
FIREBASE_ENABLED=0
|
| 11 |
+
FIREBASE_NAMESPACE=kapo
|
| 12 |
+
FIREBASE_PROJECT_ID=citadel4travels
|
| 13 |
+
GOOGLE_DRIVE_BOOTSTRAP_URL=https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG
|
| 14 |
+
HF_ACCELERATOR=cpu
|
| 15 |
+
HF_SPACE_DOCKER=1
|
| 16 |
+
KAGGLE_AUTO_BOOTSTRAP=0
|
| 17 |
+
KAPO_BOOTSTRAP_URL=https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG
|
| 18 |
+
KAPO_COMPUTE_PROFILE=cpu
|
| 19 |
+
KAPO_HF_INFERENCE_API=1
|
| 20 |
+
KAPO_HF_TRANSFORMERS_RUNTIME=0
|
| 21 |
+
KAPO_LAZY_EMBED_STARTUP=1
|
| 22 |
+
KAPO_LAZY_MODEL_STARTUP=1
|
| 23 |
+
KAPO_PATCH_BUNDLE_URL=https://drive.google.com/uc?export=download&id=16rIe05GZihhAz7ba8E-WibKaJKbh9eu1
|
| 24 |
+
KAPO_PATCH_MANIFEST_URL=https://drive.google.com/uc?export=download&id=1jLuPMCA3hp9qstZZtpBNzTK0XOmLrV8b
|
| 25 |
+
KAPO_REMOTE_ENV_PASSWORD_B64=cENvSnljQ0Z6RUN2azRpcUFLVUdQLW9BbVBoTEtNOFE
|
| 26 |
+
KAPO_REMOTE_ENV_URL_B64=aHR0cHM6Ly9kcml2ZS5nb29nbGUuY29tL3VjP2V4cG9ydD1kb3dubG9hZCZpZD0xdjlZRDlNYlBKNUdYNk9WanVHM3hOR05xLWJfQlJfTGY
|
| 27 |
+
KAPO_SHARED_STATE_BACKEND=google_drive
|
| 28 |
+
MODEL_PROFILE_ID=hf-coder-qwen25-coder-7b-instruct
|
| 29 |
+
MODEL_REPO=Qwen/Qwen2.5-Coder-1.5B-Instruct
|
| 30 |
+
REMOTE_BRAIN_ONLY=1
|
| 31 |
+
SPACE_PUBLIC_URL=https://MrA7A1-AiCoder.hf.space
|
Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PYTHONUTF8=1
|
| 5 |
+
COPY requirements.txt /app/requirements.txt
|
| 6 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 7 |
+
RUN mkdir -p /data/kapo_runtime/current /data/kapo_runtime/overlay
|
| 8 |
+
COPY brain_server /app/brain_server
|
| 9 |
+
COPY shared /app/shared
|
| 10 |
+
COPY .env /app/.env
|
| 11 |
+
COPY kapo.env /app/kapo.env
|
| 12 |
+
COPY bootstrap_space_runtime.py /app/bootstrap_space_runtime.py
|
| 13 |
+
CMD ["python", "/app/bootstrap_space_runtime.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ai_coder_main
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# ai_coder_main
|
| 11 |
+
|
| 12 |
+
Generated Hugging Face deployment package from KAPO Control Center.
|
| 13 |
+
|
| 14 |
+
Model profile: hf-coder-qwen25-coder-7b-instruct
|
| 15 |
+
Model repo: Qwen/Qwen2.5-Coder-1.5B-Instruct
|
| 16 |
+
Model file: not set
|
| 17 |
+
Roles: coding, planner, fallback
|
| 18 |
+
Languages: ar, en
|
| 19 |
+
|
| 20 |
+
This is a Docker-oriented Hugging Face Space package.
|
| 21 |
+
Push the extracted contents to a Docker Space repository, configure secrets/env vars, then set the resulting Base URL in the platform registry.
|
bootstrap_space_runtime.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
DEFAULT_ENV = {
|
| 9 |
+
"BRAIN_AUTO_NGROK": "0",
|
| 10 |
+
"BRAIN_AUTO_PUBLISH_URL_ON_STARTUP": "0",
|
| 11 |
+
"BRAIN_LANGUAGES": "ar,en",
|
| 12 |
+
"BRAIN_PLATFORM_NAME": "ai_coder_main",
|
| 13 |
+
"BRAIN_PROVIDER": "huggingface",
|
| 14 |
+
"BRAIN_REUSE_PUBLIC_URL_ON_RESTART": "0",
|
| 15 |
+
"BRAIN_ROLES": "coding,planner,fallback",
|
| 16 |
+
"BRAIN_TEMPLATE": "hf-space-cpu",
|
| 17 |
+
"BRAIN_TUNNEL_PROVIDER": "none",
|
| 18 |
+
"FIREBASE_ENABLED": "0",
|
| 19 |
+
"FIREBASE_NAMESPACE": "kapo",
|
| 20 |
+
"FIREBASE_PROJECT_ID": "citadel4travels",
|
| 21 |
+
"GOOGLE_DRIVE_BOOTSTRAP_URL": "https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG",
|
| 22 |
+
"HF_ACCELERATOR": "cpu",
|
| 23 |
+
"HF_SPACE_DOCKER": "1",
|
| 24 |
+
"KAGGLE_AUTO_BOOTSTRAP": "0",
|
| 25 |
+
"KAPO_BOOTSTRAP_URL": "https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG",
|
| 26 |
+
"KAPO_COMPUTE_PROFILE": "cpu",
|
| 27 |
+
"KAPO_HF_INFERENCE_API": "1",
|
| 28 |
+
"KAPO_HF_TRANSFORMERS_RUNTIME": "0",
|
| 29 |
+
"KAPO_LAZY_EMBED_STARTUP": "1",
|
| 30 |
+
"KAPO_LAZY_MODEL_STARTUP": "1",
|
| 31 |
+
"KAPO_PATCH_BUNDLE_URL": "https://drive.google.com/uc?export=download&id=16rIe05GZihhAz7ba8E-WibKaJKbh9eu1",
|
| 32 |
+
"KAPO_PATCH_MANIFEST_URL": "https://drive.google.com/uc?export=download&id=1jLuPMCA3hp9qstZZtpBNzTK0XOmLrV8b",
|
| 33 |
+
"KAPO_REMOTE_ENV_PASSWORD_B64": "cENvSnljQ0Z6RUN2azRpcUFLVUdQLW9BbVBoTEtNOFE",
|
| 34 |
+
"KAPO_REMOTE_ENV_URL_B64": "aHR0cHM6Ly9kcml2ZS5nb29nbGUuY29tL3VjP2V4cG9ydD1kb3dubG9hZCZpZD0xdjlZRDlNYlBKNUdYNk9WanVHM3hOR05xLWJfQlJfTGY",
|
| 35 |
+
"KAPO_SHARED_STATE_BACKEND": "google_drive",
|
| 36 |
+
"MODEL_PROFILE_ID": "hf-coder-qwen25-coder-7b-instruct",
|
| 37 |
+
"MODEL_REPO": "Qwen/Qwen2.5-Coder-1.5B-Instruct",
|
| 38 |
+
"REMOTE_BRAIN_ONLY": "1",
|
| 39 |
+
"SPACE_PUBLIC_URL": "https://MrA7A1-AiCoder.hf.space"
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _copy_tree(source: Path, target: Path) -> None:
|
| 44 |
+
if target.exists():
|
| 45 |
+
shutil.rmtree(target, ignore_errors=True)
|
| 46 |
+
shutil.copytree(
|
| 47 |
+
source,
|
| 48 |
+
target,
|
| 49 |
+
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.git', '.venv'),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _merge_overlay(overlay_root: Path, runtime_root: Path) -> None:
|
| 54 |
+
if not overlay_root.exists():
|
| 55 |
+
return
|
| 56 |
+
for path in sorted(overlay_root.rglob('*')):
|
| 57 |
+
rel = path.relative_to(overlay_root)
|
| 58 |
+
dst = runtime_root / rel
|
| 59 |
+
if path.is_dir():
|
| 60 |
+
dst.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
continue
|
| 62 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
shutil.copy2(path, dst)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main() -> None:
|
| 67 |
+
source_root = Path(os.getenv('KAPO_SPACE_SOURCE_ROOT', '/app')).resolve()
|
| 68 |
+
default_root = Path('/data/kapo_runtime/current') if Path('/data').exists() else Path('/tmp/kapo_runtime/current')
|
| 69 |
+
runtime_root = Path(os.getenv('KAPO_RUNTIME_ROOT', str(default_root))).resolve()
|
| 70 |
+
overlay_root = Path(os.getenv('KAPO_OVERLAY_ROOT', str(runtime_root.parent / 'overlay'))).resolve()
|
| 71 |
+
runtime_pkg = runtime_root / 'brain_server'
|
| 72 |
+
source_pkg = source_root / 'brain_server'
|
| 73 |
+
for key, value in DEFAULT_ENV.items():
|
| 74 |
+
os.environ.setdefault(str(key), str(value))
|
| 75 |
+
space_host = str(os.getenv('SPACE_HOST', '')).strip().rstrip('/')
|
| 76 |
+
space_id = str(os.getenv('SPACE_ID', '')).strip()
|
| 77 |
+
public_url = ''
|
| 78 |
+
if space_host:
|
| 79 |
+
public_url = space_host if space_host.startswith('http://') or space_host.startswith('https://') else f'https://{space_host}'
|
| 80 |
+
elif space_id and '/' in space_id:
|
| 81 |
+
public_url = 'https://' + space_id.replace('/', '-').lower() + '.hf.space'
|
| 82 |
+
if public_url:
|
| 83 |
+
os.environ['BRAIN_PUBLIC_URL'] = public_url.rstrip('/')
|
| 84 |
+
runtime_root.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
overlay_root.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
if not runtime_pkg.exists():
|
| 87 |
+
_copy_tree(source_pkg, runtime_pkg)
|
| 88 |
+
_merge_overlay(overlay_root, runtime_root)
|
| 89 |
+
os.environ['KAPO_RUNTIME_ROOT'] = str(runtime_root)
|
| 90 |
+
os.environ['KAPO_SYNC_ROOT'] = str(runtime_root)
|
| 91 |
+
os.environ['KAPO_OVERLAY_ROOT'] = str(overlay_root)
|
| 92 |
+
port = str(os.getenv('PORT', '7860') or '7860')
|
| 93 |
+
os.execvp(
|
| 94 |
+
sys.executable,
|
| 95 |
+
[
|
| 96 |
+
sys.executable,
|
| 97 |
+
'-m',
|
| 98 |
+
'uvicorn',
|
| 99 |
+
'api.main:app',
|
| 100 |
+
'--host',
|
| 101 |
+
'0.0.0.0',
|
| 102 |
+
'--port',
|
| 103 |
+
port,
|
| 104 |
+
'--app-dir',
|
| 105 |
+
str(runtime_pkg),
|
| 106 |
+
],
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == '__main__':
|
| 111 |
+
main()
|
brain_server/.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
brain_server/Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# تثبيت الأدوات الأساسية
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
curl \
|
| 9 |
+
git \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# نسخ requirements
|
| 13 |
+
COPY requirements.txt /app/requirements.txt
|
| 14 |
+
|
| 15 |
+
# تثبيت المكتبات
|
| 16 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 17 |
+
|
| 18 |
+
# نسخ المشروع بحيث يعمل المساران: /app/api و /app/brain_server/api
|
| 19 |
+
COPY . /app/brain_server/
|
| 20 |
+
COPY . /app/
|
| 21 |
+
|
| 22 |
+
ENV PYTHONUNBUFFERED=1
|
| 23 |
+
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
ENV PYTHONPATH=/app:/app/brain_server
|
| 27 |
+
|
| 28 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
brain_server/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Ai Brain
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
brain_server/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""???? ???????."""
|
brain_server/agents/auto_heal_agent.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-Heal Agent: ????? ?????? ??????? ???????."""
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
from api.deps import get_logger
|
| 5 |
+
|
| 6 |
+
logger = get_logger("kapo.agent.auto_heal")
|
| 7 |
+
|
| 8 |
+
ERROR_MAP = {
|
| 9 |
+
"ModuleNotFoundError": "pip install <package>",
|
| 10 |
+
"command not found": "apt-get install <package>",
|
| 11 |
+
"SyntaxError": "???? ????? ????? ????? ????????",
|
| 12 |
+
"Permission denied": "???? ?? ??????? ?????? ?? ?????? ???? ?????",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AutoHealAgent:
|
| 17 |
+
def suggest(self, error_text: str, step: Dict[str, Any]) -> Dict[str, Any]:
|
| 18 |
+
"""?????? ??? ????? ???? ??? ?????."""
|
| 19 |
+
for key, fix in ERROR_MAP.items():
|
| 20 |
+
if key in error_text:
|
| 21 |
+
return {"error": error_text, "suggested_fix": fix, "step": step}
|
| 22 |
+
return {"error": error_text, "suggested_fix": "???? ?? ????? ??????"}
|
brain_server/agents/memory_agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Memory Agent: ?????? ?????? ???????."""
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
from memory.short_term import ShortTermMemory
|
| 5 |
+
from memory.episodic_db import EpisodicDB
|
| 6 |
+
from memory.knowledge_vector import KnowledgeVectorStore
|
| 7 |
+
from api.deps import get_logger, is_remote_brain_only
|
| 8 |
+
from agents.supervisor_agent import AgentOS
|
| 9 |
+
|
| 10 |
+
logger = get_logger("kapo.agent.memory")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MemoryAgent:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.remote_only = is_remote_brain_only()
|
| 16 |
+
self.short = ShortTermMemory()
|
| 17 |
+
self.episodic = None if self.remote_only else EpisodicDB()
|
| 18 |
+
self.knowledge = None if self.remote_only else KnowledgeVectorStore()
|
| 19 |
+
self.agent_os = None if self.remote_only else AgentOS()
|
| 20 |
+
|
| 21 |
+
def write_short_term(self, key: str, value: Dict[str, Any]) -> None:
|
| 22 |
+
if self.remote_only:
|
| 23 |
+
return
|
| 24 |
+
self.short.set(key, value)
|
| 25 |
+
|
| 26 |
+
def read_short_term(self, key: str) -> Dict[str, Any] | None:
|
| 27 |
+
if self.remote_only:
|
| 28 |
+
return None
|
| 29 |
+
return self.short.get(key)
|
| 30 |
+
|
| 31 |
+
def store_experience(self, payload: Dict[str, Any]) -> None:
|
| 32 |
+
if self.remote_only or self.episodic is None:
|
| 33 |
+
return
|
| 34 |
+
self.episodic.insert_experience(
|
| 35 |
+
task=payload.get("task", ""),
|
| 36 |
+
plan=payload.get("plan", {}),
|
| 37 |
+
tools_used=payload.get("tools_used", {}),
|
| 38 |
+
result=payload.get("result", {}),
|
| 39 |
+
success=1 if payload.get("success") else 0,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def query_knowledge(self, text: str, top_k: int = 3):
|
| 43 |
+
if self.remote_only or self.knowledge is None:
|
| 44 |
+
return []
|
| 45 |
+
return self.knowledge.query(text, top_k=top_k)
|
| 46 |
+
|
| 47 |
+
def run_agent_os(self):
|
| 48 |
+
"""????? ???? ??????? ?????? ??????."""
|
| 49 |
+
if self.remote_only or self.agent_os is None:
|
| 50 |
+
return {"skipped": True, "reason": "REMOTE_BRAIN_ONLY"}
|
| 51 |
+
return self.agent_os.run()
|
brain_server/agents/planner_agent.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Planner agent that builds structured execution-friendly plans."""
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from api.deps import get_logger
|
| 6 |
+
|
| 7 |
+
logger = get_logger("kapo.agent.planner")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _contains_any(text: str, terms: tuple[str, ...]) -> bool:
|
| 11 |
+
lowered = text.lower()
|
| 12 |
+
return any(term in lowered for term in terms)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class PlannerAgent:
|
| 16 |
+
def _base_step(self, step_id: str, action: str, user_input: str, tool_hint: str, role: str) -> dict[str, Any]:
|
| 17 |
+
return {
|
| 18 |
+
"id": step_id,
|
| 19 |
+
"action": action,
|
| 20 |
+
"input": user_input,
|
| 21 |
+
"tool_hint": tool_hint,
|
| 22 |
+
"role": role,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
def run(self, user_input: str, context: dict[str, Any]) -> list[dict[str, Any]]:
|
| 26 |
+
try:
|
| 27 |
+
text = (user_input or "").strip()
|
| 28 |
+
if not text:
|
| 29 |
+
return [self._base_step("step-1", "respond", "Empty request", "python", "chat")]
|
| 30 |
+
|
| 31 |
+
context = context or {}
|
| 32 |
+
steps: list[dict[str, Any]] = [
|
| 33 |
+
{
|
| 34 |
+
**self._base_step("step-1", "analyze", text, "python", "planner"),
|
| 35 |
+
"context_keys": sorted(context.keys()),
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
research_terms = ("search", "research", "browse", "look up", "find out", "ابحث", "بحث", "دور", "فتش")
|
| 40 |
+
coding_terms = (
|
| 41 |
+
"build", "fix", "debug", "refactor", "implement", "generate", "write code", "api", "fastapi", "python",
|
| 42 |
+
"react", "repo", "project", "اصلح", "نفذ", "شغل", "عدل", "ابني", "برمجة", "كود", "مشروع",
|
| 43 |
+
)
|
| 44 |
+
planning_terms = ("plan", "roadmap", "architecture", "analyze", "design", "structure", "خطة", "بنية", "معمارية", "حلل")
|
| 45 |
+
explain_terms = ("explain", "describe", "summarize", "اشرح", "وضح", "لخص", "عرفني")
|
| 46 |
+
|
| 47 |
+
is_research = _contains_any(text, research_terms)
|
| 48 |
+
is_coding = _contains_any(text, coding_terms)
|
| 49 |
+
is_planning = _contains_any(text, planning_terms)
|
| 50 |
+
is_explainer = _contains_any(text, explain_terms)
|
| 51 |
+
|
| 52 |
+
if is_research:
|
| 53 |
+
steps.extend(
|
| 54 |
+
[
|
| 55 |
+
self._base_step("step-2", "research", text, "web", "chat"),
|
| 56 |
+
self._base_step("step-3", "synthesize", "Summarize and cite the most relevant findings", "python", "supervisor"),
|
| 57 |
+
]
|
| 58 |
+
)
|
| 59 |
+
elif is_coding:
|
| 60 |
+
steps.extend(
|
| 61 |
+
[
|
| 62 |
+
self._base_step("step-2", "collect_requirements", "Inspect impacted files, dependencies, and constraints", "python", "planner"),
|
| 63 |
+
self._base_step("step-3", "execute", text, "python", "coding"),
|
| 64 |
+
self._base_step("step-4", "verify", "Run validation checks and inspect resulting output", "python", "coding"),
|
| 65 |
+
self._base_step("step-5", "summarize", "Summarize what changed, risks, and verification status", "python", "supervisor"),
|
| 66 |
+
]
|
| 67 |
+
)
|
| 68 |
+
elif is_planning:
|
| 69 |
+
steps.extend(
|
| 70 |
+
[
|
| 71 |
+
self._base_step("step-2", "decompose", "Break the request into phases, dependencies, and risks", "python", "planner"),
|
| 72 |
+
self._base_step("step-3", "respond", "Provide a structured implementation plan", "python", "supervisor"),
|
| 73 |
+
]
|
| 74 |
+
)
|
| 75 |
+
elif is_explainer:
|
| 76 |
+
steps.extend(
|
| 77 |
+
[
|
| 78 |
+
self._base_step("step-2", "collect_context", "Gather the minimum relevant context for explanation", "python", "chat"),
|
| 79 |
+
self._base_step("step-3", "respond", "Explain the topic clearly and directly", "python", "supervisor"),
|
| 80 |
+
]
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
steps.append(self._base_step("step-2", "respond", text, "python", "chat"))
|
| 84 |
+
|
| 85 |
+
return steps
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
logger.exception("Planner failed")
|
| 88 |
+
return [{"id": "step-err", "action": "error", "input": str(exc), "tool_hint": "python", "role": "fallback"}]
|
brain_server/agents/reasoning_agent.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reasoning Agent: ???? ????? ????? ?????."""
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from api.deps import get_logger
|
| 5 |
+
|
| 6 |
+
logger = get_logger("kapo.agent.reasoning")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ReasoningAgent:
|
| 10 |
+
def run(self, user_input: str, plan: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 11 |
+
try:
|
| 12 |
+
logger.info("Reasoning", extra={"component": "reasoning"})
|
| 13 |
+
return {
|
| 14 |
+
"summary": "?? ????? ????? ??? ????? ????? ??????.",
|
| 15 |
+
"steps_count": len(plan),
|
| 16 |
+
"input": user_input,
|
| 17 |
+
}
|
| 18 |
+
except Exception as exc:
|
| 19 |
+
logger.exception("Reasoning failed")
|
| 20 |
+
return {"error": str(exc)}
|
brain_server/agents/supervisor_agent.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supervisor Agent: ????? ??????? ????? ??????."""
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any, Dict, List
|
| 4 |
+
from api.deps import get_logger
|
| 5 |
+
from memory.episodic_db import EpisodicDB
|
| 6 |
+
|
| 7 |
+
logger = get_logger("kapo.agent.supervisor")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SupervisorAgent:
|
| 11 |
+
def review(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 12 |
+
try:
|
| 13 |
+
success = all(r.get("exit_code", 0) == 0 for r in results if isinstance(r, dict))
|
| 14 |
+
return {"success": success, "results_count": len(results)}
|
| 15 |
+
except Exception as exc:
|
| 16 |
+
logger.exception("Supervisor failed")
|
| 17 |
+
return {"success": False, "error": str(exc)}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AgentOS:
|
| 21 |
+
"""Self-Improving loop reading episodic DB and proposing prompt updates."""
|
| 22 |
+
|
| 23 |
+
def run(self) -> Dict[str, Any]:
|
| 24 |
+
try:
|
| 25 |
+
db = EpisodicDB()
|
| 26 |
+
recent = db.list_recent(limit=20)
|
| 27 |
+
success_rate = 0
|
| 28 |
+
if recent:
|
| 29 |
+
success_rate = sum(1 for r in recent if r.get("success")) / len(recent)
|
| 30 |
+
proposal = {
|
| 31 |
+
"summary": f"Recent success rate: {success_rate:.2f}",
|
| 32 |
+
"prompt_update": "?????? ????? ????? Planner ??? ?????? ??????.",
|
| 33 |
+
"sandbox_test": "Run simulated plan execution in sandbox before promotion.",
|
| 34 |
+
}
|
| 35 |
+
return proposal
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
logger.exception("AgentOS failed")
|
| 38 |
+
return {"error": str(exc)}
|
brain_server/agents/tool_selector_agent.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool selector for remote execution steps."""
|
| 2 |
+
import sqlite3
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from api.deps import get_logger, load_config
|
| 6 |
+
|
| 7 |
+
logger = get_logger("kapo.agent.tool_selector")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ToolSelectorAgent:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.cfg = load_config()
|
| 13 |
+
|
| 14 |
+
def _load_catalog(self) -> list[dict[str, Any]]:
|
| 15 |
+
tools_db = self.cfg.get("TOOLS_DB_PATH")
|
| 16 |
+
if not tools_db or not str(tools_db).endswith(".db"):
|
| 17 |
+
return []
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
conn = sqlite3.connect(str(tools_db))
|
| 21 |
+
cur = conn.cursor()
|
| 22 |
+
cur.execute(
|
| 23 |
+
"""
|
| 24 |
+
SELECT tool_name, install_command, path, description, installed
|
| 25 |
+
FROM tools
|
| 26 |
+
"""
|
| 27 |
+
)
|
| 28 |
+
rows = cur.fetchall()
|
| 29 |
+
conn.close()
|
| 30 |
+
except sqlite3.OperationalError:
|
| 31 |
+
return []
|
| 32 |
+
except Exception:
|
| 33 |
+
logger.exception("Tool catalog load failed")
|
| 34 |
+
return []
|
| 35 |
+
|
| 36 |
+
return [
|
| 37 |
+
{
|
| 38 |
+
"tool_name": row[0],
|
| 39 |
+
"install_command": row[1],
|
| 40 |
+
"path": row[2],
|
| 41 |
+
"description": row[3] or "",
|
| 42 |
+
"installed": bool(row[4]),
|
| 43 |
+
}
|
| 44 |
+
for row in rows
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
def _step_text(self, step: dict[str, Any]) -> str:
|
| 48 |
+
for key in ("input", "description", "title", "summary", "task", "prompt"):
|
| 49 |
+
value = str(step.get(key, "")).strip()
|
| 50 |
+
if value:
|
| 51 |
+
return value
|
| 52 |
+
return ""
|
| 53 |
+
|
| 54 |
+
def _fallback_tool(self, tool_hint: str) -> dict[str, Any]:
|
| 55 |
+
name = f"fallback_{tool_hint or 'command'}"
|
| 56 |
+
return {
|
| 57 |
+
"tool_name": name,
|
| 58 |
+
"description": "Synthetic fallback tool selected by ToolSelectorAgent",
|
| 59 |
+
"path": tool_hint or "",
|
| 60 |
+
"installed": True,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
def _fallback_command(self, step: dict[str, Any], tool_hint: str = "") -> str:
|
| 64 |
+
action = str(step.get("action", "")).strip().lower()
|
| 65 |
+
step_input = self._step_text(step)
|
| 66 |
+
explicit_command = str(step.get("command", "")).strip()
|
| 67 |
+
if explicit_command:
|
| 68 |
+
return explicit_command
|
| 69 |
+
if action == "research":
|
| 70 |
+
return f"echo Research task queued: {step_input}".strip()
|
| 71 |
+
if action in {"verify", "summarize", "respond", "synthesize"}:
|
| 72 |
+
if step_input:
|
| 73 |
+
return f"python -c \"print({step_input!r})\""
|
| 74 |
+
return "echo Verification requested"
|
| 75 |
+
if action in {"execute", "collect_requirements", "analyze", "decompose", "collect_context"}:
|
| 76 |
+
return f"python -c \"print({step_input!r})\"" if step_input else "python -c \"print('step received')\""
|
| 77 |
+
if tool_hint in {"python", "py"} or not tool_hint:
|
| 78 |
+
return f"python -c \"print({step_input!r})\"" if step_input else "python -c \"print('step received')\""
|
| 79 |
+
if step_input:
|
| 80 |
+
return f"echo {step_input}"
|
| 81 |
+
return "echo Step received"
|
| 82 |
+
|
| 83 |
+
def select_tool(self, step: dict[str, Any]) -> dict[str, Any]:
|
| 84 |
+
action = str(step.get("action", "")).strip().lower()
|
| 85 |
+
tool_hint = str(step.get("tool_hint", "")).strip().lower()
|
| 86 |
+
step_input = self._step_text(step).lower()
|
| 87 |
+
catalog = self._load_catalog()
|
| 88 |
+
|
| 89 |
+
for tool in catalog:
|
| 90 |
+
haystack = " ".join([tool["tool_name"], tool["description"], tool["path"] or ""]).lower()
|
| 91 |
+
if tool_hint and tool_hint in haystack:
|
| 92 |
+
return {"command": tool["path"] or tool["tool_name"], "files": {}, "env": {}, "tool": tool}
|
| 93 |
+
if action and action in haystack:
|
| 94 |
+
return {"command": tool["path"] or tool["tool_name"], "files": {}, "env": {}, "tool": tool}
|
| 95 |
+
if step_input and any(token in haystack for token in step_input.split()[:4]):
|
| 96 |
+
return {"command": tool["path"] or tool["tool_name"], "files": {}, "env": {}, "tool": tool}
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"command": self._fallback_command(step, tool_hint=tool_hint),
|
| 100 |
+
"files": step.get("files", {}),
|
| 101 |
+
"env": step.get("env", {}),
|
| 102 |
+
"tool": self._fallback_tool(tool_hint or "python"),
|
| 103 |
+
}
|
brain_server/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API package."""
|
brain_server/api/deps.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Common dependency utilities for Brain API."""
|
| 2 |
+
import logging
|
| 3 |
+
import logging.config
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from shared.remote_env import load_remote_env_if_configured
|
| 11 |
+
|
| 12 |
+
CONFIG_CACHE: dict[str, Any] | None = None
|
| 13 |
+
_LOGGING_READY = False
|
| 14 |
+
PLACEHOLDER_RE = re.compile(r"^\$\{[A-Z0-9_]+\}$")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _load_env_stack() -> None:
|
| 18 |
+
candidates = [
|
| 19 |
+
".env",
|
| 20 |
+
"kapo.env",
|
| 21 |
+
".env.runtime",
|
| 22 |
+
]
|
| 23 |
+
for candidate in candidates:
|
| 24 |
+
try:
|
| 25 |
+
load_dotenv(candidate, override=True)
|
| 26 |
+
except Exception:
|
| 27 |
+
continue
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _normalize_config_paths(cfg: dict[str, Any]) -> dict[str, Any]:
|
| 31 |
+
if os.name != "nt":
|
| 32 |
+
return cfg
|
| 33 |
+
|
| 34 |
+
root = os.getcwd()
|
| 35 |
+
normalized = dict(cfg)
|
| 36 |
+
path_keys = {
|
| 37 |
+
"DB_PATH",
|
| 38 |
+
"TOOLS_DB_PATH",
|
| 39 |
+
"FAISS_INDEX_PATH",
|
| 40 |
+
"BRAIN_LOG_PATH",
|
| 41 |
+
"EXEC_LOG_PATH",
|
| 42 |
+
"LOCAL_DATA_DIR",
|
| 43 |
+
}
|
| 44 |
+
for key in path_keys:
|
| 45 |
+
value = normalized.get(key)
|
| 46 |
+
if not isinstance(value, str) or not value:
|
| 47 |
+
continue
|
| 48 |
+
if value.startswith("/data"):
|
| 49 |
+
normalized[key] = os.path.join(root, "data", value[len("/data"):].lstrip("/\\"))
|
| 50 |
+
elif value.startswith("/models"):
|
| 51 |
+
normalized[key] = os.path.join(root, "models", value[len("/models"):].lstrip("/\\"))
|
| 52 |
+
return normalized
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _strip_unresolved_placeholders(value):
|
| 56 |
+
if isinstance(value, dict):
|
| 57 |
+
return {key: _strip_unresolved_placeholders(item) for key, item in value.items()}
|
| 58 |
+
if isinstance(value, list):
|
| 59 |
+
return [_strip_unresolved_placeholders(item) for item in value]
|
| 60 |
+
if isinstance(value, str) and PLACEHOLDER_RE.match(value.strip()):
|
| 61 |
+
return ""
|
| 62 |
+
return value
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_config() -> dict:
|
| 66 |
+
global CONFIG_CACHE
|
| 67 |
+
if CONFIG_CACHE is not None:
|
| 68 |
+
return CONFIG_CACHE
|
| 69 |
+
|
| 70 |
+
_load_env_stack()
|
| 71 |
+
load_remote_env_if_configured(override=True, logger_name="kapo.brain.remote_env")
|
| 72 |
+
config_path = os.path.join(os.path.dirname(__file__), "..", "config", "config.yaml")
|
| 73 |
+
with open(config_path, "r", encoding="utf-8") as handle:
|
| 74 |
+
raw = handle.read()
|
| 75 |
+
|
| 76 |
+
for key, value in os.environ.items():
|
| 77 |
+
raw = raw.replace(f"${{{key}}}", value)
|
| 78 |
+
|
| 79 |
+
parsed = yaml.safe_load(raw) or {}
|
| 80 |
+
CONFIG_CACHE = _normalize_config_paths(_strip_unresolved_placeholders(parsed))
|
| 81 |
+
return CONFIG_CACHE
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_remote_brain_only() -> bool:
|
| 85 |
+
cfg = load_config()
|
| 86 |
+
value = cfg.get("REMOTE_BRAIN_ONLY", os.getenv("REMOTE_BRAIN_ONLY", "0"))
|
| 87 |
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def setup_logging() -> None:
|
| 91 |
+
global _LOGGING_READY
|
| 92 |
+
if _LOGGING_READY:
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
log_cfg_path = os.path.join(os.path.dirname(__file__), "..", "config", "logging.yaml")
|
| 96 |
+
if not os.path.exists(log_cfg_path):
|
| 97 |
+
logging.basicConfig(level=logging.INFO)
|
| 98 |
+
_LOGGING_READY = True
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
with open(log_cfg_path, "r", encoding="utf-8") as handle:
|
| 103 |
+
cfg = yaml.safe_load(handle) or {}
|
| 104 |
+
logging.config.dictConfig(cfg)
|
| 105 |
+
except Exception:
|
| 106 |
+
logging.basicConfig(level=logging.INFO)
|
| 107 |
+
logging.getLogger("kapo").warning("Falling back to basic logging configuration")
|
| 108 |
+
|
| 109 |
+
_LOGGING_READY = True
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def get_logger(name: str) -> logging.Logger:
|
| 113 |
+
setup_logging()
|
| 114 |
+
return logging.getLogger(name)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _normalize_base_url(candidate: Any) -> str:
|
| 118 |
+
text = "" if candidate is None else str(candidate).strip()
|
| 119 |
+
if not text:
|
| 120 |
+
return ""
|
| 121 |
+
if "://" not in text:
|
| 122 |
+
text = f"http://{text}"
|
| 123 |
+
return text.rstrip("/")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_executor_url(cfg: dict) -> str:
|
| 127 |
+
env_url = _normalize_base_url(os.getenv("EXECUTOR_URL"))
|
| 128 |
+
if env_url:
|
| 129 |
+
return env_url
|
| 130 |
+
|
| 131 |
+
cfg_url = _normalize_base_url(cfg.get("EXECUTOR_URL"))
|
| 132 |
+
if cfg_url:
|
| 133 |
+
return cfg_url
|
| 134 |
+
|
| 135 |
+
scheme = str(cfg.get("EXECUTOR_SCHEME") or os.getenv("EXECUTOR_SCHEME", "http")).strip() or "http"
|
| 136 |
+
host = str(cfg.get("EXECUTOR_HOST") or os.getenv("EXECUTOR_HOST", "localhost")).strip()
|
| 137 |
+
port = str(cfg.get("EXECUTOR_PORT") or os.getenv("EXECUTOR_PORT", "9000")).strip()
|
| 138 |
+
|
| 139 |
+
if "://" in host:
|
| 140 |
+
return host.rstrip("/")
|
| 141 |
+
if ":" in host:
|
| 142 |
+
return f"{scheme}://{host}".rstrip("/")
|
| 143 |
+
return f"{scheme}://{host}:{port}".rstrip("/")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_executor_headers(cfg: dict) -> dict:
|
| 147 |
+
header = cfg.get("EXECUTOR_BYPASS_HEADER") or os.getenv("EXECUTOR_BYPASS_HEADER")
|
| 148 |
+
value = cfg.get("EXECUTOR_BYPASS_VALUE") or os.getenv("EXECUTOR_BYPASS_VALUE")
|
| 149 |
+
if header and value:
|
| 150 |
+
return {str(header): str(value)}
|
| 151 |
+
return {}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_brain_headers(cfg: dict) -> dict:
|
| 155 |
+
header = cfg.get("BRAIN_BYPASS_HEADER") or os.getenv("BRAIN_BYPASS_HEADER")
|
| 156 |
+
value = cfg.get("BRAIN_BYPASS_VALUE") or os.getenv("BRAIN_BYPASS_VALUE")
|
| 157 |
+
if header and value:
|
| 158 |
+
return {str(header): str(value)}
|
| 159 |
+
return {}
|
brain_server/api/firebase_store.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared-state store for brain runtime via Google Drive, local files, or Firebase."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from shared.google_drive_state import GoogleDriveStateClient
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class FirebaseStore:
|
| 17 |
+
def __init__(self, component: str, logger_name: str = "kapo.brain.firebase") -> None:
|
| 18 |
+
self.component = component
|
| 19 |
+
self.logger = logging.getLogger(logger_name)
|
| 20 |
+
self._db = None
|
| 21 |
+
self._lock = threading.Lock()
|
| 22 |
+
self._read_cache: dict[str, tuple[float, Any]] = {}
|
| 23 |
+
self._list_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
| 24 |
+
self._write_cache: dict[str, tuple[float, str]] = {}
|
| 25 |
+
self._quota_backoff_until: float = 0.0
|
| 26 |
+
self._drive = GoogleDriveStateClient(self.logger)
|
| 27 |
+
|
| 28 |
+
def backend(self) -> str:
|
| 29 |
+
configured = str(os.getenv("KAPO_SHARED_STATE_BACKEND", "")).strip().lower()
|
| 30 |
+
if configured in {"google_drive", "drive", "gdrive"}:
|
| 31 |
+
return "google_drive"
|
| 32 |
+
if configured in {"file", "files"}:
|
| 33 |
+
return "file"
|
| 34 |
+
if configured in {"firebase", "firestore"}:
|
| 35 |
+
return "firebase"
|
| 36 |
+
if configured in {"disabled", "off", "none"}:
|
| 37 |
+
return "disabled"
|
| 38 |
+
if self._drive.enabled():
|
| 39 |
+
return "google_drive"
|
| 40 |
+
if str(os.getenv("FIREBASE_ENABLED", "0")).strip().lower() in {"1", "true", "yes", "on"}:
|
| 41 |
+
return "firebase"
|
| 42 |
+
return "file"
|
| 43 |
+
|
| 44 |
+
def enabled(self) -> bool:
|
| 45 |
+
return self.backend() != "disabled"
|
| 46 |
+
|
| 47 |
+
def namespace(self) -> str:
|
| 48 |
+
return str(os.getenv("FIREBASE_NAMESPACE", "kapo")).strip() or "kapo"
|
| 49 |
+
|
| 50 |
+
def storage_root(self) -> Path:
|
| 51 |
+
configured = str(os.getenv("KAPO_SHARED_STATE_DIR", "")).strip()
|
| 52 |
+
if configured:
|
| 53 |
+
root = Path(configured).expanduser()
|
| 54 |
+
if not root.is_absolute():
|
| 55 |
+
root = Path.cwd().resolve() / root
|
| 56 |
+
else:
|
| 57 |
+
root = (Path.cwd().resolve() / "data" / "local" / "shared_state").resolve()
|
| 58 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
return root
|
| 60 |
+
|
| 61 |
+
def _service_payload(self) -> dict[str, Any] | None:
|
| 62 |
+
raw = str(os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON", "")).strip()
|
| 63 |
+
if not raw:
|
| 64 |
+
return None
|
| 65 |
+
try:
|
| 66 |
+
return json.loads(raw)
|
| 67 |
+
except Exception:
|
| 68 |
+
self.logger.exception("Invalid Firebase service account JSON")
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _service_path() -> str:
|
| 73 |
+
return str(os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH", "")).strip()
|
| 74 |
+
|
| 75 |
+
def _client(self):
|
| 76 |
+
if self.backend() != "firebase":
|
| 77 |
+
return None
|
| 78 |
+
with self._lock:
|
| 79 |
+
if self._db is not None:
|
| 80 |
+
return self._db
|
| 81 |
+
try:
|
| 82 |
+
import firebase_admin
|
| 83 |
+
from firebase_admin import credentials, firestore
|
| 84 |
+
|
| 85 |
+
if not firebase_admin._apps:
|
| 86 |
+
payload = self._service_payload()
|
| 87 |
+
if payload:
|
| 88 |
+
cred = credentials.Certificate(payload)
|
| 89 |
+
else:
|
| 90 |
+
service_path = self._service_path()
|
| 91 |
+
if not service_path:
|
| 92 |
+
return None
|
| 93 |
+
path_obj = Path(service_path).expanduser()
|
| 94 |
+
if not path_obj.exists() or not path_obj.is_file():
|
| 95 |
+
self.logger.warning(
|
| 96 |
+
"Firebase service account path is unavailable on this runtime: %s",
|
| 97 |
+
service_path,
|
| 98 |
+
)
|
| 99 |
+
return None
|
| 100 |
+
cred = credentials.Certificate(str(path_obj.resolve()))
|
| 101 |
+
options = {}
|
| 102 |
+
project_id = str(os.getenv("FIREBASE_PROJECT_ID", "")).strip()
|
| 103 |
+
if project_id:
|
| 104 |
+
options["projectId"] = project_id
|
| 105 |
+
firebase_admin.initialize_app(cred, options or None)
|
| 106 |
+
self._db = firestore.client()
|
| 107 |
+
return self._db
|
| 108 |
+
except Exception:
|
| 109 |
+
self.logger.exception("Failed to initialize Firebase client")
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
def _collection(self, name: str) -> str:
|
| 113 |
+
return f"{self.namespace()}_{name}"
|
| 114 |
+
|
| 115 |
+
@staticmethod
|
| 116 |
+
def _safe_id(value: str, default: str = "default") -> str:
|
| 117 |
+
text = str(value or "").strip() or default
|
| 118 |
+
return "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in text)[:180]
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def _payload_hash(payload: Any) -> str:
|
| 122 |
+
return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def _is_quota_error(exc: Exception) -> bool:
|
| 126 |
+
text = str(exc or "").lower()
|
| 127 |
+
return "resourceexhausted" in text or "quota exceeded" in text or "429" in text
|
| 128 |
+
|
| 129 |
+
def _quota_backoff_active(self) -> bool:
|
| 130 |
+
return time.time() < self._quota_backoff_until
|
| 131 |
+
|
| 132 |
+
def _activate_quota_backoff(self, seconds: float | None = None) -> None:
|
| 133 |
+
delay = float(seconds or os.getenv("FIREBASE_QUOTA_BACKOFF_SEC", "120") or 120)
|
| 134 |
+
self._quota_backoff_until = max(self._quota_backoff_until, time.time() + max(5.0, delay))
|
| 135 |
+
|
| 136 |
+
def _should_skip_write(self, key: str, payload: Any, min_interval_sec: float) -> bool:
|
| 137 |
+
now = time.time()
|
| 138 |
+
payload_hash = self._payload_hash(payload)
|
| 139 |
+
last = self._write_cache.get(key)
|
| 140 |
+
if last and last[1] == payload_hash and (now - last[0]) < min_interval_sec:
|
| 141 |
+
return True
|
| 142 |
+
self._write_cache[key] = (now, payload_hash)
|
| 143 |
+
return False
|
| 144 |
+
|
| 145 |
+
def _file_collection_dir(self, collection: str) -> Path:
|
| 146 |
+
path = self.storage_root() / self._safe_id(collection, "collection")
|
| 147 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 148 |
+
return path
|
| 149 |
+
|
| 150 |
+
def _file_doc_path(self, collection: str, doc_id: str) -> Path:
|
| 151 |
+
return self._file_collection_dir(collection) / f"{self._safe_id(doc_id)}.json"
|
| 152 |
+
|
| 153 |
+
def _write_json_atomic(self, path: Path, payload: dict[str, Any]) -> None:
|
| 154 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
tmp = path.with_suffix(f"{path.suffix}.tmp")
|
| 156 |
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
| 157 |
+
tmp.replace(path)
|
| 158 |
+
|
| 159 |
+
def get_document(self, collection: str, doc_id: str, ttl_sec: float = 12.0) -> dict[str, Any]:
|
| 160 |
+
safe_doc = self._safe_id(doc_id)
|
| 161 |
+
cache_key = f"{collection}:{safe_doc}"
|
| 162 |
+
now = time.time()
|
| 163 |
+
cached = self._read_cache.get(cache_key)
|
| 164 |
+
if cached and (now - cached[0]) < ttl_sec:
|
| 165 |
+
return dict(cached[1] or {})
|
| 166 |
+
backend = self.backend()
|
| 167 |
+
if backend == "google_drive":
|
| 168 |
+
payload = self._drive.get_document(collection, safe_doc)
|
| 169 |
+
self._read_cache[cache_key] = (now, payload)
|
| 170 |
+
return dict(payload or {})
|
| 171 |
+
if backend == "file":
|
| 172 |
+
path = self._file_doc_path(collection, safe_doc)
|
| 173 |
+
if not path.exists():
|
| 174 |
+
return {}
|
| 175 |
+
try:
|
| 176 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 177 |
+
except Exception:
|
| 178 |
+
self.logger.warning("Failed to read shared-state file %s", path, exc_info=True)
|
| 179 |
+
return {}
|
| 180 |
+
self._read_cache[cache_key] = (now, payload)
|
| 181 |
+
return dict(payload or {})
|
| 182 |
+
if self._quota_backoff_active():
|
| 183 |
+
return dict(cached[1] or {}) if cached else {}
|
| 184 |
+
db = self._client()
|
| 185 |
+
if db is None:
|
| 186 |
+
return dict(cached[1] or {}) if cached else {}
|
| 187 |
+
try:
|
| 188 |
+
snapshot = db.collection(self._collection(collection)).document(safe_doc).get()
|
| 189 |
+
payload = snapshot.to_dict() if snapshot.exists else {}
|
| 190 |
+
self._read_cache[cache_key] = (now, payload)
|
| 191 |
+
return dict(payload or {})
|
| 192 |
+
except Exception as exc:
|
| 193 |
+
if self._is_quota_error(exc):
|
| 194 |
+
self._activate_quota_backoff()
|
| 195 |
+
self.logger.warning("Firebase quota exceeded while reading %s/%s; using cache/backoff", collection, safe_doc)
|
| 196 |
+
else:
|
| 197 |
+
self.logger.exception("Failed to read Firebase document %s/%s", collection, safe_doc)
|
| 198 |
+
return dict(cached[1] or {}) if cached else {}
|
| 199 |
+
|
| 200 |
+
def set_document(self, collection: str, doc_id: str, payload: dict[str, Any], merge: bool = True, min_interval_sec: float = 5.0) -> bool:
|
| 201 |
+
safe_doc = self._safe_id(doc_id)
|
| 202 |
+
cache_key = f"{collection}:{safe_doc}"
|
| 203 |
+
body = dict(payload or {})
|
| 204 |
+
body["component"] = self.component
|
| 205 |
+
body["updated_at"] = time.time()
|
| 206 |
+
if self._should_skip_write(cache_key, body, min_interval_sec):
|
| 207 |
+
return True
|
| 208 |
+
backend = self.backend()
|
| 209 |
+
if backend == "google_drive":
|
| 210 |
+
stored = self._drive.set_document(collection, safe_doc, body, merge=merge)
|
| 211 |
+
if stored:
|
| 212 |
+
self._read_cache.pop(cache_key, None)
|
| 213 |
+
stale_prefix = f"{collection}:list:"
|
| 214 |
+
for key in list(self._list_cache.keys()):
|
| 215 |
+
if key.startswith(stale_prefix):
|
| 216 |
+
self._list_cache.pop(key, None)
|
| 217 |
+
return stored
|
| 218 |
+
if backend == "file":
|
| 219 |
+
try:
|
| 220 |
+
path = self._file_doc_path(collection, safe_doc)
|
| 221 |
+
existing = {}
|
| 222 |
+
if merge and path.exists():
|
| 223 |
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
| 224 |
+
combined = {**existing, **body} if merge else body
|
| 225 |
+
combined.setdefault("id", safe_doc)
|
| 226 |
+
self._write_json_atomic(path, combined)
|
| 227 |
+
self._read_cache.pop(cache_key, None)
|
| 228 |
+
stale_prefix = f"{collection}:list:"
|
| 229 |
+
for key in list(self._list_cache.keys()):
|
| 230 |
+
if key.startswith(stale_prefix):
|
| 231 |
+
self._list_cache.pop(key, None)
|
| 232 |
+
return True
|
| 233 |
+
except Exception:
|
| 234 |
+
self.logger.warning("Failed to write shared-state file %s/%s", collection, safe_doc, exc_info=True)
|
| 235 |
+
return False
|
| 236 |
+
if self._quota_backoff_active():
|
| 237 |
+
return False
|
| 238 |
+
db = self._client()
|
| 239 |
+
if db is None:
|
| 240 |
+
return False
|
| 241 |
+
try:
|
| 242 |
+
if self._should_skip_write(cache_key, body, min_interval_sec):
|
| 243 |
+
return True
|
| 244 |
+
db.collection(self._collection(collection)).document(safe_doc).set(body, merge=merge)
|
| 245 |
+
self._read_cache.pop(cache_key, None)
|
| 246 |
+
stale_prefix = f"{collection}:list:"
|
| 247 |
+
for key in list(self._list_cache.keys()):
|
| 248 |
+
if key.startswith(stale_prefix):
|
| 249 |
+
self._list_cache.pop(key, None)
|
| 250 |
+
return True
|
| 251 |
+
except Exception as exc:
|
| 252 |
+
if self._is_quota_error(exc):
|
| 253 |
+
self._activate_quota_backoff()
|
| 254 |
+
self.logger.warning("Firebase quota exceeded while writing %s/%s; write skipped", collection, safe_doc)
|
| 255 |
+
else:
|
| 256 |
+
self.logger.exception("Failed to write Firebase document %s/%s", collection, safe_doc)
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
def list_documents(self, collection: str, limit: int = 200, ttl_sec: float = 30.0) -> list[dict[str, Any]]:
|
| 260 |
+
cache_key = f"{collection}:list:{max(1, int(limit))}"
|
| 261 |
+
now = time.time()
|
| 262 |
+
cached = self._list_cache.get(cache_key)
|
| 263 |
+
if cached and (now - cached[0]) < ttl_sec:
|
| 264 |
+
return [dict(item) for item in (cached[1] or [])]
|
| 265 |
+
backend = self.backend()
|
| 266 |
+
if backend == "google_drive":
|
| 267 |
+
items = self._drive.list_documents(collection, limit=max(1, int(limit)))
|
| 268 |
+
self._list_cache[cache_key] = (now, [dict(item) for item in items])
|
| 269 |
+
return items
|
| 270 |
+
if backend == "file":
|
| 271 |
+
items: list[dict[str, Any]] = []
|
| 272 |
+
try:
|
| 273 |
+
paths = sorted(
|
| 274 |
+
self._file_collection_dir(collection).glob("*.json"),
|
| 275 |
+
key=lambda path: path.stat().st_mtime,
|
| 276 |
+
reverse=True,
|
| 277 |
+
)
|
| 278 |
+
for path in paths[: max(1, int(limit))]:
|
| 279 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 280 |
+
payload.setdefault("id", path.stem)
|
| 281 |
+
items.append(payload)
|
| 282 |
+
except Exception:
|
| 283 |
+
self.logger.warning("Failed to list shared-state files for %s", collection, exc_info=True)
|
| 284 |
+
self._list_cache[cache_key] = (now, [dict(item) for item in items])
|
| 285 |
+
return items
|
| 286 |
+
if self._quota_backoff_active():
|
| 287 |
+
return [dict(item) for item in ((cached[1] if cached else []) or [])]
|
| 288 |
+
db = self._client()
|
| 289 |
+
if db is None:
|
| 290 |
+
return [dict(item) for item in ((cached[1] if cached else []) or [])]
|
| 291 |
+
try:
|
| 292 |
+
docs = db.collection(self._collection(collection)).limit(max(1, int(limit))).stream()
|
| 293 |
+
items: list[dict[str, Any]] = []
|
| 294 |
+
for doc in docs:
|
| 295 |
+
payload = doc.to_dict() or {}
|
| 296 |
+
payload.setdefault("id", doc.id)
|
| 297 |
+
items.append(payload)
|
| 298 |
+
self._list_cache[cache_key] = (now, [dict(item) for item in items])
|
| 299 |
+
return items
|
| 300 |
+
except Exception as exc:
|
| 301 |
+
if self._is_quota_error(exc):
|
| 302 |
+
self._activate_quota_backoff()
|
| 303 |
+
self.logger.warning("Firebase quota exceeded while listing %s; using cache/backoff", collection)
|
| 304 |
+
else:
|
| 305 |
+
self.logger.exception("Failed to list Firebase collection %s", collection)
|
| 306 |
+
return [dict(item) for item in ((cached[1] if cached else []) or [])]
|
| 307 |
+
|
| 308 |
+
def delete_document(self, collection: str, doc_id: str) -> bool:
|
| 309 |
+
safe_doc = self._safe_id(doc_id)
|
| 310 |
+
backend = self.backend()
|
| 311 |
+
if backend == "google_drive":
|
| 312 |
+
deleted = self._drive.delete_document(collection, safe_doc)
|
| 313 |
+
if deleted:
|
| 314 |
+
self._read_cache.pop(f"{collection}:{safe_doc}", None)
|
| 315 |
+
self._write_cache.pop(f"{collection}:{safe_doc}", None)
|
| 316 |
+
stale_prefix = f"{collection}:list:"
|
| 317 |
+
for key in list(self._list_cache.keys()):
|
| 318 |
+
if key.startswith(stale_prefix):
|
| 319 |
+
self._list_cache.pop(key, None)
|
| 320 |
+
return deleted
|
| 321 |
+
if backend == "file":
|
| 322 |
+
try:
|
| 323 |
+
path = self._file_doc_path(collection, safe_doc)
|
| 324 |
+
if path.exists():
|
| 325 |
+
path.unlink()
|
| 326 |
+
self._read_cache.pop(f"{collection}:{safe_doc}", None)
|
| 327 |
+
self._write_cache.pop(f"{collection}:{safe_doc}", None)
|
| 328 |
+
stale_prefix = f"{collection}:list:"
|
| 329 |
+
for key in list(self._list_cache.keys()):
|
| 330 |
+
if key.startswith(stale_prefix):
|
| 331 |
+
self._list_cache.pop(key, None)
|
| 332 |
+
return True
|
| 333 |
+
except Exception:
|
| 334 |
+
self.logger.warning("Failed to delete shared-state file %s/%s", collection, safe_doc, exc_info=True)
|
| 335 |
+
return False
|
| 336 |
+
if self._quota_backoff_active():
|
| 337 |
+
return False
|
| 338 |
+
db = self._client()
|
| 339 |
+
if db is None:
|
| 340 |
+
return False
|
| 341 |
+
try:
|
| 342 |
+
db.collection(self._collection(collection)).document(safe_doc).delete()
|
| 343 |
+
self._read_cache.pop(f"{collection}:{safe_doc}", None)
|
| 344 |
+
self._write_cache.pop(f"{collection}:{safe_doc}", None)
|
| 345 |
+
stale_prefix = f"{collection}:list:"
|
| 346 |
+
for key in list(self._list_cache.keys()):
|
| 347 |
+
if key.startswith(stale_prefix):
|
| 348 |
+
self._list_cache.pop(key, None)
|
| 349 |
+
return True
|
| 350 |
+
except Exception as exc:
|
| 351 |
+
if self._is_quota_error(exc):
|
| 352 |
+
self._activate_quota_backoff()
|
| 353 |
+
self.logger.warning("Firebase quota exceeded while deleting %s/%s; delete skipped", collection, safe_doc)
|
| 354 |
+
else:
|
| 355 |
+
self.logger.exception("Failed to delete Firebase document %s/%s", collection, safe_doc)
|
| 356 |
+
return False
|
brain_server/api/main.py
ADDED
|
@@ -0,0 +1,2356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI entrypoint for the Brain Server."""
|
| 2 |
+
import base64
|
| 3 |
+
import gc
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
import zipfile
|
| 14 |
+
from collections import deque
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
from fastapi import FastAPI, File, UploadFile
|
| 20 |
+
from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
from agents.memory_agent import MemoryAgent
|
| 23 |
+
from agents.planner_agent import PlannerAgent
|
| 24 |
+
from agents.reasoning_agent import ReasoningAgent
|
| 25 |
+
try:
|
| 26 |
+
from api import deps as deps_module
|
| 27 |
+
from api.deps import get_executor_headers, get_logger, load_config
|
| 28 |
+
from api.firebase_store import FirebaseStore
|
| 29 |
+
from api.routes_analyze import router as analyze_router
|
| 30 |
+
from api.routes_execute import router as execute_router
|
| 31 |
+
from api.routes_plan import router as plan_router
|
| 32 |
+
from shared.google_drive_state import GoogleDriveStateClient
|
| 33 |
+
except ImportError:
|
| 34 |
+
from . import deps as deps_module
|
| 35 |
+
from .deps import get_executor_headers, get_logger, load_config
|
| 36 |
+
from .firebase_store import FirebaseStore
|
| 37 |
+
from .routes_analyze import router as analyze_router
|
| 38 |
+
from .routes_execute import router as execute_router
|
| 39 |
+
from .routes_plan import router as plan_router
|
| 40 |
+
from shared.google_drive_state import GoogleDriveStateClient
|
| 41 |
+
|
| 42 |
+
logger = get_logger("kapo.brain.main")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _configure_windows_utf8() -> None:
|
| 46 |
+
if os.name != "nt":
|
| 47 |
+
return
|
| 48 |
+
os.environ.setdefault("PYTHONUTF8", "1")
|
| 49 |
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
| 50 |
+
os.environ.setdefault("PYTHONLEGACYWINDOWSSTDIO", "utf-8")
|
| 51 |
+
try:
|
| 52 |
+
import ctypes
|
| 53 |
+
|
| 54 |
+
kernel32 = ctypes.windll.kernel32
|
| 55 |
+
kernel32.SetConsoleCP(65001)
|
| 56 |
+
kernel32.SetConsoleOutputCP(65001)
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
_configure_windows_utf8()
|
| 62 |
+
|
| 63 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 64 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 65 |
+
if hasattr(sys.stderr, "reconfigure"):
|
| 66 |
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
| 67 |
+
|
| 68 |
+
app = FastAPI(title="KAPO-AI Brain Server", version="1.0.0")
|
| 69 |
+
app.include_router(plan_router)
|
| 70 |
+
app.include_router(execute_router)
|
| 71 |
+
app.include_router(analyze_router)
|
| 72 |
+
|
| 73 |
+
MODEL = None
|
| 74 |
+
MODEL_ERROR = None
|
| 75 |
+
MODEL_META = {"repo_id": None, "filename": None, "path": None}
|
| 76 |
+
EMBED_MODEL = None
|
| 77 |
+
FIREBASE = FirebaseStore("brain", logger_name="kapo.brain.firebase")
|
| 78 |
+
DRIVE_STATE = GoogleDriveStateClient(logger)
|
| 79 |
+
FIREBASE_RUNTIME_CACHE: dict[str, tuple[float, Any]] = {}
|
| 80 |
+
RUNTIME_LOG_BUFFER: deque[dict[str, Any]] = deque(maxlen=200)
|
| 81 |
+
LAST_BRAIN_URL_REPORT: dict[str, Any] = {"url": "", "ts": 0.0}
|
| 82 |
+
RUNTIME_STATE_THREAD_STARTED = False
|
| 83 |
+
|
| 84 |
+
DEFAULT_MODEL_REPO = "QuantFactory/aya-expanse-8b-GGUF"
|
| 85 |
+
DEFAULT_MODEL_FILE = "aya-expanse-8b.Q4_K_M.gguf"
|
| 86 |
+
DEFAULT_MODEL_PROFILE_ID = "supervisor-ar-en-default"
|
| 87 |
+
HAS_MULTIPART = True
|
| 88 |
+
try:
|
| 89 |
+
import multipart # noqa: F401
|
| 90 |
+
except Exception:
|
| 91 |
+
HAS_MULTIPART = False
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class RuntimeLogHandler(logging.Handler):
|
| 95 |
+
def emit(self, record) -> None:
|
| 96 |
+
try:
|
| 97 |
+
RUNTIME_LOG_BUFFER.append(
|
| 98 |
+
{
|
| 99 |
+
"ts": time.time(),
|
| 100 |
+
"level": record.levelname,
|
| 101 |
+
"name": record.name,
|
| 102 |
+
"message": record.getMessage(),
|
| 103 |
+
}
|
| 104 |
+
)
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
_runtime_log_handler = RuntimeLogHandler(level=logging.WARNING)
|
| 110 |
+
if not any(isinstance(handler, RuntimeLogHandler) for handler in logger.handlers):
|
| 111 |
+
logger.addHandler(_runtime_log_handler)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _feature_enabled(name: str, default: bool = False) -> bool:
|
| 115 |
+
value = os.getenv(name)
|
| 116 |
+
if value is None or str(value).strip() == "":
|
| 117 |
+
return default
|
| 118 |
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _remote_brain_only() -> bool:
|
| 122 |
+
return _feature_enabled("REMOTE_BRAIN_ONLY", default=False)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _ngrok_bootstrap_enabled() -> bool:
|
| 126 |
+
return _feature_enabled("BRAIN_AUTO_NGROK", default=True)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _configured_public_url() -> str:
|
| 130 |
+
return str(os.getenv("BRAIN_PUBLIC_URL", "")).strip().rstrip("/")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _prefer_configured_public_url() -> bool:
|
| 134 |
+
provider = str(os.getenv("BRAIN_PROVIDER", "") or os.getenv("BRAIN_TEMPLATE", "")).strip().lower()
|
| 135 |
+
if "huggingface" in provider or "hf-space" in provider:
|
| 136 |
+
return True
|
| 137 |
+
return _feature_enabled("BRAIN_FORCE_CONFIGURED_PUBLIC_URL", default=False)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _reuse_public_url_on_restart() -> bool:
|
| 141 |
+
return _feature_enabled("BRAIN_REUSE_PUBLIC_URL_ON_RESTART", default=True)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _auto_publish_public_url_on_startup() -> bool:
|
| 145 |
+
return _feature_enabled("BRAIN_AUTO_PUBLISH_URL_ON_STARTUP", default=True)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _internal_restart_in_progress() -> bool:
|
| 149 |
+
return _feature_enabled("KAPO_INTERNAL_RESTART", default=False)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _fast_restart_enabled() -> bool:
|
| 153 |
+
return _feature_enabled("KAPO_FAST_INTERNAL_RESTART", default=True)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _executor_connect_timeout() -> float:
|
| 157 |
+
return float(os.getenv("EXECUTOR_CONNECT_TIMEOUT_SEC", "3.0") or 3.0)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _executor_read_timeout(name: str, default: float) -> float:
|
| 161 |
+
return float(os.getenv(name, str(default)) or default)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _executor_roundtrip_allowed(feature_name: str, default: bool = True) -> bool:
|
| 165 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip()
|
| 166 |
+
if not executor_url:
|
| 167 |
+
return False
|
| 168 |
+
kaggle_defaults = {
|
| 169 |
+
"BRAIN_REMOTE_TRACE_STORE_ENABLED": False,
|
| 170 |
+
"BRAIN_REMOTE_AUTO_INGEST_ENABLED": False,
|
| 171 |
+
"BRAIN_REMOTE_STYLE_PROFILE_ENABLED": False,
|
| 172 |
+
}
|
| 173 |
+
effective_default = kaggle_defaults.get(feature_name, default) if _is_kaggle_runtime() else default
|
| 174 |
+
return _feature_enabled(feature_name, default=effective_default)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _remote_runtime_reads_enabled() -> bool:
|
| 178 |
+
return _feature_enabled("KAPO_REMOTE_STATE_READS", default=False)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _shared_state_backend() -> str:
|
| 182 |
+
return str(os.getenv("KAPO_SHARED_STATE_BACKEND", "")).strip().lower()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _drive_bootstrap_configured() -> bool:
|
| 186 |
+
return bool(
|
| 187 |
+
str(os.getenv("GOOGLE_DRIVE_BOOTSTRAP_URL", "") or os.getenv("KAPO_BOOTSTRAP_URL", "") or "").strip()
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _bootstrap_shared_state() -> None:
|
| 192 |
+
if _drive_bootstrap_configured() or _shared_state_backend() in {"google_drive", "drive", "gdrive"}:
|
| 193 |
+
DRIVE_STATE.ensure_bootstrap_loaded(force=False)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _startup_self_update_enabled() -> bool:
|
| 197 |
+
return _feature_enabled("KAPO_STARTUP_SELF_UPDATE", default=True)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _should_report_brain_url(public_url: str) -> bool:
|
| 201 |
+
normalized = str(public_url or "").strip().rstrip("/")
|
| 202 |
+
if not normalized:
|
| 203 |
+
return False
|
| 204 |
+
interval_sec = max(30.0, float(os.getenv("BRAIN_REPORT_MIN_INTERVAL_SEC", "600") or 600))
|
| 205 |
+
previous_url = str(LAST_BRAIN_URL_REPORT.get("url") or "").strip()
|
| 206 |
+
previous_ts = float(LAST_BRAIN_URL_REPORT.get("ts") or 0.0)
|
| 207 |
+
now = time.time()
|
| 208 |
+
if normalized != previous_url or (now - previous_ts) >= interval_sec:
|
| 209 |
+
LAST_BRAIN_URL_REPORT["url"] = normalized
|
| 210 |
+
LAST_BRAIN_URL_REPORT["ts"] = now
|
| 211 |
+
return True
|
| 212 |
+
return False
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _download_model(repo_id: str, filename: str, hf_token: str | None = None) -> str:
|
| 216 |
+
from huggingface_hub import hf_hub_download
|
| 217 |
+
|
| 218 |
+
configured_cache = str(os.getenv("MODEL_CACHE_DIR", "") or "").strip()
|
| 219 |
+
if configured_cache:
|
| 220 |
+
cache_dir = configured_cache
|
| 221 |
+
elif _is_kaggle_runtime():
|
| 222 |
+
cache_dir = str((_project_root() / "models_cache").resolve())
|
| 223 |
+
else:
|
| 224 |
+
cache_dir = os.path.join(tempfile.gettempdir(), "kapo_models")
|
| 225 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 226 |
+
return hf_hub_download(repo_id=repo_id, filename=filename, cache_dir=cache_dir, token=hf_token)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def ensure_model_loaded(repo_id: str, filename: str, hf_token: str | None = None) -> None:
|
| 230 |
+
global MODEL, MODEL_ERROR, MODEL_META
|
| 231 |
+
repo_id = (repo_id or "").strip()
|
| 232 |
+
filename = (filename or "").strip()
|
| 233 |
+
if not repo_id or not filename:
|
| 234 |
+
MODEL = None
|
| 235 |
+
MODEL_ERROR = "model repo/file missing"
|
| 236 |
+
return
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
model_path = _download_model(repo_id, filename, hf_token=hf_token)
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
MODEL = None
|
| 242 |
+
MODEL_ERROR = f"model download failed: {exc}"
|
| 243 |
+
logger.exception("Model download failed")
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
from llama_cpp import Llama
|
| 248 |
+
|
| 249 |
+
MODEL = Llama(model_path=model_path, n_ctx=4096)
|
| 250 |
+
MODEL_ERROR = None
|
| 251 |
+
MODEL_META = {"repo_id": repo_id, "filename": filename, "path": model_path}
|
| 252 |
+
logger.info("Loaded model %s/%s", repo_id, filename)
|
| 253 |
+
except Exception as exc:
|
| 254 |
+
MODEL = None
|
| 255 |
+
MODEL_ERROR = f"model load failed: {exc}"
|
| 256 |
+
logger.exception("Model load failed")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _load_embed_model() -> None:
|
| 260 |
+
global EMBED_MODEL
|
| 261 |
+
if EMBED_MODEL is not None:
|
| 262 |
+
return
|
| 263 |
+
|
| 264 |
+
from sentence_transformers import SentenceTransformer
|
| 265 |
+
|
| 266 |
+
model_name = os.getenv("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
| 267 |
+
EMBED_MODEL = SentenceTransformer(model_name)
|
| 268 |
+
logger.info("Loaded embedding model %s", model_name)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _load_default_model() -> None:
|
| 272 |
+
repo_id = os.getenv("MODEL_REPO", DEFAULT_MODEL_REPO)
|
| 273 |
+
filename = os.getenv("MODEL_FILE", DEFAULT_MODEL_FILE)
|
| 274 |
+
ensure_model_loaded(repo_id, filename, hf_token=os.getenv("HF_TOKEN"))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _brain_headers() -> dict:
|
| 278 |
+
cfg = load_config()
|
| 279 |
+
return get_executor_headers(cfg)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _project_root() -> Path:
|
| 283 |
+
return Path(__file__).resolve().parents[1]
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _is_kaggle_runtime() -> bool:
|
| 287 |
+
return "/kaggle/" in str(_project_root()).replace("\\", "/") or bool(os.getenv("KAGGLE_KERNEL_RUN_TYPE"))
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _is_hf_space_runtime() -> bool:
|
| 291 |
+
return str(os.getenv("HF_SPACE_DOCKER", "0")).strip().lower() in {"1", "true", "yes", "on"} or bool(os.getenv("SPACE_ID"))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _apply_executor_settings(settings: dict[str, Any]) -> None:
|
| 295 |
+
for key in (
|
| 296 |
+
"NGROK_AUTHTOKEN",
|
| 297 |
+
"MODEL_REPO",
|
| 298 |
+
"MODEL_FILE",
|
| 299 |
+
"MODEL_PROFILE_ID",
|
| 300 |
+
"SUPERVISOR_MODEL_PROFILE_ID",
|
| 301 |
+
"EMBED_MODEL",
|
| 302 |
+
"REQUEST_TIMEOUT_SEC",
|
| 303 |
+
"REQUEST_RETRIES",
|
| 304 |
+
"CHAT_TIMEOUT_SEC",
|
| 305 |
+
"EXECUTOR_BYPASS_HEADER",
|
| 306 |
+
"EXECUTOR_BYPASS_VALUE",
|
| 307 |
+
"BRAIN_BYPASS_HEADER",
|
| 308 |
+
"BRAIN_BYPASS_VALUE",
|
| 309 |
+
"REMOTE_BRAIN_ONLY",
|
| 310 |
+
"KAGGLE_AUTO_BOOTSTRAP",
|
| 311 |
+
"BRAIN_AUTO_NGROK",
|
| 312 |
+
"BRAIN_AUTO_PUBLISH_URL_ON_STARTUP",
|
| 313 |
+
"BRAIN_PUBLIC_URL",
|
| 314 |
+
"BRAIN_REUSE_PUBLIC_URL_ON_RESTART",
|
| 315 |
+
"KAGGLE_SYNC_SUBDIR",
|
| 316 |
+
"BRAIN_ROLES",
|
| 317 |
+
"BRAIN_LANGUAGES",
|
| 318 |
+
"BRAIN_REMOTE_KNOWLEDGE_ENABLED",
|
| 319 |
+
"BRAIN_REMOTE_WEB_SEARCH_ENABLED",
|
| 320 |
+
"BRAIN_REMOTE_TRACE_STORE_ENABLED",
|
| 321 |
+
"BRAIN_REMOTE_AUTO_INGEST_ENABLED",
|
| 322 |
+
"BRAIN_LOCAL_RAG_FALLBACK_ENABLED",
|
| 323 |
+
"EXECUTOR_CONNECT_TIMEOUT_SEC",
|
| 324 |
+
"BRAIN_REMOTE_KNOWLEDGE_TIMEOUT_SEC",
|
| 325 |
+
"BRAIN_REMOTE_WEB_SEARCH_TIMEOUT_SEC",
|
| 326 |
+
"BRAIN_REMOTE_TRACE_STORE_TIMEOUT_SEC",
|
| 327 |
+
"BRAIN_REMOTE_AUTO_INGEST_TIMEOUT_SEC",
|
| 328 |
+
"FIREBASE_ENABLED",
|
| 329 |
+
"FIREBASE_PROJECT_ID",
|
| 330 |
+
"FIREBASE_SERVICE_ACCOUNT_PATH",
|
| 331 |
+
"FIREBASE_SERVICE_ACCOUNT_JSON",
|
| 332 |
+
"FIREBASE_NAMESPACE",
|
| 333 |
+
"KAPO_SHARED_STATE_BACKEND",
|
| 334 |
+
"GOOGLE_DRIVE_SHARED_STATE_FOLDER_ID",
|
| 335 |
+
"GOOGLE_DRIVE_SHARED_STATE_PREFIX",
|
| 336 |
+
"GOOGLE_DRIVE_BOOTSTRAP_URL",
|
| 337 |
+
"KAPO_BOOTSTRAP_URL",
|
| 338 |
+
"GOOGLE_DRIVE_ACCESS_TOKEN",
|
| 339 |
+
"GOOGLE_DRIVE_REFRESH_TOKEN",
|
| 340 |
+
"GOOGLE_DRIVE_CLIENT_SECRET_JSON",
|
| 341 |
+
"GOOGLE_DRIVE_CLIENT_SECRET_JSON_BASE64",
|
| 342 |
+
"GOOGLE_DRIVE_CLIENT_SECRET_PATH",
|
| 343 |
+
"GOOGLE_DRIVE_TOKEN_EXPIRES_AT",
|
| 344 |
+
"KAPO_CONTROL_PLANE_URL",
|
| 345 |
+
"KAPO_CLOUDFLARE_QUEUE_NAME",
|
| 346 |
+
):
|
| 347 |
+
value = settings.get(key)
|
| 348 |
+
if value not in (None, ""):
|
| 349 |
+
os.environ[key] = str(value)
|
| 350 |
+
deps_module.CONFIG_CACHE = None
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _apply_firebase_runtime_settings() -> None:
|
| 354 |
+
if not _remote_runtime_reads_enabled():
|
| 355 |
+
return
|
| 356 |
+
if not FIREBASE.enabled():
|
| 357 |
+
return
|
| 358 |
+
shared = FIREBASE.get_document("settings", "global")
|
| 359 |
+
runtime = FIREBASE.get_document("runtime", "executor")
|
| 360 |
+
role_items = FIREBASE.list_documents("roles", limit=64)
|
| 361 |
+
merged = {}
|
| 362 |
+
merged.update(shared or {})
|
| 363 |
+
merged.update(runtime or {})
|
| 364 |
+
mappings = {
|
| 365 |
+
"executor_public_url": "EXECUTOR_PUBLIC_URL",
|
| 366 |
+
"executor_url": "EXECUTOR_URL",
|
| 367 |
+
"model_repo": "MODEL_REPO",
|
| 368 |
+
"model_file": "MODEL_FILE",
|
| 369 |
+
"model_profile_id": "MODEL_PROFILE_ID",
|
| 370 |
+
"supervisor_model_profile_id": "SUPERVISOR_MODEL_PROFILE_ID",
|
| 371 |
+
"brain_roles": "BRAIN_ROLES",
|
| 372 |
+
"brain_languages": "BRAIN_LANGUAGES",
|
| 373 |
+
}
|
| 374 |
+
current_brain_url = str(merged.get("current_brain_url") or "").strip()
|
| 375 |
+
if current_brain_url:
|
| 376 |
+
os.environ["KAPO_EXECUTOR_CURRENT_BRAIN_URL"] = current_brain_url
|
| 377 |
+
for key, env_name in mappings.items():
|
| 378 |
+
value = merged.get(key)
|
| 379 |
+
if value not in (None, ""):
|
| 380 |
+
os.environ[env_name] = str(value)
|
| 381 |
+
if role_items:
|
| 382 |
+
enabled_roles = [
|
| 383 |
+
str(item.get("name") or item.get("id") or "").strip().lower()
|
| 384 |
+
for item in role_items
|
| 385 |
+
if str(item.get("enabled", True)).strip().lower() not in {"0", "false", "no", "off"}
|
| 386 |
+
]
|
| 387 |
+
enabled_roles = [role for role in enabled_roles if role]
|
| 388 |
+
if enabled_roles:
|
| 389 |
+
os.environ["BRAIN_ROLES"] = ",".join(dict.fromkeys(enabled_roles))
|
| 390 |
+
deps_module.CONFIG_CACHE = None
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _firebase_collection_cache_key(name: str) -> str:
|
| 394 |
+
return f"collection:{name}"
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _firebase_list_documents_cached(collection: str, ttl_sec: float = 30.0, limit: int = 200) -> list[dict[str, Any]]:
|
| 398 |
+
if not _remote_runtime_reads_enabled():
|
| 399 |
+
return []
|
| 400 |
+
if not FIREBASE.enabled():
|
| 401 |
+
return []
|
| 402 |
+
key = _firebase_collection_cache_key(collection)
|
| 403 |
+
now = time.time()
|
| 404 |
+
cached = FIREBASE_RUNTIME_CACHE.get(key)
|
| 405 |
+
if cached and (now - cached[0]) < ttl_sec:
|
| 406 |
+
return list(cached[1] or [])
|
| 407 |
+
items = FIREBASE.list_documents(collection, limit=limit)
|
| 408 |
+
FIREBASE_RUNTIME_CACHE[key] = (now, items)
|
| 409 |
+
return list(items or [])
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _firebase_role_profiles() -> list[dict[str, Any]]:
|
| 413 |
+
items = _firebase_list_documents_cached("roles", ttl_sec=30.0, limit=64)
|
| 414 |
+
if items:
|
| 415 |
+
return items
|
| 416 |
+
roles = [part.strip() for part in str(os.getenv("BRAIN_ROLES", "")).split(",") if part.strip()]
|
| 417 |
+
return [{"name": role, "enabled": True} for role in roles]
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _firebase_runtime_snapshot() -> dict[str, Any]:
|
| 421 |
+
return {
|
| 422 |
+
"platforms": _firebase_list_documents_cached("platforms", ttl_sec=45.0, limit=64),
|
| 423 |
+
"models": _firebase_list_documents_cached("models", ttl_sec=45.0, limit=128),
|
| 424 |
+
"prompts": _firebase_list_documents_cached("prompts", ttl_sec=20.0, limit=128),
|
| 425 |
+
"roles": _firebase_role_profiles(),
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _json_safe(value: Any) -> Any:
|
| 430 |
+
if isinstance(value, dict):
|
| 431 |
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
| 432 |
+
if isinstance(value, list):
|
| 433 |
+
return [_json_safe(item) for item in value]
|
| 434 |
+
if isinstance(value, tuple):
|
| 435 |
+
return [_json_safe(item) for item in value]
|
| 436 |
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
| 437 |
+
return value
|
| 438 |
+
return str(value)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _firebase_prompt_body(role_name: str, language: str = "en") -> str:
|
| 442 |
+
role_name = str(role_name or "").strip().lower()
|
| 443 |
+
language = str(language or "en").strip().lower()
|
| 444 |
+
if not role_name:
|
| 445 |
+
return ""
|
| 446 |
+
prompts = _firebase_runtime_snapshot().get("prompts", [])
|
| 447 |
+
exact = []
|
| 448 |
+
fallback = []
|
| 449 |
+
for item in prompts:
|
| 450 |
+
if str(item.get("role_name") or "").strip().lower() != role_name:
|
| 451 |
+
continue
|
| 452 |
+
if str(item.get("enabled", True)).strip().lower() in {"0", "false", "no", "off"}:
|
| 453 |
+
continue
|
| 454 |
+
item_lang = str(item.get("language") or "en").strip().lower()
|
| 455 |
+
body = str(item.get("body") or "").strip()
|
| 456 |
+
if not body:
|
| 457 |
+
continue
|
| 458 |
+
if item_lang == language:
|
| 459 |
+
exact.append(body)
|
| 460 |
+
elif item_lang == "en":
|
| 461 |
+
fallback.append(body)
|
| 462 |
+
if exact:
|
| 463 |
+
return exact[0]
|
| 464 |
+
if fallback:
|
| 465 |
+
return fallback[0]
|
| 466 |
+
return ""
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _prepare_runtime_environment() -> None:
|
| 470 |
+
try:
|
| 471 |
+
_bootstrap_shared_state()
|
| 472 |
+
except Exception:
|
| 473 |
+
logger.warning("Shared-state bootstrap preload failed", exc_info=True)
|
| 474 |
+
if not _is_kaggle_runtime():
|
| 475 |
+
return
|
| 476 |
+
|
| 477 |
+
source_root = _project_root()
|
| 478 |
+
source_text = str(source_root).replace("\\", "/")
|
| 479 |
+
runtime_root_env = os.getenv("KAPO_RUNTIME_ROOT", "").strip()
|
| 480 |
+
if runtime_root_env:
|
| 481 |
+
runtime_root = Path(runtime_root_env).resolve()
|
| 482 |
+
elif source_text.startswith("/kaggle/working/"):
|
| 483 |
+
runtime_root = source_root.resolve()
|
| 484 |
+
else:
|
| 485 |
+
runtime_root = Path("/kaggle/working/KAPO-AI-SYSTEM").resolve()
|
| 486 |
+
sync_root = runtime_root
|
| 487 |
+
auto_bootstrap = str(os.getenv("KAGGLE_AUTO_BOOTSTRAP", "1")).strip().lower() in {"1", "true", "yes", "on"}
|
| 488 |
+
|
| 489 |
+
if auto_bootstrap and source_text.startswith("/kaggle/input/") and source_root != runtime_root:
|
| 490 |
+
if runtime_root.exists():
|
| 491 |
+
shutil.rmtree(runtime_root, ignore_errors=True)
|
| 492 |
+
shutil.copytree(
|
| 493 |
+
source_root,
|
| 494 |
+
runtime_root,
|
| 495 |
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git", ".venv"),
|
| 496 |
+
)
|
| 497 |
+
if str(runtime_root) not in sys.path:
|
| 498 |
+
sys.path.insert(0, str(runtime_root))
|
| 499 |
+
|
| 500 |
+
data_dir = runtime_root / "data" / "local" / "brain_runtime"
|
| 501 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 502 |
+
os.environ["KAPO_RUNTIME_ROOT"] = str(runtime_root)
|
| 503 |
+
os.environ["KAPO_SYNC_ROOT"] = str(sync_root)
|
| 504 |
+
os.environ["LOCAL_DATA_DIR"] = str(data_dir)
|
| 505 |
+
os.environ["DB_PATH"] = str(data_dir / "episodic.db")
|
| 506 |
+
os.environ["TOOLS_DB_PATH"] = str(data_dir / "tools.db")
|
| 507 |
+
os.environ["FAISS_INDEX_PATH"] = str(data_dir / "faiss.index")
|
| 508 |
+
os.environ["REMOTE_BRAIN_ONLY"] = str(os.getenv("REMOTE_BRAIN_ONLY", "1") or "1")
|
| 509 |
+
saved_executor_url = _load_saved_executor_url()
|
| 510 |
+
current_executor_url = str(os.getenv("EXECUTOR_URL", "") or "").strip()
|
| 511 |
+
if saved_executor_url and not current_executor_url:
|
| 512 |
+
os.environ["EXECUTOR_URL"] = saved_executor_url
|
| 513 |
+
deps_module.CONFIG_CACHE = None
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def _sync_target_root() -> str:
|
| 517 |
+
return os.getenv("KAPO_SYNC_ROOT") or os.getenv("KAPO_RUNTIME_ROOT") or os.getcwd()
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _sync_root_path() -> Path:
|
| 521 |
+
return Path(_sync_target_root()).resolve()
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _resolve_sync_path(user_path: str | None = None) -> Path:
|
| 525 |
+
root = _sync_root_path()
|
| 526 |
+
relative = str(user_path or "").strip().replace("\\", "/").lstrip("/")
|
| 527 |
+
candidate = (root / relative).resolve() if relative else root
|
| 528 |
+
if candidate != root and root not in candidate.parents:
|
| 529 |
+
raise ValueError("Path escapes sync root")
|
| 530 |
+
return candidate
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _describe_sync_entry(path: Path) -> dict[str, Any]:
|
| 534 |
+
stat = path.stat()
|
| 535 |
+
return {
|
| 536 |
+
"name": path.name or str(path),
|
| 537 |
+
"path": str(path.relative_to(_sync_root_path())).replace("\\", "/") if path != _sync_root_path() else "",
|
| 538 |
+
"is_dir": path.is_dir(),
|
| 539 |
+
"size": stat.st_size,
|
| 540 |
+
"modified_at": stat.st_mtime,
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def _public_url_state_path() -> Path:
|
| 545 |
+
runtime_root = Path(_sync_target_root()).resolve()
|
| 546 |
+
state_dir = runtime_root / "data" / "local" / "brain_runtime"
|
| 547 |
+
state_dir.mkdir(parents=True, exist_ok=True)
|
| 548 |
+
return state_dir / "public_url.txt"
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def _remember_public_url(public_url: str) -> None:
|
| 552 |
+
value = str(public_url or "").strip().rstrip("/")
|
| 553 |
+
if not value:
|
| 554 |
+
return
|
| 555 |
+
os.environ["BRAIN_PUBLIC_URL"] = value
|
| 556 |
+
try:
|
| 557 |
+
_public_url_state_path().write_text(value, encoding="utf-8")
|
| 558 |
+
except Exception:
|
| 559 |
+
logger.warning("Failed to persist public URL", exc_info=True)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _load_saved_public_url() -> str:
|
| 563 |
+
try:
|
| 564 |
+
value = _public_url_state_path().read_text(encoding="utf-8").strip().rstrip("/")
|
| 565 |
+
return value
|
| 566 |
+
except Exception:
|
| 567 |
+
return ""
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def _ngrok_api_state_path() -> Path:
|
| 571 |
+
runtime_root = Path(_sync_target_root()).resolve()
|
| 572 |
+
state_dir = runtime_root / "data" / "local" / "brain_runtime"
|
| 573 |
+
state_dir.mkdir(parents=True, exist_ok=True)
|
| 574 |
+
return state_dir / "ngrok_api_url.txt"
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _executor_url_state_path() -> Path:
|
| 578 |
+
runtime_root = Path(_sync_target_root()).resolve()
|
| 579 |
+
state_dir = runtime_root / "data" / "local" / "brain_runtime"
|
| 580 |
+
state_dir.mkdir(parents=True, exist_ok=True)
|
| 581 |
+
return state_dir / "executor_url.txt"
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _remember_executor_url(executor_url: str) -> None:
|
| 585 |
+
value = str(executor_url or "").strip().rstrip("/")
|
| 586 |
+
if not value:
|
| 587 |
+
return
|
| 588 |
+
os.environ["EXECUTOR_URL"] = value
|
| 589 |
+
try:
|
| 590 |
+
_executor_url_state_path().write_text(value, encoding="utf-8")
|
| 591 |
+
except Exception:
|
| 592 |
+
logger.warning("Failed to persist executor URL", exc_info=True)
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def _load_saved_executor_url() -> str:
|
| 596 |
+
configured = str(os.getenv("EXECUTOR_URL", "")).strip().rstrip("/")
|
| 597 |
+
if configured:
|
| 598 |
+
return configured
|
| 599 |
+
try:
|
| 600 |
+
return _executor_url_state_path().read_text(encoding="utf-8").strip().rstrip("/")
|
| 601 |
+
except Exception:
|
| 602 |
+
return ""
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def _brain_state_id() -> str:
|
| 606 |
+
public_url = str(os.getenv("BRAIN_PUBLIC_URL") or _load_saved_public_url() or "").strip().rstrip("/")
|
| 607 |
+
if public_url:
|
| 608 |
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", public_url)
|
| 609 |
+
runtime_root = str(os.getenv("KAPO_RUNTIME_ROOT") or _sync_target_root() or "brain_runtime").strip()
|
| 610 |
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", runtime_root)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _applied_runtime_version_path() -> Path:
|
| 614 |
+
runtime_root = Path(_sync_target_root()).resolve()
|
| 615 |
+
state_dir = runtime_root / "data" / "local" / "brain_runtime"
|
| 616 |
+
state_dir.mkdir(parents=True, exist_ok=True)
|
| 617 |
+
return state_dir / "applied_version.json"
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _load_applied_runtime_version() -> dict[str, Any]:
|
| 621 |
+
try:
|
| 622 |
+
return dict(json.loads(_applied_runtime_version_path().read_text(encoding="utf-8")) or {})
|
| 623 |
+
except Exception:
|
| 624 |
+
return {}
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _write_applied_runtime_version(payload: dict[str, Any]) -> None:
|
| 628 |
+
_applied_runtime_version_path().write_text(
|
| 629 |
+
json.dumps(dict(payload or {}), ensure_ascii=False, indent=2, sort_keys=True),
|
| 630 |
+
encoding="utf-8",
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def _download_remote_zip(url: str, destination: Path) -> Path:
|
| 635 |
+
response = requests.get(str(url).strip(), timeout=120)
|
| 636 |
+
response.raise_for_status()
|
| 637 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 638 |
+
destination.write_bytes(response.content)
|
| 639 |
+
return destination
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def _apply_zip_overlay(zip_path: Path, target_root: Path) -> None:
|
| 643 |
+
with zipfile.ZipFile(zip_path, "r") as archive:
|
| 644 |
+
archive.extractall(target_root)
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def _load_patch_manifest_from_bootstrap() -> dict[str, Any]:
|
| 648 |
+
bootstrap = DRIVE_STATE.ensure_bootstrap_loaded(force=True) or {}
|
| 649 |
+
manifest_url = str(bootstrap.get("patch_manifest_url") or "").strip()
|
| 650 |
+
if not manifest_url:
|
| 651 |
+
return dict(bootstrap)
|
| 652 |
+
try:
|
| 653 |
+
response = requests.get(manifest_url, timeout=30)
|
| 654 |
+
response.raise_for_status()
|
| 655 |
+
payload = dict(response.json() or {})
|
| 656 |
+
merged = {**bootstrap, **payload}
|
| 657 |
+
return merged
|
| 658 |
+
except Exception:
|
| 659 |
+
logger.warning("Failed to load patch manifest from bootstrap URL", exc_info=True)
|
| 660 |
+
return dict(bootstrap)
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _run_startup_self_update() -> None:
|
| 664 |
+
if not _startup_self_update_enabled():
|
| 665 |
+
return
|
| 666 |
+
manifest = _load_patch_manifest_from_bootstrap()
|
| 667 |
+
target_version = str(manifest.get("version") or "").strip()
|
| 668 |
+
target_hash = str(manifest.get("build_hash") or "").strip()
|
| 669 |
+
if not target_version and not target_hash:
|
| 670 |
+
return
|
| 671 |
+
current = _load_applied_runtime_version()
|
| 672 |
+
if (
|
| 673 |
+
str(current.get("version") or "").strip() == target_version
|
| 674 |
+
and str(current.get("build_hash") or "").strip() == target_hash
|
| 675 |
+
and target_version
|
| 676 |
+
):
|
| 677 |
+
return
|
| 678 |
+
runtime_root = Path(_sync_target_root()).resolve()
|
| 679 |
+
temp_dir = runtime_root / "data" / "local" / "brain_runtime" / "updates"
|
| 680 |
+
patch_url = str(manifest.get("patch_bundle_url") or "").strip()
|
| 681 |
+
full_url = str(manifest.get("full_package_url") or "").strip()
|
| 682 |
+
applied_mode = ""
|
| 683 |
+
source_url = ""
|
| 684 |
+
try:
|
| 685 |
+
if patch_url:
|
| 686 |
+
zip_path = _download_remote_zip(patch_url, temp_dir / "patch_bundle.zip")
|
| 687 |
+
_apply_zip_overlay(zip_path, runtime_root)
|
| 688 |
+
applied_mode = "patch_bundle"
|
| 689 |
+
source_url = patch_url
|
| 690 |
+
elif full_url:
|
| 691 |
+
zip_path = _download_remote_zip(full_url, temp_dir / "full_package.zip")
|
| 692 |
+
_apply_zip_overlay(zip_path, runtime_root)
|
| 693 |
+
applied_mode = "full_package"
|
| 694 |
+
source_url = full_url
|
| 695 |
+
else:
|
| 696 |
+
return
|
| 697 |
+
_write_applied_runtime_version(
|
| 698 |
+
{
|
| 699 |
+
"version": target_version,
|
| 700 |
+
"build_hash": target_hash,
|
| 701 |
+
"applied_at": time.time(),
|
| 702 |
+
"mode": applied_mode,
|
| 703 |
+
"source_url": source_url,
|
| 704 |
+
}
|
| 705 |
+
)
|
| 706 |
+
logger.info("Applied startup self-update (%s) version=%s", applied_mode, target_version or target_hash)
|
| 707 |
+
except Exception:
|
| 708 |
+
logger.warning("Startup self-update failed", exc_info=True)
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def _remember_ngrok_api_url(api_url: str) -> None:
|
| 712 |
+
value = str(api_url or "").strip().rstrip("/")
|
| 713 |
+
if not value:
|
| 714 |
+
return
|
| 715 |
+
os.environ["KAPO_NGROK_API_URL"] = value
|
| 716 |
+
try:
|
| 717 |
+
_ngrok_api_state_path().write_text(value, encoding="utf-8")
|
| 718 |
+
except Exception:
|
| 719 |
+
logger.warning("Failed to persist ngrok API URL", exc_info=True)
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def _load_saved_ngrok_api_url() -> str:
|
| 723 |
+
configured = str(os.getenv("KAPO_NGROK_API_URL", "")).strip().rstrip("/")
|
| 724 |
+
if configured:
|
| 725 |
+
return configured
|
| 726 |
+
try:
|
| 727 |
+
return _ngrok_api_state_path().read_text(encoding="utf-8").strip().rstrip("/")
|
| 728 |
+
except Exception:
|
| 729 |
+
return ""
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def _ngrok_api_candidates() -> list[str]:
|
| 733 |
+
seen: set[str] = set()
|
| 734 |
+
candidates: list[str] = []
|
| 735 |
+
for candidate in [_load_saved_ngrok_api_url(), "http://127.0.0.1:4040", "http://127.0.0.1:4041", "http://127.0.0.1:4042"]:
|
| 736 |
+
value = str(candidate or "").strip().rstrip("/")
|
| 737 |
+
if value and value not in seen:
|
| 738 |
+
seen.add(value)
|
| 739 |
+
candidates.append(value)
|
| 740 |
+
return candidates
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def _probe_ngrok_api(api_url: str) -> bool:
|
| 744 |
+
try:
|
| 745 |
+
response = requests.get(f"{api_url}/api/tunnels", timeout=2)
|
| 746 |
+
return response.status_code == 200
|
| 747 |
+
except Exception:
|
| 748 |
+
return False
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def _find_live_ngrok_api() -> str | None:
|
| 752 |
+
for api_url in _ngrok_api_candidates():
|
| 753 |
+
if _probe_ngrok_api(api_url):
|
| 754 |
+
_remember_ngrok_api_url(api_url)
|
| 755 |
+
return api_url
|
| 756 |
+
return None
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def _ngrok_binary_path() -> str:
|
| 760 |
+
env_path = str(os.getenv("NGROK_PATH", "")).strip()
|
| 761 |
+
if env_path and Path(env_path).exists():
|
| 762 |
+
return env_path
|
| 763 |
+
default_ngrok_path = ""
|
| 764 |
+
try:
|
| 765 |
+
from pyngrok import conf
|
| 766 |
+
|
| 767 |
+
default_ngrok_path = str(conf.get_default().ngrok_path or "").strip()
|
| 768 |
+
if default_ngrok_path and Path(default_ngrok_path).exists():
|
| 769 |
+
return default_ngrok_path
|
| 770 |
+
except Exception:
|
| 771 |
+
pass
|
| 772 |
+
try:
|
| 773 |
+
from pyngrok import installer
|
| 774 |
+
|
| 775 |
+
install_target = default_ngrok_path or str((Path.home() / ".ngrok" / "ngrok").resolve())
|
| 776 |
+
Path(install_target).parent.mkdir(parents=True, exist_ok=True)
|
| 777 |
+
installer.install_ngrok(install_target)
|
| 778 |
+
if Path(install_target).exists():
|
| 779 |
+
return install_target
|
| 780 |
+
except Exception:
|
| 781 |
+
logger.warning("Failed to auto-install ngrok binary", exc_info=True)
|
| 782 |
+
discovered = shutil.which("ngrok")
|
| 783 |
+
if discovered:
|
| 784 |
+
return discovered
|
| 785 |
+
return "ngrok"
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _ensure_ngrok_auth(token: str) -> None:
|
| 789 |
+
ngrok_path = _ngrok_binary_path()
|
| 790 |
+
subprocess.run(
|
| 791 |
+
[ngrok_path, "config", "add-authtoken", token],
|
| 792 |
+
check=False,
|
| 793 |
+
stdout=subprocess.DEVNULL,
|
| 794 |
+
stderr=subprocess.DEVNULL,
|
| 795 |
+
)
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _start_detached_ngrok_agent(token: str) -> str | None:
|
| 799 |
+
if token:
|
| 800 |
+
_ensure_ngrok_auth(token)
|
| 801 |
+
os.environ["NGROK_AUTHTOKEN"] = token
|
| 802 |
+
|
| 803 |
+
ngrok_path = _ngrok_binary_path()
|
| 804 |
+
popen_kwargs = {
|
| 805 |
+
"stdout": subprocess.DEVNULL,
|
| 806 |
+
"stderr": subprocess.DEVNULL,
|
| 807 |
+
"stdin": subprocess.DEVNULL,
|
| 808 |
+
}
|
| 809 |
+
if os.name == "nt":
|
| 810 |
+
popen_kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
| 811 |
+
else:
|
| 812 |
+
popen_kwargs["start_new_session"] = True
|
| 813 |
+
|
| 814 |
+
subprocess.Popen(
|
| 815 |
+
[ngrok_path, "start", "--none", "--log=stdout"],
|
| 816 |
+
**popen_kwargs,
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
deadline = time.time() + 12
|
| 820 |
+
while time.time() < deadline:
|
| 821 |
+
api_url = _find_live_ngrok_api()
|
| 822 |
+
if api_url:
|
| 823 |
+
return api_url
|
| 824 |
+
time.sleep(0.5)
|
| 825 |
+
return None
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def _list_ngrok_tunnels(api_url: str) -> list[dict[str, Any]]:
|
| 829 |
+
response = requests.get(f"{api_url}/api/tunnels", timeout=5)
|
| 830 |
+
response.raise_for_status()
|
| 831 |
+
payload = response.json()
|
| 832 |
+
tunnels = payload.get("tunnels")
|
| 833 |
+
return tunnels if isinstance(tunnels, list) else []
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def _existing_ngrok_public_url(api_url: str, port: int) -> str | None:
|
| 837 |
+
for tunnel in _list_ngrok_tunnels(api_url):
|
| 838 |
+
public_url = str(tunnel.get("public_url") or "").strip()
|
| 839 |
+
config = tunnel.get("config") or {}
|
| 840 |
+
addr = str(config.get("addr") or "").strip()
|
| 841 |
+
if public_url and addr.endswith(f":{port}"):
|
| 842 |
+
return public_url.rstrip("/")
|
| 843 |
+
return None
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def _create_ngrok_tunnel(api_url: str, port: int) -> str | None:
|
| 847 |
+
response = requests.post(
|
| 848 |
+
f"{api_url}/api/tunnels",
|
| 849 |
+
json={
|
| 850 |
+
"name": f"http-{port}-kapo",
|
| 851 |
+
"addr": str(port),
|
| 852 |
+
"proto": "http",
|
| 853 |
+
},
|
| 854 |
+
timeout=10,
|
| 855 |
+
)
|
| 856 |
+
response.raise_for_status()
|
| 857 |
+
payload = response.json()
|
| 858 |
+
return str(payload.get("public_url") or "").strip().rstrip("/") or None
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
def _publish_brain_presence(public_url: str, *, source: str = "runtime") -> None:
|
| 862 |
+
normalized = str(public_url or "").strip().rstrip("/")
|
| 863 |
+
if not normalized:
|
| 864 |
+
return
|
| 865 |
+
payload = {
|
| 866 |
+
"url": normalized,
|
| 867 |
+
"status": "healthy",
|
| 868 |
+
"source": source,
|
| 869 |
+
"platform": "kaggle" if _is_kaggle_runtime() else str(os.getenv("BRAIN_PROVIDER") or "remote"),
|
| 870 |
+
"role": os.getenv("BRAIN_PRIMARY_ROLE", "fallback"),
|
| 871 |
+
"roles": [part.strip() for part in os.getenv("BRAIN_ROLES", "supervisor,chat,coding,planner,arabic,fallback").split(",") if part.strip()],
|
| 872 |
+
"languages": [part.strip() for part in os.getenv("BRAIN_LANGUAGES", "ar,en").split(",") if part.strip()],
|
| 873 |
+
"model_profile_id": os.getenv("MODEL_PROFILE_ID") or os.getenv("SUPERVISOR_MODEL_PROFILE_ID") or DEFAULT_MODEL_PROFILE_ID,
|
| 874 |
+
"model_repo": MODEL_META.get("repo_id") or os.getenv("MODEL_REPO") or DEFAULT_MODEL_REPO,
|
| 875 |
+
"model_file": MODEL_META.get("filename") or os.getenv("MODEL_FILE") or DEFAULT_MODEL_FILE,
|
| 876 |
+
"updated_at": time.time(),
|
| 877 |
+
}
|
| 878 |
+
FIREBASE.set_document("brains", normalized, payload)
|
| 879 |
+
FIREBASE.set_document(
|
| 880 |
+
"runtime",
|
| 881 |
+
"brains_last_report",
|
| 882 |
+
{
|
| 883 |
+
"brain_url": normalized,
|
| 884 |
+
"source": source,
|
| 885 |
+
"updated_at": time.time(),
|
| 886 |
+
"provider": payload["platform"],
|
| 887 |
+
"model_profile_id": payload["model_profile_id"],
|
| 888 |
+
},
|
| 889 |
+
)
|
| 890 |
+
FIREBASE.set_document(
|
| 891 |
+
"tunnels",
|
| 892 |
+
f"brain_{normalized}",
|
| 893 |
+
{
|
| 894 |
+
"kind": "brain",
|
| 895 |
+
"public_url": normalized,
|
| 896 |
+
"provider": "ngrok" if "ngrok" in normalized else payload["platform"],
|
| 897 |
+
"updated_at": time.time(),
|
| 898 |
+
},
|
| 899 |
+
)
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def _report_brain_url(public_url: str) -> None:
|
| 903 |
+
_publish_brain_presence(public_url, source="report_attempt")
|
| 904 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 905 |
+
if not executor_url:
|
| 906 |
+
return
|
| 907 |
+
if not _should_report_brain_url(public_url):
|
| 908 |
+
return
|
| 909 |
+
last_error: Exception | None = None
|
| 910 |
+
connect_timeout = max(1.0, float(os.getenv("BRAIN_REPORT_CONNECT_TIMEOUT_SEC", "4.0") or 4.0))
|
| 911 |
+
read_timeout = max(5.0, float(os.getenv("BRAIN_REPORT_READ_TIMEOUT_SEC", "15.0") or 15.0))
|
| 912 |
+
retries = max(1, int(os.getenv("BRAIN_REPORT_RETRIES", "2") or 2))
|
| 913 |
+
for _ in range(retries):
|
| 914 |
+
try:
|
| 915 |
+
response = requests.post(
|
| 916 |
+
f"{executor_url}/brain/report-url",
|
| 917 |
+
json={
|
| 918 |
+
"brain_url": public_url,
|
| 919 |
+
"platform": "kaggle" if _is_kaggle_runtime() else "remote",
|
| 920 |
+
"role": os.getenv("BRAIN_PRIMARY_ROLE", "fallback"),
|
| 921 |
+
"roles": [part.strip() for part in os.getenv("BRAIN_ROLES", "supervisor,chat,coding,planner,arabic,fallback").split(",") if part.strip()],
|
| 922 |
+
"languages": [part.strip() for part in os.getenv("BRAIN_LANGUAGES", "ar,en").split(",") if part.strip()],
|
| 923 |
+
"model_profile_id": os.getenv("MODEL_PROFILE_ID") or os.getenv("SUPERVISOR_MODEL_PROFILE_ID") or DEFAULT_MODEL_PROFILE_ID,
|
| 924 |
+
"model_repo": MODEL_META.get("repo_id") or os.getenv("MODEL_REPO") or DEFAULT_MODEL_REPO,
|
| 925 |
+
"model_file": MODEL_META.get("filename") or os.getenv("MODEL_FILE") or DEFAULT_MODEL_FILE,
|
| 926 |
+
},
|
| 927 |
+
headers=_brain_headers(),
|
| 928 |
+
timeout=(connect_timeout, read_timeout),
|
| 929 |
+
)
|
| 930 |
+
response.raise_for_status()
|
| 931 |
+
_publish_brain_presence(public_url, source="executor_report")
|
| 932 |
+
return
|
| 933 |
+
except Exception as exc:
|
| 934 |
+
last_error = exc
|
| 935 |
+
time.sleep(1)
|
| 936 |
+
logger.info(
|
| 937 |
+
"Brain URL report to executor timed out or failed; continuing (%s)",
|
| 938 |
+
last_error,
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def _pull_executor_settings() -> dict[str, Any]:
|
| 943 |
+
if _shared_state_backend() in {"google_drive", "drive", "gdrive"} or _drive_bootstrap_configured():
|
| 944 |
+
return {}
|
| 945 |
+
executor_url = _load_saved_executor_url()
|
| 946 |
+
if not executor_url:
|
| 947 |
+
return {}
|
| 948 |
+
try:
|
| 949 |
+
response = requests.get(
|
| 950 |
+
f"{executor_url}/share/settings",
|
| 951 |
+
headers=_brain_headers(),
|
| 952 |
+
timeout=(
|
| 953 |
+
max(1.5, float(os.getenv("EXECUTOR_CONNECT_TIMEOUT_SEC", "3.0") or 3.0)),
|
| 954 |
+
max(2.0, float(os.getenv("EXECUTOR_SETTINGS_READ_TIMEOUT_SEC", "5.0") or 5.0)),
|
| 955 |
+
),
|
| 956 |
+
)
|
| 957 |
+
if response.status_code == 200:
|
| 958 |
+
return response.json()
|
| 959 |
+
logger.warning("Executor settings request failed: %s", response.text[:400])
|
| 960 |
+
except Exception as exc:
|
| 961 |
+
logger.warning("Failed to pull executor settings (%s)", exc)
|
| 962 |
+
return {}
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
def start_ngrok(token: str | None = None) -> str | None:
|
| 966 |
+
restart_reuse = str(os.getenv("KAPO_RESTART_REUSE_PUBLIC_URL", "")).strip().lower() in {"1", "true", "yes", "on"}
|
| 967 |
+
if restart_reuse:
|
| 968 |
+
saved_public_url = _load_saved_public_url()
|
| 969 |
+
if saved_public_url:
|
| 970 |
+
_remember_public_url(saved_public_url)
|
| 971 |
+
_report_brain_url(saved_public_url)
|
| 972 |
+
FIREBASE.set_document("brains", saved_public_url, {"url": saved_public_url, "status": "healthy", "source": "restart_reuse"})
|
| 973 |
+
FIREBASE.set_document("tunnels", f"brain_{saved_public_url}", {"kind": "brain", "public_url": saved_public_url, "provider": "ngrok"})
|
| 974 |
+
logger.info("Reusing saved brain public URL after restart: %s", saved_public_url)
|
| 975 |
+
os.environ["KAPO_RESTART_REUSE_PUBLIC_URL"] = "0"
|
| 976 |
+
return saved_public_url
|
| 977 |
+
|
| 978 |
+
configured_public_url = _configured_public_url()
|
| 979 |
+
if configured_public_url and _prefer_configured_public_url():
|
| 980 |
+
_remember_public_url(configured_public_url)
|
| 981 |
+
_report_brain_url(configured_public_url)
|
| 982 |
+
FIREBASE.set_document("brains", configured_public_url, {"url": configured_public_url, "status": "healthy", "source": "configured_public_url"})
|
| 983 |
+
FIREBASE.set_document("tunnels", f"brain_{configured_public_url}", {"kind": "brain", "public_url": configured_public_url, "provider": "configured"})
|
| 984 |
+
logger.info("Using configured brain public URL without starting ngrok: %s", configured_public_url)
|
| 985 |
+
return configured_public_url
|
| 986 |
+
|
| 987 |
+
if not _ngrok_bootstrap_enabled():
|
| 988 |
+
logger.info("Skipping ngrok bootstrap because BRAIN_AUTO_NGROK is disabled")
|
| 989 |
+
return None
|
| 990 |
+
|
| 991 |
+
try:
|
| 992 |
+
authtoken = str(token or os.getenv("NGROK_AUTHTOKEN") or "").strip()
|
| 993 |
+
if not authtoken:
|
| 994 |
+
return None
|
| 995 |
+
|
| 996 |
+
port = int(os.getenv("BRAIN_PORT", "7860"))
|
| 997 |
+
api_url = _find_live_ngrok_api()
|
| 998 |
+
if not api_url:
|
| 999 |
+
api_url = _start_detached_ngrok_agent(authtoken)
|
| 1000 |
+
if not api_url:
|
| 1001 |
+
logger.warning("Ngrok agent did not expose a local API URL")
|
| 1002 |
+
return None
|
| 1003 |
+
|
| 1004 |
+
public_url = _existing_ngrok_public_url(api_url, port)
|
| 1005 |
+
if not public_url:
|
| 1006 |
+
public_url = _create_ngrok_tunnel(api_url, port)
|
| 1007 |
+
if not public_url:
|
| 1008 |
+
return None
|
| 1009 |
+
|
| 1010 |
+
match = re.search(r"https://[A-Za-z0-9.-]+", public_url)
|
| 1011 |
+
if match:
|
| 1012 |
+
public_url = match.group(0)
|
| 1013 |
+
_remember_ngrok_api_url(api_url)
|
| 1014 |
+
_remember_public_url(public_url)
|
| 1015 |
+
_report_brain_url(public_url)
|
| 1016 |
+
FIREBASE.set_document("brains", public_url, {"url": public_url, "status": "healthy", "source": "ngrok_bootstrap"})
|
| 1017 |
+
FIREBASE.set_document("tunnels", f"brain_{public_url}", {"kind": "brain", "public_url": public_url, "provider": "ngrok", "api_url": api_url})
|
| 1018 |
+
return public_url
|
| 1019 |
+
except Exception:
|
| 1020 |
+
logger.exception("Ngrok startup failed")
|
| 1021 |
+
return None
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
def _report_known_public_url() -> str | None:
|
| 1025 |
+
public_url = _load_saved_public_url()
|
| 1026 |
+
if not public_url:
|
| 1027 |
+
return None
|
| 1028 |
+
_remember_public_url(public_url)
|
| 1029 |
+
_report_brain_url(public_url)
|
| 1030 |
+
FIREBASE.set_document("brains", public_url, {"url": public_url, "status": "healthy", "source": "saved_public_url"})
|
| 1031 |
+
logger.info("Reported known brain public URL without starting ngrok: %s", public_url)
|
| 1032 |
+
return public_url
|
| 1033 |
+
|
| 1034 |
+
|
| 1035 |
+
def _bootstrap_executor_handshake(start_tunnel: bool = False) -> None:
|
| 1036 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip()
|
| 1037 |
+
if not executor_url:
|
| 1038 |
+
if start_tunnel:
|
| 1039 |
+
public_url = start_ngrok(os.getenv("NGROK_AUTHTOKEN") or None)
|
| 1040 |
+
if public_url:
|
| 1041 |
+
logger.info("Brain public URL started locally without executor handshake: %s", public_url)
|
| 1042 |
+
else:
|
| 1043 |
+
logger.info("Brain started without publishing a public URL")
|
| 1044 |
+
return
|
| 1045 |
+
logger.info("Skipping executor handshake: EXECUTOR_URL not configured")
|
| 1046 |
+
return
|
| 1047 |
+
|
| 1048 |
+
settings = _pull_executor_settings()
|
| 1049 |
+
_apply_executor_settings(settings)
|
| 1050 |
+
|
| 1051 |
+
public_url = None
|
| 1052 |
+
if start_tunnel:
|
| 1053 |
+
public_url = start_ngrok(os.getenv("NGROK_AUTHTOKEN") or None)
|
| 1054 |
+
if not public_url:
|
| 1055 |
+
public_url = _report_known_public_url()
|
| 1056 |
+
else:
|
| 1057 |
+
public_url = _report_known_public_url()
|
| 1058 |
+
if public_url:
|
| 1059 |
+
logger.info("Brain public URL reported to executor: %s", public_url)
|
| 1060 |
+
else:
|
| 1061 |
+
logger.info("Brain started without publishing a public URL")
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
@app.on_event("startup")
|
| 1065 |
+
async def startup_event():
|
| 1066 |
+
global RUNTIME_STATE_THREAD_STARTED
|
| 1067 |
+
try:
|
| 1068 |
+
_bootstrap_shared_state()
|
| 1069 |
+
except Exception:
|
| 1070 |
+
logger.exception("Shared-state bootstrap failed")
|
| 1071 |
+
try:
|
| 1072 |
+
_prepare_runtime_environment()
|
| 1073 |
+
except Exception:
|
| 1074 |
+
logger.exception("Runtime environment bootstrap failed")
|
| 1075 |
+
try:
|
| 1076 |
+
_run_startup_self_update()
|
| 1077 |
+
except Exception:
|
| 1078 |
+
logger.exception("Startup self-update bootstrap failed")
|
| 1079 |
+
internal_restart = _internal_restart_in_progress()
|
| 1080 |
+
fast_restart = internal_restart and _fast_restart_enabled()
|
| 1081 |
+
if not fast_restart:
|
| 1082 |
+
try:
|
| 1083 |
+
settings = _pull_executor_settings()
|
| 1084 |
+
_apply_executor_settings(settings)
|
| 1085 |
+
except Exception:
|
| 1086 |
+
logger.exception("Executor settings bootstrap failed")
|
| 1087 |
+
try:
|
| 1088 |
+
_apply_firebase_runtime_settings()
|
| 1089 |
+
except Exception:
|
| 1090 |
+
logger.exception("Firebase runtime bootstrap failed")
|
| 1091 |
+
else:
|
| 1092 |
+
logger.info("Fast internal restart enabled; skipping executor/Firebase startup bootstrap")
|
| 1093 |
+
if not fast_restart:
|
| 1094 |
+
_load_default_model()
|
| 1095 |
+
try:
|
| 1096 |
+
if not fast_restart:
|
| 1097 |
+
_load_embed_model()
|
| 1098 |
+
except Exception:
|
| 1099 |
+
logger.exception("Embedding model startup failed")
|
| 1100 |
+
try:
|
| 1101 |
+
start_tunnel = _auto_publish_public_url_on_startup() and not internal_restart
|
| 1102 |
+
_bootstrap_executor_handshake(start_tunnel=start_tunnel)
|
| 1103 |
+
except Exception:
|
| 1104 |
+
logger.exception("Executor handshake startup failed")
|
| 1105 |
+
finally:
|
| 1106 |
+
os.environ["KAPO_INTERNAL_RESTART"] = "0"
|
| 1107 |
+
_persist_runtime_state_snapshot(reason="startup")
|
| 1108 |
+
if not RUNTIME_STATE_THREAD_STARTED:
|
| 1109 |
+
RUNTIME_STATE_THREAD_STARTED = True
|
| 1110 |
+
threading.Thread(target=_runtime_state_pulse, daemon=True).start()
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
class ModelLoadRequest(BaseModel):
|
| 1114 |
+
repo_id: str
|
| 1115 |
+
filename: str
|
| 1116 |
+
hf_token: str | None = None
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
class ConnectionInit(BaseModel):
|
| 1120 |
+
executor_url: str
|
| 1121 |
+
ngrok_token: str | None = None
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
class PublishUrlRequest(BaseModel):
|
| 1125 |
+
ngrok_token: str | None = None
|
| 1126 |
+
public_url: str | None = None
|
| 1127 |
+
start_tunnel: bool = True
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
class RestartRequest(BaseModel):
|
| 1131 |
+
delay_sec: float = 1.0
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
class FileWriteRequest(BaseModel):
|
| 1135 |
+
path: str
|
| 1136 |
+
content: str = ""
|
| 1137 |
+
overwrite: bool = True
|
| 1138 |
+
|
| 1139 |
+
|
| 1140 |
+
class FileDeleteRequest(BaseModel):
|
| 1141 |
+
path: str
|
| 1142 |
+
recursive: bool = False
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
class FileMkdirRequest(BaseModel):
|
| 1146 |
+
path: str
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
class ChatRequest(BaseModel):
|
| 1150 |
+
request_id: str
|
| 1151 |
+
user_input: str
|
| 1152 |
+
context: dict[str, Any] = {}
|
| 1153 |
+
history: list[dict[str, str]] = []
|
| 1154 |
+
auto_execute: bool = True
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
def _contains_arabic(text: str) -> bool:
|
| 1158 |
+
return bool(re.search(r"[\u0600-\u06FF]", text or ""))
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
def _detect_language(text: str) -> str:
|
| 1162 |
+
return "ar" if _contains_arabic(text) else "en"
|
| 1163 |
+
|
| 1164 |
+
|
| 1165 |
+
def _is_task_request(text: str) -> bool:
|
| 1166 |
+
lower = (text or "").strip().lower()
|
| 1167 |
+
task_words = [
|
| 1168 |
+
"build", "fix", "debug", "create project", "generate project", "implement", "refactor",
|
| 1169 |
+
"run", "execute", "install", "modify", "edit", "update", "write code", "make app",
|
| 1170 |
+
"Ø§ÙØ´Ø¦", "Ø£ÙØ´Ø¦", "اعÙ
Ù", "ÙÙØ°", "شغÙ", "Ø§ØµÙØ", "Ø£ØµÙØ", "عدÙÙ", "عدÙ", "ابÙÙ", "ÙÙÙÙ Ù
Ø´Ø±ÙØ¹",
|
| 1171 |
+
]
|
| 1172 |
+
return any(word in lower for word in task_words)
|
| 1173 |
+
|
| 1174 |
+
|
| 1175 |
+
def _is_research_request(text: str) -> bool:
|
| 1176 |
+
lower = (text or "").strip().lower()
|
| 1177 |
+
research_words = [
|
| 1178 |
+
"search", "research", "look up", "find out", "web", "browse",
|
| 1179 |
+
"Ø§Ø¨ØØ«", "Ø§Ø¨ØØ« عÙ", "Ø¯ÙØ±", "ÙØªØ´", "Ù
عÙÙÙ
Ø© عÙ", "Ù
عÙÙÙ
ات عÙ",
|
| 1180 |
+
]
|
| 1181 |
+
return any(word in lower for word in research_words)
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
def _is_knowledge_request(text: str, context: dict[str, Any] | None = None) -> bool:
|
| 1185 |
+
context = context or {}
|
| 1186 |
+
if bool(context.get("use_executor_knowledge")):
|
| 1187 |
+
return True
|
| 1188 |
+
lower = (text or "").strip().lower()
|
| 1189 |
+
knowledge_words = [
|
| 1190 |
+
"remember", "memory", "knowledge", "docs", "documentation", "project structure", "architecture",
|
| 1191 |
+
"ØªØ°ÙØ±", "Ø§ÙØ°Ø§Ùرة", "اÙÙ
Ø¹Ø±ÙØ©", "اÙÙØ«Ø§Ø¦Ù", "Ø§ÙØ¯ÙÙÙ", "بÙÙØ© اÙÙ
Ø´Ø±ÙØ¹", "ÙÙÙ٠اÙÙ
Ø´Ø±ÙØ¹", "Ù
عÙ
Ø§Ø±ÙØ©",
|
| 1192 |
+
]
|
| 1193 |
+
return any(word in lower for word in knowledge_words)
|
| 1194 |
+
|
| 1195 |
+
|
| 1196 |
+
def _prune_history(history: list[dict[str, str]], keep_last: int = 6) -> list[dict[str, str]]:
|
| 1197 |
+
if len(history) <= keep_last:
|
| 1198 |
+
return history
|
| 1199 |
+
return history[-keep_last:]
|
| 1200 |
+
|
| 1201 |
+
|
| 1202 |
+
def _retrieve_knowledge(query: str, top_k: int = 4) -> list[dict[str, Any]]:
|
| 1203 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1204 |
+
expanded_query = _expand_project_query(query)
|
| 1205 |
+
if _executor_roundtrip_allowed("BRAIN_REMOTE_KNOWLEDGE_ENABLED", default=True):
|
| 1206 |
+
try:
|
| 1207 |
+
response = requests.get(
|
| 1208 |
+
f"{executor_url}/rag/search",
|
| 1209 |
+
params={"query": expanded_query, "top_k": top_k},
|
| 1210 |
+
headers=_brain_headers(),
|
| 1211 |
+
timeout=(
|
| 1212 |
+
_executor_connect_timeout(),
|
| 1213 |
+
_executor_read_timeout("BRAIN_REMOTE_KNOWLEDGE_TIMEOUT_SEC", 6.0),
|
| 1214 |
+
),
|
| 1215 |
+
)
|
| 1216 |
+
if response.status_code == 200:
|
| 1217 |
+
payload = response.json()
|
| 1218 |
+
results = payload.get("results", [])
|
| 1219 |
+
if isinstance(results, list):
|
| 1220 |
+
return results
|
| 1221 |
+
except requests.exceptions.ReadTimeout:
|
| 1222 |
+
logger.info("Executor knowledge retrieval timed out; continuing without remote knowledge")
|
| 1223 |
+
except Exception:
|
| 1224 |
+
logger.warning("Executor knowledge retrieval failed", exc_info=True)
|
| 1225 |
+
if _remote_brain_only() or not _feature_enabled("BRAIN_LOCAL_RAG_FALLBACK_ENABLED", default=False):
|
| 1226 |
+
return []
|
| 1227 |
+
try:
|
| 1228 |
+
from rag.retriever import retrieve
|
| 1229 |
+
|
| 1230 |
+
return retrieve(expanded_query, top_k=top_k)
|
| 1231 |
+
except Exception:
|
| 1232 |
+
logger.warning("Knowledge retrieval failed", exc_info=True)
|
| 1233 |
+
return []
|
| 1234 |
+
|
| 1235 |
+
|
| 1236 |
+
def _search_web(query: str) -> list[dict[str, Any]]:
|
| 1237 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1238 |
+
if not _executor_roundtrip_allowed("BRAIN_REMOTE_WEB_SEARCH_ENABLED", default=True):
|
| 1239 |
+
return []
|
| 1240 |
+
try:
|
| 1241 |
+
response = requests.post(
|
| 1242 |
+
f"{executor_url}/tools/search",
|
| 1243 |
+
json={"query": query, "num_results": 5},
|
| 1244 |
+
headers=_brain_headers(),
|
| 1245 |
+
timeout=(
|
| 1246 |
+
_executor_connect_timeout(),
|
| 1247 |
+
_executor_read_timeout("BRAIN_REMOTE_WEB_SEARCH_TIMEOUT_SEC", 8.0),
|
| 1248 |
+
),
|
| 1249 |
+
)
|
| 1250 |
+
if response.status_code == 200:
|
| 1251 |
+
payload = response.json()
|
| 1252 |
+
results = payload.get("results", [])
|
| 1253 |
+
return results if isinstance(results, list) else []
|
| 1254 |
+
except Exception:
|
| 1255 |
+
logger.warning("Web search failed", exc_info=True)
|
| 1256 |
+
return []
|
| 1257 |
+
|
| 1258 |
+
|
| 1259 |
+
def _format_context_blocks(knowledge: list[dict[str, Any]], web_results: list[dict[str, Any]]) -> str:
|
| 1260 |
+
blocks: list[str] = []
|
| 1261 |
+
if knowledge:
|
| 1262 |
+
lines = []
|
| 1263 |
+
for item in knowledge[:4]:
|
| 1264 |
+
source = item.get("source", "knowledge")
|
| 1265 |
+
content = item.get("content", "") or item.get("text", "")
|
| 1266 |
+
lines.append(f"- [{source}] {content[:500]}")
|
| 1267 |
+
blocks.append("Knowledge:\n" + "\n".join(lines))
|
| 1268 |
+
if web_results:
|
| 1269 |
+
lines = []
|
| 1270 |
+
for item in web_results[:5]:
|
| 1271 |
+
lines.append(f"- {item.get('title', '')}: {item.get('snippet', '')}")
|
| 1272 |
+
blocks.append("Web:\n" + "\n".join(lines))
|
| 1273 |
+
return "\n\n".join(blocks).strip()
|
| 1274 |
+
|
| 1275 |
+
|
| 1276 |
+
def _project_context_tags(text: str) -> list[str]:
|
| 1277 |
+
source = str(text or "")
|
| 1278 |
+
lowered = source.lower()
|
| 1279 |
+
tags: list[str] = []
|
| 1280 |
+
tag_rules = [
|
| 1281 |
+
("brain_runtime", ["Ø§ÙØ¹ÙÙ", "brain", "model", "Ù
ÙØ¯ÙÙ", "اÙÙÙ
ÙØ°Ø¬"]),
|
| 1282 |
+
("executor_runtime", ["اÙÙÙÙÙ Ø§ÙØªÙÙÙØ°Ù", "executor", "agent"]),
|
| 1283 |
+
("tunnel_runtime", ["اÙÙÙÙ", "tunnel", "ngrok", "cloudflare"]),
|
| 1284 |
+
("url_routing", ["Ø§ÙØ±Ø§Ø¨Ø·", "url", "endpoint", "ÙÙÙÙ"]),
|
| 1285 |
+
("restart_sync", ["restart", "Ø±ÙØ³ØªØ§Ø±Øª", "إعادة تشغÙÙ", "اعادة تشغÙÙ", "sync", "Ù
زاÙ
ÙØ©"]),
|
| 1286 |
+
("knowledge_memory", ["memory", "Ø°Ø§ÙØ±Ø©", "Ù
Ø¹Ø±ÙØ©", "knowledge", "rag", "Ø·Ø¨ÙØ§Øª", "embedding", "embeddings"]),
|
| 1287 |
+
("kaggle_runtime", ["kaggle", "ÙØ§Ø¬Ù"]),
|
| 1288 |
+
]
|
| 1289 |
+
for tag, markers in tag_rules:
|
| 1290 |
+
if any(marker in source or marker in lowered for marker in markers):
|
| 1291 |
+
tags.append(tag)
|
| 1292 |
+
return tags
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
def _expand_project_query(query: str) -> str:
|
| 1296 |
+
tags = _project_context_tags(query)
|
| 1297 |
+
additions: list[str] = []
|
| 1298 |
+
if "tunnel_runtime" in tags:
|
| 1299 |
+
additions.append("ngrok tunnel public url reverse proxy runtime restart")
|
| 1300 |
+
if "restart_sync" in tags:
|
| 1301 |
+
additions.append("system restart sync uvicorn process reuse public url")
|
| 1302 |
+
if "executor_runtime" in tags:
|
| 1303 |
+
additions.append("executor share settings control plane local machine")
|
| 1304 |
+
if "knowledge_memory" in tags:
|
| 1305 |
+
additions.append("knowledge layers rag embeddings preferences profile")
|
| 1306 |
+
if "brain_runtime" in tags:
|
| 1307 |
+
additions.append("brain server kaggle model startup runtime")
|
| 1308 |
+
return query if not additions else f"{query}\nContext expansion: {' | '.join(additions)}"
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
def _fetch_style_profile() -> dict[str, Any]:
|
| 1312 |
+
firebase_profile = FIREBASE.get_document("profiles", "style", ttl_sec=30.0)
|
| 1313 |
+
if firebase_profile:
|
| 1314 |
+
return firebase_profile
|
| 1315 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1316 |
+
if not executor_url or not _executor_roundtrip_allowed("BRAIN_REMOTE_STYLE_PROFILE_ENABLED", default=False):
|
| 1317 |
+
return {}
|
| 1318 |
+
try:
|
| 1319 |
+
response = requests.get(
|
| 1320 |
+
f"{executor_url}/preferences/profile",
|
| 1321 |
+
headers=_brain_headers(),
|
| 1322 |
+
timeout=(
|
| 1323 |
+
_executor_connect_timeout(),
|
| 1324 |
+
_executor_read_timeout("BRAIN_REMOTE_STYLE_PROFILE_TIMEOUT_SEC", 2.5),
|
| 1325 |
+
),
|
| 1326 |
+
)
|
| 1327 |
+
if response.status_code != 200:
|
| 1328 |
+
return {}
|
| 1329 |
+
payload = response.json().get("profile", {})
|
| 1330 |
+
return payload if isinstance(payload, dict) else {}
|
| 1331 |
+
except requests.exceptions.ReadTimeout:
|
| 1332 |
+
logger.info("Style profile load timed out; continuing without remote style profile")
|
| 1333 |
+
return {}
|
| 1334 |
+
except Exception:
|
| 1335 |
+
logger.warning("Failed to load style profile", exc_info=True)
|
| 1336 |
+
return {}
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
def _render_style_profile_context(profile: dict[str, Any]) -> str:
|
| 1340 |
+
if not profile:
|
| 1341 |
+
return ""
|
| 1342 |
+
preferences = profile.get("preferences", []) or []
|
| 1343 |
+
examples = profile.get("examples", []) or []
|
| 1344 |
+
lexical_signals = profile.get("lexical_signals", []) or []
|
| 1345 |
+
style_markers = profile.get("style_markers", {}) or {}
|
| 1346 |
+
persona_summary = str(profile.get("persona_summary") or "").strip()
|
| 1347 |
+
response_contract = str(profile.get("response_contract") or "").strip()
|
| 1348 |
+
lines: list[str] = []
|
| 1349 |
+
if persona_summary:
|
| 1350 |
+
lines.append(f"User persona summary: {persona_summary}")
|
| 1351 |
+
if response_contract:
|
| 1352 |
+
lines.append(f"Response contract: {response_contract}")
|
| 1353 |
+
if preferences:
|
| 1354 |
+
lines.append("User Style Preferences:")
|
| 1355 |
+
for item in preferences[:10]:
|
| 1356 |
+
lines.append(f"- {item}")
|
| 1357 |
+
if lexical_signals:
|
| 1358 |
+
lines.append("User Lexical Signals:")
|
| 1359 |
+
for item in lexical_signals[:10]:
|
| 1360 |
+
lines.append(f"- {item}")
|
| 1361 |
+
if style_markers:
|
| 1362 |
+
lines.append("Style Markers:")
|
| 1363 |
+
for key, value in sorted(style_markers.items()):
|
| 1364 |
+
lines.append(f"- {key}: {value}")
|
| 1365 |
+
if examples:
|
| 1366 |
+
lines.append("Recent User Style Examples:")
|
| 1367 |
+
for sample in examples[-3:]:
|
| 1368 |
+
user_text = str(sample.get("user_input") or "").strip()
|
| 1369 |
+
assistant_text = str(sample.get("assistant_reply") or "").strip()
|
| 1370 |
+
if user_text:
|
| 1371 |
+
lines.append(f"- User: {user_text}")
|
| 1372 |
+
if assistant_text:
|
| 1373 |
+
lines.append(f" Assistant: {assistant_text}")
|
| 1374 |
+
return "\n".join(lines).strip()
|
| 1375 |
+
|
| 1376 |
+
|
| 1377 |
+
def _project_domain_context(user_input: str, context: dict[str, Any] | None = None) -> str:
|
| 1378 |
+
tags = _project_context_tags(user_input)
|
| 1379 |
+
if not tags:
|
| 1380 |
+
return ""
|
| 1381 |
+
lines = [
|
| 1382 |
+
"Project Domain Glossary:",
|
| 1383 |
+
"- In this project, terms like Ø§ÙØ¹ÙÙ, اÙÙÙÙÙ Ø§ÙØªÙÙÙØ°Ù, اÙÙÙÙ, Ø§ÙØ±Ø§Ø¨Ø·, Ø§ÙØ±Ùستارت, اÙÙ
زاÙ
ÙØ©, Ø§ÙØ°Ø§Ùرة, ÙØ§ÙØ·Ø¨ÙØ§Øª usually refer to software runtime and operations concepts.",
|
| 1384 |
+
"- Treat اÙÙÙÙ as ngrok or cloudflare tunnel unless the user explicitly asks for a literal/civil meaning.",
|
| 1385 |
+
"- Treat Ø§ÙØ±Ø§Ø¨Ø· as public URL, endpoint, or routing target when the surrounding context mentions deployment, Kaggle, restart, or sync.",
|
| 1386 |
+
"- Treat Ø§ÙØ¹ÙÙ as the remote Brain service/model runtime, and اÙÙÙÙÙ Ø§ÙØªÙÙÙØ°Ù as the local executor/control plane on the user's device.",
|
| 1387 |
+
]
|
| 1388 |
+
if "restart_sync" in tags:
|
| 1389 |
+
lines.append("- Restart means process/service restart; preserving the same public URL matters more than creating a fresh tunnel.")
|
| 1390 |
+
if "knowledge_memory" in tags:
|
| 1391 |
+
lines.append("- Knowledge, embeddings, layers, and memory refer to the RAG and memory system inside this project.")
|
| 1392 |
+
if "kaggle_runtime" in tags:
|
| 1393 |
+
lines.append("- Kaggle here is the remote runtime hosting the Brain service.")
|
| 1394 |
+
role_name = str((context or {}).get("role_name") or "").strip()
|
| 1395 |
+
if role_name:
|
| 1396 |
+
lines.append(f"- Current assigned role: {role_name}.")
|
| 1397 |
+
return "\n".join(lines)
|
| 1398 |
+
|
| 1399 |
+
|
| 1400 |
+
def _firebase_runtime_context(role_name: str, language: str) -> str:
|
| 1401 |
+
snapshot = _firebase_runtime_snapshot()
|
| 1402 |
+
lines: list[str] = []
|
| 1403 |
+
roles = snapshot.get("roles") or []
|
| 1404 |
+
if roles:
|
| 1405 |
+
enabled_roles = [
|
| 1406 |
+
str(item.get("name") or item.get("id") or "").strip()
|
| 1407 |
+
for item in roles
|
| 1408 |
+
if str(item.get("enabled", True)).strip().lower() not in {"0", "false", "no", "off"}
|
| 1409 |
+
]
|
| 1410 |
+
enabled_roles = [item for item in enabled_roles if item]
|
| 1411 |
+
if enabled_roles:
|
| 1412 |
+
lines.append("Live roles from Firestore: " + ", ".join(enabled_roles[:12]))
|
| 1413 |
+
models = snapshot.get("models") or []
|
| 1414 |
+
if models:
|
| 1415 |
+
preferred = [
|
| 1416 |
+
item for item in models
|
| 1417 |
+
if str(item.get("enabled", True)).strip().lower() not in {"0", "false", "no", "off"}
|
| 1418 |
+
]
|
| 1419 |
+
if preferred:
|
| 1420 |
+
labels = [str(item.get("label") or item.get("id") or "").strip() for item in preferred[:5]]
|
| 1421 |
+
labels = [item for item in labels if item]
|
| 1422 |
+
if labels:
|
| 1423 |
+
lines.append("Live model profiles: " + ", ".join(labels))
|
| 1424 |
+
platforms = snapshot.get("platforms") or []
|
| 1425 |
+
if platforms:
|
| 1426 |
+
names = [str(item.get("name") or "").strip() for item in platforms[:6] if str(item.get("name") or "").strip()]
|
| 1427 |
+
if names:
|
| 1428 |
+
lines.append("Live platforms: " + ", ".join(names))
|
| 1429 |
+
prompt_body = _firebase_prompt_body(role_name or "chat", language) or _firebase_prompt_body(role_name or "chat", "en")
|
| 1430 |
+
if prompt_body:
|
| 1431 |
+
lines.append(f"Live Firestore prompt for role '{role_name or 'chat'}': {prompt_body}")
|
| 1432 |
+
return "\n".join(lines).strip()
|
| 1433 |
+
|
| 1434 |
+
|
| 1435 |
+
def _append_runtime_instructions(context_block: str, context: dict[str, Any]) -> str:
|
| 1436 |
+
instructions = str((context or {}).get("system_instructions") or "").strip()
|
| 1437 |
+
role_name = str((context or {}).get("role_name") or "").strip()
|
| 1438 |
+
user_input = str((context or {}).get("user_input") or "").strip()
|
| 1439 |
+
language = _detect_language(user_input)
|
| 1440 |
+
style_profile = _render_style_profile_context(_fetch_style_profile())
|
| 1441 |
+
domain_context = _project_domain_context(user_input, context)
|
| 1442 |
+
firebase_context = _firebase_runtime_context(role_name, language)
|
| 1443 |
+
if not instructions and not role_name and not style_profile and not domain_context and not firebase_context:
|
| 1444 |
+
return context_block
|
| 1445 |
+
extra = []
|
| 1446 |
+
if role_name:
|
| 1447 |
+
extra.append(f"Assigned role: {role_name}")
|
| 1448 |
+
if instructions:
|
| 1449 |
+
extra.append(instructions)
|
| 1450 |
+
if firebase_context:
|
| 1451 |
+
extra.append(firebase_context)
|
| 1452 |
+
if style_profile:
|
| 1453 |
+
extra.append(style_profile)
|
| 1454 |
+
if domain_context:
|
| 1455 |
+
extra.append(domain_context)
|
| 1456 |
+
extra_block = "Runtime Instructions:\n" + "\n".join(extra)
|
| 1457 |
+
return (context_block + "\n\n" + extra_block).strip() if context_block else extra_block
|
| 1458 |
+
|
| 1459 |
+
|
| 1460 |
+
def _extract_exact_reply_instruction(user_input: str) -> str:
|
| 1461 |
+
text = (user_input or "").strip()
|
| 1462 |
+
patterns = [
|
| 1463 |
+
r'(?is)reply\s+with\s+exactly\s+[:"]?\s*(.+?)\s*[".]?$',
|
| 1464 |
+
r'(?is)respond\s+with\s+exactly\s+[:"]?\s*(.+?)\s*[".]?$',
|
| 1465 |
+
r"(?is)ÙÙ\s+ÙÙØ·[:ï¼]?\s*(.+?)\s*$",
|
| 1466 |
+
r"(?is)Ø§ÙØªØ¨\s+ÙÙØ·[:ï¼]?\s*(.+?)\s*$",
|
| 1467 |
+
]
|
| 1468 |
+
for pattern in patterns:
|
| 1469 |
+
match = re.search(pattern, text)
|
| 1470 |
+
if match:
|
| 1471 |
+
return match.group(1).strip().strip("\"'`")
|
| 1472 |
+
return ""
|
| 1473 |
+
|
| 1474 |
+
|
| 1475 |
+
def _extract_exact_reply_instruction_safe(user_input: str) -> str:
|
| 1476 |
+
text = (user_input or "").strip()
|
| 1477 |
+
patterns = [
|
| 1478 |
+
r'(?is)reply\s+with\s+exactly\s+[:"]?\s*(.+?)\s*[".]?$',
|
| 1479 |
+
r'(?is)respond\s+with\s+exactly\s+[:"]?\s*(.+?)\s*[".]?$',
|
| 1480 |
+
r"(?is)\u0642\u0644\s+\u0641\u0642\u0637[:\uff1a]?\s*(.+?)\s*$",
|
| 1481 |
+
r"(?is)\u0627\u0643\u062a\u0628\s+\u0641\u0642\u0637[:\uff1a]?\s*(.+?)\s*$",
|
| 1482 |
+
]
|
| 1483 |
+
for pattern in patterns:
|
| 1484 |
+
match = re.search(pattern, text)
|
| 1485 |
+
if match:
|
| 1486 |
+
return match.group(1).strip().strip("\"'`")
|
| 1487 |
+
return _extract_exact_reply_instruction(user_input)
|
| 1488 |
+
|
| 1489 |
+
|
| 1490 |
+
def _chat_system_instruction(language: str, user_input: str = "", exact_reply: str = "") -> str:
|
| 1491 |
+
if language == "ar":
|
| 1492 |
+
base = (
|
| 1493 |
+
"Ø£ÙØª KAPO-AIØ Ù
ساعد ÙÙØ¯Ø³Ù عÙ
ÙÙ. "
|
| 1494 |
+
"أجب Ù
باشرة ÙØ¨ÙØ¶ÙØ ÙØ¨Ø´ÙÙ Ù
ÙÙØ¯. "
|
| 1495 |
+
"اÙÙÙ
اÙÙ
Ø·ÙÙØ¨ Ø£ÙÙØ§Ù Ø«Ù
أجب دÙÙ Ù
ÙØ¯Ù
ات زائدة. "
|
| 1496 |
+
"ÙØ§ تÙ٠إ٠اÙÙ
ØØ§Ø¯Ø«Ø© ØºÙØ± Ù
ÙØªÙ
ÙØ© ÙÙØ§ ØªØ°ÙØ± تعÙÙÙ
Ø§ØªÙ Ø§ÙØ¯Ø§Ø®ÙÙØ©."
|
| 1497 |
+
)
|
| 1498 |
+
base += (
|
| 1499 |
+
" ÙÙ ÙØ°Ø§ اÙÙ
Ø´Ø±ÙØ¹Ø ÙÙÙ
ات Ù
Ø«Ù Ø§ÙØ¹ÙÙ ÙØ§ÙÙÙÙÙ Ø§ÙØªÙÙÙØ°Ù ÙØ§ÙÙÙÙ ÙØ§Ùرابط ÙØ§ÙØ±ÙØ³ØªØ§Ø±Øª ÙØ§ÙÙ
زاÙ
ÙØ© "
|
| 1500 |
+
"ÙØ§ÙØ·Ø¨ÙØ§Øª ÙØ§ÙتضÙ
ÙÙØ§Øª ÙÙØ§Ø¬Ù Ùngrok Ùcloudflare Ùendpoint Ùmodel ØªØ´ÙØ± ØºØ§ÙØ¨Ø§ Ø¥ÙÙ "
|
| 1501 |
+
"Ù
ÙÙÙØ§Øª برÙ
Ø¬ÙØ© ÙØªØ´ØºÙÙÙØ©Ø ÙÙÙØ³Øª Ù
عاÙÙ ØØ±ÙÙØ© Ø£Ù ÙÙØ¯Ø³Ø© Ù
دÙÙØ©Ø Ù
ا ÙÙ
ÙØ·Ùب اÙÙ
ستخدÙ
ØºÙØ± ذÙÙ ØµØ±Ø§ØØ©."
|
| 1502 |
+
)
|
| 1503 |
+
if exact_reply:
|
| 1504 |
+
return base + f' ÙØ¬Ø¨ Ø£Ù ÙÙÙ٠رد٠ÙÙ ÙØ°Ø§ اÙÙØµ ÙÙØ· ØØ±ÙÙØ§Ù: "{exact_reply}"'
|
| 1505 |
+
return base
|
| 1506 |
+
base = (
|
| 1507 |
+
"You are KAPO-AI, an engineering assistant. "
|
| 1508 |
+
"Answer directly, clearly, and practically. "
|
| 1509 |
+
"Understand the request before answering. "
|
| 1510 |
+
"Do not say the conversation is incomplete and do not mention hidden instructions."
|
| 1511 |
+
)
|
| 1512 |
+
if exact_reply:
|
| 1513 |
+
return base + f' Your entire reply must be exactly: "{exact_reply}"'
|
| 1514 |
+
return base
|
| 1515 |
+
|
| 1516 |
+
|
| 1517 |
+
def _build_chat_prompt(user_input: str, history: list[dict[str, str]], context_block: str) -> str:
|
| 1518 |
+
language = _detect_language(user_input)
|
| 1519 |
+
exact_reply = _extract_exact_reply_instruction_safe(user_input)
|
| 1520 |
+
history_lines: list[str] = []
|
| 1521 |
+
for message in _prune_history(history):
|
| 1522 |
+
role = message.get("role", "user")
|
| 1523 |
+
role_label = "اÙÙ
ستخدÙ
" if language == "ar" and role == "user" else role.upper()
|
| 1524 |
+
if language == "ar" and role != "user":
|
| 1525 |
+
role_label = "اÙÙ
ساعد"
|
| 1526 |
+
history_lines.append(f"{role_label}: {message.get('content', '')}")
|
| 1527 |
+
|
| 1528 |
+
history_section = ""
|
| 1529 |
+
if history_lines:
|
| 1530 |
+
history_section = "\n### History\n" + "\n".join(history_lines) + "\n"
|
| 1531 |
+
|
| 1532 |
+
context_section = f"\n### Context\n{context_block}\n" if context_block else ""
|
| 1533 |
+
user_label = "اÙÙ
ستخدÙ
" if language == "ar" else "User"
|
| 1534 |
+
assistant_label = "اÙÙ
ساعد" if language == "ar" else "Assistant"
|
| 1535 |
+
return (
|
| 1536 |
+
f"### System\n{_chat_system_instruction(language, user_input, exact_reply)}\n"
|
| 1537 |
+
f"{history_section}"
|
| 1538 |
+
f"{context_section}"
|
| 1539 |
+
f"### Instruction\n"
|
| 1540 |
+
f"{user_label}: {user_input}\n"
|
| 1541 |
+
f"{assistant_label}:"
|
| 1542 |
+
)
|
| 1543 |
+
|
| 1544 |
+
|
| 1545 |
+
def _response_looks_bad(text: str, language: str) -> bool:
|
| 1546 |
+
cleaned = (text or "").strip()
|
| 1547 |
+
if not cleaned:
|
| 1548 |
+
return True
|
| 1549 |
+
markers = [
|
| 1550 |
+
"the assistant is not sure",
|
| 1551 |
+
"conversation seems incomplete",
|
| 1552 |
+
"provide more information",
|
| 1553 |
+
"unless otherwise noted",
|
| 1554 |
+
"as an ai model developed by",
|
| 1555 |
+
"developed by ibm",
|
| 1556 |
+
"tensorflow library",
|
| 1557 |
+
"dataset of 1024",
|
| 1558 |
+
]
|
| 1559 |
+
if any(marker in cleaned.lower() for marker in markers):
|
| 1560 |
+
return True
|
| 1561 |
+
if language == "ar":
|
| 1562 |
+
arabic_chars = len(re.findall(r"[\u0600-\u06FF]", cleaned))
|
| 1563 |
+
latin_chars = len(re.findall(r"[A-Za-z]", cleaned))
|
| 1564 |
+
if arabic_chars < 8 and latin_chars > max(12, arabic_chars * 2):
|
| 1565 |
+
return True
|
| 1566 |
+
return False
|
| 1567 |
+
|
| 1568 |
+
|
| 1569 |
+
def _fallback_response(user_input: str) -> str:
|
| 1570 |
+
if _detect_language(user_input) == "ar":
|
| 1571 |
+
return "ÙÙÙ
ت Ø±Ø³Ø§ÙØªÙØ ÙÙÙ Ø§ÙØ±Ø¯ اÙÙ
ÙÙØ¯ ÙÙ
ÙÙÙ ØµØ§ÙØØ§Ù ÙÙØ§Ø³ØªØ®Ø¯Ø§Ù
. أعد ØµÙØ§ØºØ© Ø§ÙØ·Ùب بشÙÙ Ø£ÙØ«Ø± ØªØØ¯ÙداÙ."
|
| 1572 |
+
return "I understood your message, but the generated reply was not usable. Please rephrase the request more specifically."
|
| 1573 |
+
|
| 1574 |
+
|
| 1575 |
+
def _project_specific_fast_reply(user_input: str) -> str:
|
| 1576 |
+
text = (user_input or "").strip()
|
| 1577 |
+
lower = text.lower()
|
| 1578 |
+
if any(token in text for token in ("اÙ٠اÙÙÙ Ø§ØªØµÙØ", "Ø¥Ù٠اÙÙÙ Ø§ØªØµÙØ", "Ù٠اÙ٠اÙÙÙ Ø§ØªØµÙØ", "Ù٠إÙ٠اÙÙÙ Ø§ØªØµÙØ")) and any(
|
| 1579 |
+
token in text or token in lower for token in ("اÙÙÙÙ", "Ø§ÙØ±Ø§Ø¨Ø·", "Ø§ÙØ±Ùستارت", "restart")
|
| 1580 |
+
):
|
| 1581 |
+
return (
|
| 1582 |
+
"Ø§ÙØ°Ù Ø§ØªØµÙØ ÙÙ Ø£Ù Ø§ÙØ±Ùستارت Ø§ÙØ¯Ø§Ø®Ù٠بÙÙ ÙØ¹Ùد تشغÙ٠خدÙ
Ø© Ø§ÙØ¹ÙÙ ÙÙØ³Ùا Ù
Ù ØºÙØ± Ù
ا ÙÙØ³Ø± اÙÙÙÙ Ø£Ù ÙØºÙÙØ± Ø§ÙØ±Ø§Ø¨Ø· Ø§ÙØ¹Ø§Ù
. "
|
| 1583 |
+
"ÙÙÙ Ø¸ÙØ± تÙÙÙ ÙØµÙر Ø£Ø«ÙØ§Ø¡ Ø§ÙØ¥ÙÙØ§Ø¹ ÙÙØ°Ø§ ÙÙÙÙ Ù
Ù Ø±Ø¬ÙØ¹ Ø§ÙØ®Ø¯Ù
Ø©Ø ÙØ§ Ù
Ù Ø¥ÙØ´Ø§Ø¡ ÙÙÙ Ø¬Ø¯ÙØ¯."
|
| 1584 |
+
)
|
| 1585 |
+
if "اÙÙÙÙ" in text and any(token in text for token in ("Ø¥ØµÙØ§Ø", "Ø§ØµÙØ§Ø", "Ø§ÙØ°Ù تÙ
", "Ø§ØªØµÙØ", "تÙ
Ø¥ØµÙØ§Ø", "تÙ
Ø§ØµÙØ§Ø")):
|
| 1586 |
+
return (
|
| 1587 |
+
"تÙ
ÙØµÙ Ø¯ÙØ±Ø© ØÙاة اÙÙÙÙ Ø¹Ù Ø¯ÙØ±Ø© ØÙاة خدÙ
Ø© Ø§ÙØ¹ÙÙØ ÙØ£ØµØ¨Ø ngrok ÙØ¹Ù
Ù ÙØ¹Ø§Ù
Ù Ù
ستÙ٠ع٠عÙ
ÙÙØ© Uvicorn. "
|
| 1588 |
+
"ÙØ¨Ø§ÙتاÙÙ ÙØØªÙØ¸ /system/restart بÙÙØ³ Ø§ÙØ±Ø§Ø¨Ø· Ø§ÙØ¹Ø§Ù
Ø¨Ø¯Ù Ø¥ÙØ´Ø§Ø¡ رابط Ø¬Ø¯ÙØ¯Ø ÙÙØ¯ ÙØ¸Ùر ERR_NGROK_8012 Ù
Ø¤ÙØªÙا ÙÙØ· Ø£Ø«ÙØ§Ø¡ Ø§ÙØ¥ÙÙØ§Ø¹."
|
| 1589 |
+
)
|
| 1590 |
+
if "ÙÙØ³ Ø§ÙØ±Ø§Ø¨Ø·" in text and ("Ø±ÙØ³ØªØ§Ø±Øª" in text or "restart" in lower):
|
| 1591 |
+
return (
|
| 1592 |
+
"اÙÙØ¯Ù ÙÙØ§ Ø£Ù ØªØ¹ÙØ¯ Ø§ÙØ®Ø¯Ù
Ø© Ø§ÙØ¥ÙÙØ§Ø¹ داخÙÙÙØ§ Ù
ع Ø§ÙØ¥Ø¨Ùاء عÙÙ ÙÙØ³ اÙÙ public URL. "
|
| 1593 |
+
"ÙØ°ÙÙ ÙØ¨ÙÙ tunnel ØÙÙØ§Ø بÙÙÙ
ا ØªØ¹ÙØ¯ خدÙ
Ø© localhost:7860 ÙÙØ¹Ù
٠بعد Ø«ÙØ§ÙÙ ÙÙÙÙØ© عÙÙ ÙÙØ³ Ø§ÙØ±Ø§Ø¨Ø·."
|
| 1594 |
+
)
|
| 1595 |
+
return ""
|
| 1596 |
+
|
| 1597 |
+
|
| 1598 |
+
def _generate_response(user_input: str, history: list[dict[str, str]], context_block: str) -> str:
|
| 1599 |
+
language = _detect_language(user_input)
|
| 1600 |
+
exact_reply = _extract_exact_reply_instruction_safe(user_input)
|
| 1601 |
+
if exact_reply:
|
| 1602 |
+
return exact_reply
|
| 1603 |
+
fast_reply = _project_specific_fast_reply(user_input)
|
| 1604 |
+
if fast_reply:
|
| 1605 |
+
return fast_reply
|
| 1606 |
+
if MODEL is None:
|
| 1607 |
+
if language == "ar":
|
| 1608 |
+
return "Ø§ÙØ®Ø¯Ù
Ø© تعÙ
Ù ÙÙ٠تÙÙÙØ¯ Ø§ÙØ±Ø¯ Ø§ÙØØ± ØºÙØ± Ù
ØªØ§Ø Ø§ÙØ¢Ù ÙØ£Ù اÙÙÙ
ÙØ°Ø¬ ØºÙØ± Ù
ØÙ
Ù."
|
| 1609 |
+
return "The Brain is online, but natural chat generation is unavailable because the model is not loaded."
|
| 1610 |
+
|
| 1611 |
+
prompt = _build_chat_prompt(user_input, history, context_block)
|
| 1612 |
+
|
| 1613 |
+
try:
|
| 1614 |
+
max_tokens = 80 if language == "ar" else 96
|
| 1615 |
+
output = MODEL(
|
| 1616 |
+
prompt,
|
| 1617 |
+
max_tokens=max_tokens,
|
| 1618 |
+
temperature=0.1,
|
| 1619 |
+
top_p=0.85,
|
| 1620 |
+
stop=["\nUser:", "\nUSER:", "\nاÙÙ
ستخدÙ
:", "\n###", "<|EOT|>"],
|
| 1621 |
+
)
|
| 1622 |
+
text = output["choices"][0]["text"].strip()
|
| 1623 |
+
if _response_looks_bad(text, language):
|
| 1624 |
+
return _fallback_response(user_input)
|
| 1625 |
+
return text or ("تÙ
Ø§Ø³ØªÙØ§Ù
Ø±Ø³Ø§ÙØªÙ." if language == "ar" else "I received your message.")
|
| 1626 |
+
except Exception:
|
| 1627 |
+
logger.exception("Model generation failed")
|
| 1628 |
+
if language == "ar":
|
| 1629 |
+
return "ÙÙÙ
ت Ø·ÙØ¨ÙØ ÙÙÙ ÙØ´Ù تÙÙÙØ¯ Ø§ÙØ±Ø¯ اÙÙØµÙ."
|
| 1630 |
+
return "I understood your request, but text generation failed."
|
| 1631 |
+
|
| 1632 |
+
|
| 1633 |
+
def _store_chat_trace(request_id: str, payload: dict[str, Any]) -> None:
|
| 1634 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1635 |
+
if not _executor_roundtrip_allowed("BRAIN_REMOTE_TRACE_STORE_ENABLED", default=True):
|
| 1636 |
+
return
|
| 1637 |
+
try:
|
| 1638 |
+
requests.post(
|
| 1639 |
+
f"{executor_url}/memory/store",
|
| 1640 |
+
json={"request_id": request_id, "payload": payload},
|
| 1641 |
+
headers=_brain_headers(),
|
| 1642 |
+
timeout=(
|
| 1643 |
+
_executor_connect_timeout(),
|
| 1644 |
+
_executor_read_timeout("BRAIN_REMOTE_TRACE_STORE_TIMEOUT_SEC", 2.5),
|
| 1645 |
+
),
|
| 1646 |
+
)
|
| 1647 |
+
except requests.exceptions.ReadTimeout:
|
| 1648 |
+
logger.info("Chat trace store timed out; continuing")
|
| 1649 |
+
except Exception:
|
| 1650 |
+
logger.warning("Failed to store chat trace on executor", exc_info=True)
|
| 1651 |
+
|
| 1652 |
+
|
| 1653 |
+
def _ingest_chat_knowledge(request_id: str, user_input: str, reply: str) -> None:
|
| 1654 |
+
if len(reply or "") < 180:
|
| 1655 |
+
return
|
| 1656 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1657 |
+
if not _executor_roundtrip_allowed("BRAIN_REMOTE_AUTO_INGEST_ENABLED", default=False):
|
| 1658 |
+
return
|
| 1659 |
+
payload = {
|
| 1660 |
+
"request_id": request_id,
|
| 1661 |
+
"payload": {
|
| 1662 |
+
"source": "auto_chat",
|
| 1663 |
+
"content": f"User: {user_input}\nAssistant: {reply}",
|
| 1664 |
+
},
|
| 1665 |
+
}
|
| 1666 |
+
try:
|
| 1667 |
+
requests.post(
|
| 1668 |
+
f"{executor_url}/rag/ingest",
|
| 1669 |
+
json=payload,
|
| 1670 |
+
headers=_brain_headers(),
|
| 1671 |
+
timeout=(
|
| 1672 |
+
_executor_connect_timeout(),
|
| 1673 |
+
_executor_read_timeout("BRAIN_REMOTE_AUTO_INGEST_TIMEOUT_SEC", 3.0),
|
| 1674 |
+
),
|
| 1675 |
+
)
|
| 1676 |
+
except requests.exceptions.ReadTimeout:
|
| 1677 |
+
logger.info("Auto-ingest chat knowledge timed out; continuing")
|
| 1678 |
+
except Exception:
|
| 1679 |
+
logger.warning("Failed to auto-ingest chat knowledge", exc_info=True)
|
| 1680 |
+
|
| 1681 |
+
|
| 1682 |
+
def _learn_user_style(request_id: str, user_input: str, reply: str, context: dict[str, Any]) -> None:
|
| 1683 |
+
executor_url = os.getenv("EXECUTOR_URL", "").strip().rstrip("/")
|
| 1684 |
+
if not executor_url or not _executor_roundtrip_allowed("BRAIN_REMOTE_STYLE_PROFILE_ENABLED", default=False):
|
| 1685 |
+
return
|
| 1686 |
+
try:
|
| 1687 |
+
requests.post(
|
| 1688 |
+
f"{executor_url}/preferences/learn",
|
| 1689 |
+
json={
|
| 1690 |
+
"request_id": request_id,
|
| 1691 |
+
"user_input": user_input,
|
| 1692 |
+
"assistant_reply": reply,
|
| 1693 |
+
"context": context or {},
|
| 1694 |
+
},
|
| 1695 |
+
headers=_brain_headers(),
|
| 1696 |
+
timeout=(
|
| 1697 |
+
_executor_connect_timeout(),
|
| 1698 |
+
_executor_read_timeout("BRAIN_REMOTE_TRACE_STORE_TIMEOUT_SEC", 2.5),
|
| 1699 |
+
),
|
| 1700 |
+
)
|
| 1701 |
+
except requests.exceptions.ReadTimeout:
|
| 1702 |
+
logger.info("Style learning timed out; continuing")
|
| 1703 |
+
except Exception:
|
| 1704 |
+
logger.warning("Failed to learn user style on executor", exc_info=True)
|
| 1705 |
+
|
| 1706 |
+
|
| 1707 |
+
def _dispatch_background(task, *args) -> None:
|
| 1708 |
+
try:
|
| 1709 |
+
threading.Thread(target=task, args=args, daemon=True).start()
|
| 1710 |
+
except Exception:
|
| 1711 |
+
logger.warning("Background task dispatch failed", exc_info=True)
|
| 1712 |
+
|
| 1713 |
+
|
| 1714 |
+
def _restart_process(delay_sec: float = 1.0) -> None:
|
| 1715 |
+
if _is_hf_space_runtime():
|
| 1716 |
+
logger.info("Skipping in-process restart on Hugging Face Space runtime")
|
| 1717 |
+
return
|
| 1718 |
+
def _run() -> None:
|
| 1719 |
+
time.sleep(max(0.2, float(delay_sec)))
|
| 1720 |
+
target_root = _sync_target_root()
|
| 1721 |
+
os.chdir(target_root)
|
| 1722 |
+
os.environ["KAPO_INTERNAL_RESTART"] = "1"
|
| 1723 |
+
os.environ.setdefault("KAPO_FAST_INTERNAL_RESTART", "1")
|
| 1724 |
+
if _reuse_public_url_on_restart():
|
| 1725 |
+
current_public_url = _load_saved_public_url()
|
| 1726 |
+
if current_public_url:
|
| 1727 |
+
_remember_public_url(current_public_url)
|
| 1728 |
+
os.environ["KAPO_RESTART_REUSE_PUBLIC_URL"] = "1"
|
| 1729 |
+
port = str(os.getenv("BRAIN_PORT", "7860") or "7860")
|
| 1730 |
+
app_module = str(os.getenv("KAPO_UVICORN_APP") or "").strip()
|
| 1731 |
+
if not app_module:
|
| 1732 |
+
if (Path(target_root) / "brain_server" / "api" / "main.py").exists():
|
| 1733 |
+
app_module = "brain_server.api.main:app"
|
| 1734 |
+
elif (Path(target_root) / "api" / "main.py").exists():
|
| 1735 |
+
app_module = "api.main:app"
|
| 1736 |
+
else:
|
| 1737 |
+
app_module = "brain_server.api.main:app"
|
| 1738 |
+
os.execv(
|
| 1739 |
+
sys.executable,
|
| 1740 |
+
[
|
| 1741 |
+
sys.executable,
|
| 1742 |
+
"-m",
|
| 1743 |
+
"uvicorn",
|
| 1744 |
+
app_module,
|
| 1745 |
+
"--host",
|
| 1746 |
+
"0.0.0.0",
|
| 1747 |
+
"--port",
|
| 1748 |
+
port,
|
| 1749 |
+
],
|
| 1750 |
+
)
|
| 1751 |
+
|
| 1752 |
+
threading.Thread(target=_run, daemon=True).start()
|
| 1753 |
+
|
| 1754 |
+
|
| 1755 |
+
@app.get("/")
|
| 1756 |
+
async def root():
|
| 1757 |
+
return {"status": "ok", "service": "brain_server", "docs": "/docs", "health": "/health"}
|
| 1758 |
+
|
| 1759 |
+
|
| 1760 |
+
@app.get("/runtime/firebase")
|
| 1761 |
+
async def runtime_firebase_snapshot():
|
| 1762 |
+
try:
|
| 1763 |
+
return {"status": "ok", "firebase": _json_safe(_firebase_runtime_snapshot())}
|
| 1764 |
+
except Exception as exc:
|
| 1765 |
+
logger.warning("Failed to build firebase runtime snapshot", exc_info=True)
|
| 1766 |
+
return {"status": "degraded", "firebase": {"platforms": [], "models": [], "prompts": [], "roles": []}, "detail": str(exc)}
|
| 1767 |
+
|
| 1768 |
+
|
| 1769 |
+
@app.get("/runtime/errors")
|
| 1770 |
+
async def runtime_errors(limit: int = 50, level: str = "WARNING"):
|
| 1771 |
+
normalized = str(level or "WARNING").strip().upper()
|
| 1772 |
+
allowed = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
| 1773 |
+
threshold = allowed.get(normalized, 30)
|
| 1774 |
+
items = [item for item in list(RUNTIME_LOG_BUFFER) if allowed.get(str(item.get("level") or "").upper(), 0) >= threshold]
|
| 1775 |
+
return {"status": "ok", "count": len(items[-limit:]), "items": items[-limit:]}
|
| 1776 |
+
|
| 1777 |
+
|
| 1778 |
+
@app.get("/runtime/modernization")
|
| 1779 |
+
async def runtime_modernization():
|
| 1780 |
+
try:
|
| 1781 |
+
return {"status": "ok", "modernization": _runtime_modernization_snapshot()}
|
| 1782 |
+
except Exception as exc:
|
| 1783 |
+
logger.warning("Failed to build runtime modernization snapshot", exc_info=True)
|
| 1784 |
+
return {"status": "degraded", "detail": str(exc), "modernization": {}}
|
| 1785 |
+
|
| 1786 |
+
|
| 1787 |
+
@app.get("/model/status")
|
| 1788 |
+
async def model_status():
|
| 1789 |
+
return {
|
| 1790 |
+
"loaded": MODEL is not None,
|
| 1791 |
+
"error": MODEL_ERROR,
|
| 1792 |
+
"repo_id": MODEL_META.get("repo_id"),
|
| 1793 |
+
"filename": MODEL_META.get("filename"),
|
| 1794 |
+
}
|
| 1795 |
+
|
| 1796 |
+
|
| 1797 |
+
@app.post("/model/load")
|
| 1798 |
+
async def model_load(req: ModelLoadRequest):
|
| 1799 |
+
ensure_model_loaded(req.repo_id, req.filename, hf_token=req.hf_token)
|
| 1800 |
+
return await model_status()
|
| 1801 |
+
|
| 1802 |
+
|
| 1803 |
+
@app.post("/model/hotswap")
|
| 1804 |
+
async def model_hotswap(req: ModelLoadRequest):
|
| 1805 |
+
global MODEL
|
| 1806 |
+
if MODEL is not None:
|
| 1807 |
+
del MODEL
|
| 1808 |
+
gc.collect()
|
| 1809 |
+
MODEL = None
|
| 1810 |
+
ensure_model_loaded(req.repo_id, req.filename, hf_token=req.hf_token)
|
| 1811 |
+
return await model_status()
|
| 1812 |
+
|
| 1813 |
+
|
| 1814 |
+
@app.post("/embeddings")
|
| 1815 |
+
async def embeddings(payload: dict[str, Any]):
|
| 1816 |
+
if EMBED_MODEL is None:
|
| 1817 |
+
_load_embed_model()
|
| 1818 |
+
texts = payload.get("texts") or []
|
| 1819 |
+
if not texts:
|
| 1820 |
+
return {"embeddings": []}
|
| 1821 |
+
return {"embeddings": EMBED_MODEL.encode(texts).tolist()}
|
| 1822 |
+
|
| 1823 |
+
|
| 1824 |
+
@app.post("/chat")
|
| 1825 |
+
async def chat(req: ChatRequest):
|
| 1826 |
+
try:
|
| 1827 |
+
exact_reply = _extract_exact_reply_instruction_safe(req.user_input)
|
| 1828 |
+
if exact_reply:
|
| 1829 |
+
runtime_context = {**(req.context or {}), "user_input": req.user_input}
|
| 1830 |
+
trace_payload = {
|
| 1831 |
+
"mode": "chat",
|
| 1832 |
+
"user_input": req.user_input,
|
| 1833 |
+
"reply": exact_reply,
|
| 1834 |
+
"plan": None,
|
| 1835 |
+
"execution": None,
|
| 1836 |
+
"knowledge": [],
|
| 1837 |
+
"web_results": [],
|
| 1838 |
+
"context": runtime_context,
|
| 1839 |
+
}
|
| 1840 |
+
memory = MemoryAgent()
|
| 1841 |
+
memory.write_short_term(req.request_id, trace_payload)
|
| 1842 |
+
return {
|
| 1843 |
+
"status": "ok",
|
| 1844 |
+
"mode": "chat",
|
| 1845 |
+
"reply": exact_reply,
|
| 1846 |
+
"plan": None,
|
| 1847 |
+
"rationale": None,
|
| 1848 |
+
"execution": None,
|
| 1849 |
+
"knowledge": [],
|
| 1850 |
+
"web_results": [],
|
| 1851 |
+
"timestamp": time.time(),
|
| 1852 |
+
}
|
| 1853 |
+
|
| 1854 |
+
planner = PlannerAgent()
|
| 1855 |
+
reasoning = ReasoningAgent()
|
| 1856 |
+
memory = MemoryAgent()
|
| 1857 |
+
|
| 1858 |
+
mode = "task" if _is_task_request(req.user_input) else "chat"
|
| 1859 |
+
runtime_context = {**(req.context or {}), "user_input": req.user_input}
|
| 1860 |
+
knowledge = _retrieve_knowledge(req.user_input, top_k=4) if _is_knowledge_request(req.user_input, req.context) else []
|
| 1861 |
+
web_results = _search_web(req.user_input) if _is_research_request(req.user_input) else []
|
| 1862 |
+
context_block = _append_runtime_instructions(_format_context_blocks(knowledge, web_results), runtime_context)
|
| 1863 |
+
response_text = ""
|
| 1864 |
+
plan_steps = None
|
| 1865 |
+
execution = None
|
| 1866 |
+
rationale = None
|
| 1867 |
+
|
| 1868 |
+
if mode == "task":
|
| 1869 |
+
plan_steps = planner.run(req.user_input, req.context)
|
| 1870 |
+
rationale = reasoning.run(req.user_input, plan_steps)
|
| 1871 |
+
response_text = (
|
| 1872 |
+
"سأتعاÙ
Ù Ù
ع ÙØ°Ù Ø§ÙØ±Ø³Ø§ÙØ© ÙØ·Ùب تÙÙÙØ°Ø ÙÙÙ
ت Ø¨Ø¨ÙØ§Ø¡ خطة Ù
Ø¨Ø¯Ø¦ÙØ© ÙØ³Ø£Ø¨Ø¯Ø£ Ø§ÙØªÙÙÙØ° تÙÙØ§Ø¦ÙاÙ."
|
| 1873 |
+
if _contains_arabic(req.user_input)
|
| 1874 |
+
else "I treated this as an execution request, built a plan, and started automatic execution."
|
| 1875 |
+
)
|
| 1876 |
+
if req.auto_execute:
|
| 1877 |
+
from api.routes_execute import ExecuteRequest, execute as execute_route
|
| 1878 |
+
|
| 1879 |
+
execution = await execute_route(
|
| 1880 |
+
ExecuteRequest(
|
| 1881 |
+
request_id=req.request_id,
|
| 1882 |
+
plan={"steps": plan_steps},
|
| 1883 |
+
executor_url=os.getenv("EXECUTOR_URL", "").strip() or None,
|
| 1884 |
+
)
|
| 1885 |
+
)
|
| 1886 |
+
if execution.get("report", {}).get("success"):
|
| 1887 |
+
response_text += (
|
| 1888 |
+
"\n\nتÙ
Ø§ÙØªÙÙÙØ° Ø¨ÙØ¬Ø§Ø Ù
Ø¨Ø¯Ø¦ÙØ§Ù."
|
| 1889 |
+
if _contains_arabic(req.user_input)
|
| 1890 |
+
else "\n\nExecution completed successfully."
|
| 1891 |
+
)
|
| 1892 |
+
else:
|
| 1893 |
+
response_text += (
|
| 1894 |
+
"\n\nتÙ
ت اÙÙ
ØØ§ÙÙØ© ÙÙÙ ØªÙØ¬Ø¯ Ù
خرجات ØªØØªØ§Ø¬ Ù
راجعة."
|
| 1895 |
+
if _contains_arabic(req.user_input)
|
| 1896 |
+
else "\n\nExecution ran, but the result still needs review."
|
| 1897 |
+
)
|
| 1898 |
+
else:
|
| 1899 |
+
response_text = _generate_response(req.user_input, req.history, context_block)
|
| 1900 |
+
|
| 1901 |
+
trace_payload = {
|
| 1902 |
+
"mode": mode,
|
| 1903 |
+
"user_input": req.user_input,
|
| 1904 |
+
"reply": response_text,
|
| 1905 |
+
"plan": plan_steps,
|
| 1906 |
+
"execution": execution,
|
| 1907 |
+
"knowledge": knowledge,
|
| 1908 |
+
"web_results": web_results,
|
| 1909 |
+
"context": runtime_context,
|
| 1910 |
+
}
|
| 1911 |
+
memory.write_short_term(req.request_id, trace_payload)
|
| 1912 |
+
_dispatch_background(_store_chat_trace, req.request_id, trace_payload)
|
| 1913 |
+
_dispatch_background(_ingest_chat_knowledge, req.request_id, req.user_input, response_text)
|
| 1914 |
+
_dispatch_background(_learn_user_style, req.request_id, req.user_input, response_text, runtime_context)
|
| 1915 |
+
|
| 1916 |
+
return {
|
| 1917 |
+
"status": "ok",
|
| 1918 |
+
"mode": mode,
|
| 1919 |
+
"reply": response_text,
|
| 1920 |
+
"plan": plan_steps,
|
| 1921 |
+
"rationale": rationale,
|
| 1922 |
+
"execution": execution,
|
| 1923 |
+
"knowledge": knowledge,
|
| 1924 |
+
"web_results": web_results,
|
| 1925 |
+
"timestamp": time.time(),
|
| 1926 |
+
}
|
| 1927 |
+
except Exception as exc:
|
| 1928 |
+
logger.exception("Chat failed")
|
| 1929 |
+
return {
|
| 1930 |
+
"status": "error",
|
| 1931 |
+
"mode": "chat",
|
| 1932 |
+
"reply": "ØØ¯Ø« خطأ Ø£Ø«ÙØ§Ø¡ Ù
Ø¹Ø§ÙØ¬Ø© Ø§ÙØ±Ø³Ø§ÙØ©." if _contains_arabic(req.user_input) else "Chat processing failed.",
|
| 1933 |
+
"detail": str(exc),
|
| 1934 |
+
"timestamp": time.time(),
|
| 1935 |
+
}
|
| 1936 |
+
|
| 1937 |
+
|
| 1938 |
+
@app.post("/init-connection")
|
| 1939 |
+
async def init_connection(payload: ConnectionInit):
|
| 1940 |
+
_remember_executor_url(payload.executor_url)
|
| 1941 |
+
FIREBASE.set_document("runtime", "executor", {"executor_url": payload.executor_url})
|
| 1942 |
+
public_url = _report_known_public_url()
|
| 1943 |
+
if not public_url:
|
| 1944 |
+
public_url = start_ngrok(payload.ngrok_token)
|
| 1945 |
+
return {"status": "connected", "brain_public_url": public_url}
|
| 1946 |
+
|
| 1947 |
+
|
| 1948 |
+
@app.post("/system/publish-url")
|
| 1949 |
+
async def system_publish_url(req: PublishUrlRequest | None = None):
|
| 1950 |
+
payload = req or PublishUrlRequest()
|
| 1951 |
+
explicit_public_url = str(payload.public_url or "").strip().rstrip("/")
|
| 1952 |
+
if explicit_public_url:
|
| 1953 |
+
_remember_public_url(explicit_public_url)
|
| 1954 |
+
_report_brain_url(explicit_public_url)
|
| 1955 |
+
FIREBASE.set_document("brains", explicit_public_url, {"url": explicit_public_url, "status": "healthy", "source": "explicit_publish"})
|
| 1956 |
+
return {"status": "published", "brain_public_url": explicit_public_url, "mode": "explicit"}
|
| 1957 |
+
|
| 1958 |
+
if not payload.start_tunnel:
|
| 1959 |
+
public_url = _report_known_public_url()
|
| 1960 |
+
if public_url:
|
| 1961 |
+
return {"status": "published", "brain_public_url": public_url, "mode": "saved"}
|
| 1962 |
+
return {"status": "skipped", "brain_public_url": None, "mode": "none"}
|
| 1963 |
+
|
| 1964 |
+
public_url = start_ngrok(payload.ngrok_token)
|
| 1965 |
+
if public_url:
|
| 1966 |
+
return {"status": "published", "brain_public_url": public_url, "mode": "ngrok"}
|
| 1967 |
+
public_url = _report_known_public_url()
|
| 1968 |
+
return {"status": "published" if public_url else "error", "brain_public_url": public_url, "mode": "saved" if public_url else "none"}
|
| 1969 |
+
|
| 1970 |
+
|
| 1971 |
+
@app.get("/system/files")
|
| 1972 |
+
async def system_files(path: str = "", include_content: bool = False):
|
| 1973 |
+
try:
|
| 1974 |
+
target = _resolve_sync_path(path)
|
| 1975 |
+
if not target.exists():
|
| 1976 |
+
return {"status": "error", "detail": "Path not found", "path": path}
|
| 1977 |
+
if target.is_file():
|
| 1978 |
+
payload = {"status": "ok", "entry": _describe_sync_entry(target)}
|
| 1979 |
+
if include_content:
|
| 1980 |
+
payload["content"] = target.read_text(encoding="utf-8", errors="ignore")
|
| 1981 |
+
return payload
|
| 1982 |
+
items = sorted((_describe_sync_entry(item) for item in target.iterdir()), key=lambda item: (not item["is_dir"], item["name"].lower()))
|
| 1983 |
+
return {"status": "ok", "root": str(_sync_root_path()), "path": path, "items": items}
|
| 1984 |
+
except Exception as exc:
|
| 1985 |
+
logger.exception("File listing failed")
|
| 1986 |
+
return {"status": "error", "detail": str(exc), "path": path}
|
| 1987 |
+
|
| 1988 |
+
|
| 1989 |
+
@app.post("/system/files/write")
|
| 1990 |
+
async def system_files_write(payload: FileWriteRequest):
|
| 1991 |
+
try:
|
| 1992 |
+
target = _resolve_sync_path(payload.path)
|
| 1993 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 1994 |
+
if target.exists() and target.is_dir():
|
| 1995 |
+
return {"status": "error", "detail": "Target path is a directory"}
|
| 1996 |
+
if target.exists() and not payload.overwrite:
|
| 1997 |
+
return {"status": "error", "detail": "File already exists"}
|
| 1998 |
+
target.write_text(payload.content or "", encoding="utf-8")
|
| 1999 |
+
return {"status": "saved", "entry": _describe_sync_entry(target)}
|
| 2000 |
+
except Exception as exc:
|
| 2001 |
+
logger.exception("File write failed")
|
| 2002 |
+
return {"status": "error", "detail": str(exc), "path": payload.path}
|
| 2003 |
+
|
| 2004 |
+
|
| 2005 |
+
@app.post("/system/files/mkdir")
|
| 2006 |
+
async def system_files_mkdir(payload: FileMkdirRequest):
|
| 2007 |
+
try:
|
| 2008 |
+
target = _resolve_sync_path(payload.path)
|
| 2009 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 2010 |
+
return {"status": "created", "entry": _describe_sync_entry(target)}
|
| 2011 |
+
except Exception as exc:
|
| 2012 |
+
logger.exception("Directory creation failed")
|
| 2013 |
+
return {"status": "error", "detail": str(exc), "path": payload.path}
|
| 2014 |
+
|
| 2015 |
+
|
| 2016 |
+
@app.delete("/system/files")
|
| 2017 |
+
async def system_files_delete(payload: FileDeleteRequest):
|
| 2018 |
+
try:
|
| 2019 |
+
target = _resolve_sync_path(payload.path)
|
| 2020 |
+
if not target.exists():
|
| 2021 |
+
return {"status": "deleted", "path": payload.path, "existed": False}
|
| 2022 |
+
if target.is_dir():
|
| 2023 |
+
if payload.recursive:
|
| 2024 |
+
shutil.rmtree(target)
|
| 2025 |
+
else:
|
| 2026 |
+
target.rmdir()
|
| 2027 |
+
else:
|
| 2028 |
+
target.unlink()
|
| 2029 |
+
return {"status": "deleted", "path": payload.path, "existed": True}
|
| 2030 |
+
except Exception as exc:
|
| 2031 |
+
logger.exception("Delete failed")
|
| 2032 |
+
return {"status": "error", "detail": str(exc), "path": payload.path}
|
| 2033 |
+
|
| 2034 |
+
|
| 2035 |
+
if HAS_MULTIPART:
|
| 2036 |
+
@app.post("/system/sync")
|
| 2037 |
+
async def sync_codebase(file: UploadFile = File(...), restart: bool = False):
|
| 2038 |
+
temp_zip = os.path.join(tempfile.gettempdir(), "kapo_update.zip")
|
| 2039 |
+
try:
|
| 2040 |
+
with open(temp_zip, "wb") as buffer:
|
| 2041 |
+
shutil.copyfileobj(file.file, buffer)
|
| 2042 |
+
with zipfile.ZipFile(temp_zip, "r") as zip_ref:
|
| 2043 |
+
zip_ref.extractall(_sync_target_root())
|
| 2044 |
+
if restart:
|
| 2045 |
+
_restart_process()
|
| 2046 |
+
return {"status": "synced", "target_root": _sync_target_root(), "restart_scheduled": restart}
|
| 2047 |
+
except Exception as exc:
|
| 2048 |
+
logger.exception("Code sync failed")
|
| 2049 |
+
return {"status": "error", "detail": str(exc)}
|
| 2050 |
+
|
| 2051 |
+
@app.post("/system/archive/upload")
|
| 2052 |
+
async def system_archive_upload(file: UploadFile = File(...), target_path: str = "", restart: bool = False):
|
| 2053 |
+
temp_zip = os.path.join(tempfile.gettempdir(), "kapo_archive_upload.zip")
|
| 2054 |
+
try:
|
| 2055 |
+
extract_root = _resolve_sync_path(target_path)
|
| 2056 |
+
extract_root.mkdir(parents=True, exist_ok=True)
|
| 2057 |
+
with open(temp_zip, "wb") as buffer:
|
| 2058 |
+
shutil.copyfileobj(file.file, buffer)
|
| 2059 |
+
with zipfile.ZipFile(temp_zip, "r") as zip_ref:
|
| 2060 |
+
zip_ref.extractall(extract_root)
|
| 2061 |
+
if restart:
|
| 2062 |
+
_restart_process()
|
| 2063 |
+
return {
|
| 2064 |
+
"status": "extracted",
|
| 2065 |
+
"target_root": str(extract_root),
|
| 2066 |
+
"restart_scheduled": restart,
|
| 2067 |
+
}
|
| 2068 |
+
except Exception as exc:
|
| 2069 |
+
logger.exception("Archive upload failed")
|
| 2070 |
+
return {"status": "error", "detail": str(exc)}
|
| 2071 |
+
else:
|
| 2072 |
+
@app.post("/system/sync")
|
| 2073 |
+
async def sync_codebase_unavailable():
|
| 2074 |
+
return {"status": "error", "detail": "python-multipart is required for /system/sync"}
|
| 2075 |
+
|
| 2076 |
+
@app.post("/system/archive/upload")
|
| 2077 |
+
async def system_archive_upload_unavailable():
|
| 2078 |
+
return {"status": "error", "detail": "python-multipart is required for /system/archive/upload"}
|
| 2079 |
+
|
| 2080 |
+
|
| 2081 |
+
@app.post("/system/restart")
|
| 2082 |
+
async def system_restart(req: RestartRequest | None = None):
|
| 2083 |
+
delay_sec = req.delay_sec if req else 1.0
|
| 2084 |
+
if _is_hf_space_runtime():
|
| 2085 |
+
return {
|
| 2086 |
+
"status": "skipped",
|
| 2087 |
+
"reason": "restart_disabled_on_hf_space",
|
| 2088 |
+
"delay_sec": delay_sec,
|
| 2089 |
+
"target_root": _sync_target_root(),
|
| 2090 |
+
}
|
| 2091 |
+
_restart_process(delay_sec=delay_sec)
|
| 2092 |
+
return {
|
| 2093 |
+
"status": "restarting",
|
| 2094 |
+
"delay_sec": delay_sec,
|
| 2095 |
+
"target_root": _sync_target_root(),
|
| 2096 |
+
}
|
| 2097 |
+
|
| 2098 |
+
|
| 2099 |
+
def _health_payload(check_executor: bool = False, executor_url: str | None = None) -> dict[str, Any]:
|
| 2100 |
+
cfg = load_config()
|
| 2101 |
+
base_exec_url = (executor_url or os.getenv("EXECUTOR_URL", "")).strip().rstrip("/")
|
| 2102 |
+
exec_ok = False
|
| 2103 |
+
exec_checked = False
|
| 2104 |
+
exec_error = None
|
| 2105 |
+
health_timeout = int(cfg.get("REQUEST_TIMEOUT_SEC", 20) or 20)
|
| 2106 |
+
health_retries = max(1, int(cfg.get("REQUEST_RETRIES", 2) or 2))
|
| 2107 |
+
|
| 2108 |
+
if base_exec_url and check_executor:
|
| 2109 |
+
exec_checked = True
|
| 2110 |
+
for _ in range(health_retries):
|
| 2111 |
+
try:
|
| 2112 |
+
response = requests.get(
|
| 2113 |
+
f"{base_exec_url}/health",
|
| 2114 |
+
headers=get_executor_headers(cfg),
|
| 2115 |
+
timeout=health_timeout,
|
| 2116 |
+
)
|
| 2117 |
+
exec_ok = response.status_code == 200
|
| 2118 |
+
if exec_ok:
|
| 2119 |
+
exec_error = None
|
| 2120 |
+
break
|
| 2121 |
+
exec_error = response.text
|
| 2122 |
+
except Exception as exc:
|
| 2123 |
+
exec_error = str(exc)
|
| 2124 |
+
|
| 2125 |
+
faiss_path = cfg.get("FAISS_INDEX_PATH")
|
| 2126 |
+
return {
|
| 2127 |
+
"status": "ok",
|
| 2128 |
+
"model_loaded": MODEL is not None,
|
| 2129 |
+
"model_error": MODEL_ERROR,
|
| 2130 |
+
"embedding_loaded": EMBED_MODEL is not None,
|
| 2131 |
+
"faiss_ok": bool(faiss_path and os.path.exists(faiss_path)),
|
| 2132 |
+
"executor_checked": exec_checked,
|
| 2133 |
+
"executor_ok": exec_ok,
|
| 2134 |
+
"executor_error": exec_error,
|
| 2135 |
+
"remote_brain_only": str(os.getenv("REMOTE_BRAIN_ONLY", "")).strip().lower() in {"1", "true", "yes", "on"},
|
| 2136 |
+
"runtime_root": os.getenv("KAPO_RUNTIME_ROOT", ""),
|
| 2137 |
+
"sync_root": _sync_target_root(),
|
| 2138 |
+
"timestamp": time.time(),
|
| 2139 |
+
}
|
| 2140 |
+
|
| 2141 |
+
|
| 2142 |
+
def _decode_b64_text(value: str) -> str:
|
| 2143 |
+
raw = str(value or "").strip()
|
| 2144 |
+
if not raw:
|
| 2145 |
+
return ""
|
| 2146 |
+
padding = "=" * (-len(raw) % 4)
|
| 2147 |
+
try:
|
| 2148 |
+
return base64.urlsafe_b64decode((raw + padding).encode("utf-8")).decode("utf-8").strip()
|
| 2149 |
+
except Exception:
|
| 2150 |
+
return ""
|
| 2151 |
+
|
| 2152 |
+
|
| 2153 |
+
def _runtime_modernization_snapshot() -> dict[str, Any]:
|
| 2154 |
+
bootstrap = DRIVE_STATE.ensure_bootstrap_loaded(force=False) or {}
|
| 2155 |
+
patch_manifest_url = str(
|
| 2156 |
+
os.getenv("KAPO_PATCH_MANIFEST_URL", "")
|
| 2157 |
+
or bootstrap.get("patch_manifest_url")
|
| 2158 |
+
or ""
|
| 2159 |
+
).strip()
|
| 2160 |
+
remote_env_url = _decode_b64_text(os.getenv("KAPO_REMOTE_ENV_URL_B64", "")) or str(os.getenv("KAPO_REMOTE_ENV_URL", "") or "").strip()
|
| 2161 |
+
public_url = str(os.getenv("BRAIN_PUBLIC_URL", "") or LAST_BRAIN_URL_REPORT.get("url") or _load_saved_public_url() or "").strip()
|
| 2162 |
+
applied_version = _load_applied_runtime_version()
|
| 2163 |
+
control_plane_url = str(os.getenv("KAPO_CONTROL_PLANE_URL", "") or "").strip()
|
| 2164 |
+
queue_name = str(os.getenv("KAPO_CLOUDFLARE_QUEUE_NAME", "") or "").strip()
|
| 2165 |
+
return {
|
| 2166 |
+
"shared_state_backend": _shared_state_backend(),
|
| 2167 |
+
"firebase_enabled": str(os.getenv("FIREBASE_ENABLED", "0")).strip().lower() in {"1", "true", "yes", "on"},
|
| 2168 |
+
"drive": {
|
| 2169 |
+
"folder_id": str(os.getenv("GOOGLE_DRIVE_SHARED_STATE_FOLDER_ID", "") or os.getenv("GOOGLE_DRIVE_STORAGE_FOLDER_ID", "") or "").strip(),
|
| 2170 |
+
"prefix": str(os.getenv("GOOGLE_DRIVE_SHARED_STATE_PREFIX", "") or os.getenv("GOOGLE_DRIVE_STORAGE_PREFIX", "") or "").strip(),
|
| 2171 |
+
"bootstrap_loaded": bool(bootstrap),
|
| 2172 |
+
},
|
| 2173 |
+
"bootstrap": {
|
| 2174 |
+
"configured": bool(str(os.getenv("KAPO_BOOTSTRAP_URL", "") or os.getenv("GOOGLE_DRIVE_BOOTSTRAP_URL", "") or "").strip()),
|
| 2175 |
+
"url": str(os.getenv("KAPO_BOOTSTRAP_URL", "") or os.getenv("GOOGLE_DRIVE_BOOTSTRAP_URL", "") or "").strip(),
|
| 2176 |
+
"loaded": bool(bootstrap),
|
| 2177 |
+
"version": str(bootstrap.get("version") or "").strip(),
|
| 2178 |
+
"updated_at": bootstrap.get("updated_at"),
|
| 2179 |
+
},
|
| 2180 |
+
"patch": {
|
| 2181 |
+
"configured": bool(patch_manifest_url),
|
| 2182 |
+
"manifest_url": patch_manifest_url,
|
| 2183 |
+
"startup_self_update_enabled": _startup_self_update_enabled(),
|
| 2184 |
+
"applied_version": applied_version,
|
| 2185 |
+
},
|
| 2186 |
+
"remote_env": {
|
| 2187 |
+
"configured": bool(remote_env_url),
|
| 2188 |
+
"loaded": str(os.getenv("KAPO_REMOTE_ENV_LOADED", "0")).strip().lower() in {"1", "true", "yes", "on"},
|
| 2189 |
+
"url": remote_env_url,
|
| 2190 |
+
},
|
| 2191 |
+
"control_plane": {
|
| 2192 |
+
"configured": bool(control_plane_url),
|
| 2193 |
+
"url": control_plane_url,
|
| 2194 |
+
"queue_name": queue_name,
|
| 2195 |
+
"queue_configured": bool(queue_name),
|
| 2196 |
+
},
|
| 2197 |
+
"transport": {
|
| 2198 |
+
"provider": str(os.getenv("BRAIN_TUNNEL_PROVIDER", "ngrok") or "ngrok").strip().lower(),
|
| 2199 |
+
"auto_ngrok": _ngrok_bootstrap_enabled(),
|
| 2200 |
+
"reuse_public_url_on_restart": _reuse_public_url_on_restart(),
|
| 2201 |
+
"public_url": public_url,
|
| 2202 |
+
"executor_url": str(os.getenv("EXECUTOR_URL", "") or "").strip(),
|
| 2203 |
+
},
|
| 2204 |
+
"runtime": {
|
| 2205 |
+
"root": str(_sync_target_root()),
|
| 2206 |
+
"model_profile_id": str(os.getenv("MODEL_PROFILE_ID", "") or "").strip(),
|
| 2207 |
+
"model_loaded": MODEL is not None,
|
| 2208 |
+
"embed_loaded": EMBED_MODEL is not None,
|
| 2209 |
+
},
|
| 2210 |
+
}
|
| 2211 |
+
|
| 2212 |
+
|
| 2213 |
+
def _persist_runtime_state_snapshot(reason: str = "periodic") -> dict[str, Any]:
|
| 2214 |
+
payload = _health_payload(check_executor=False)
|
| 2215 |
+
public_url = os.getenv("BRAIN_PUBLIC_URL") or _load_saved_public_url() or ""
|
| 2216 |
+
state_id = _brain_state_id()
|
| 2217 |
+
FIREBASE.set_document(
|
| 2218 |
+
"brains",
|
| 2219 |
+
public_url or state_id,
|
| 2220 |
+
{
|
| 2221 |
+
"url": public_url,
|
| 2222 |
+
"health": payload,
|
| 2223 |
+
"status": "healthy" if payload["model_loaded"] else "degraded",
|
| 2224 |
+
"model_repo": os.getenv("MODEL_REPO", DEFAULT_MODEL_REPO),
|
| 2225 |
+
"model_file": os.getenv("MODEL_FILE", DEFAULT_MODEL_FILE),
|
| 2226 |
+
"model_profile_id": os.getenv("MODEL_PROFILE_ID", DEFAULT_MODEL_PROFILE_ID),
|
| 2227 |
+
"roles": os.getenv("BRAIN_ROLES", "supervisor,chat,coding,planner,arabic,fallback"),
|
| 2228 |
+
"languages": os.getenv("BRAIN_LANGUAGES", "ar,en"),
|
| 2229 |
+
"updated_at": time.time(),
|
| 2230 |
+
"source": reason,
|
| 2231 |
+
},
|
| 2232 |
+
min_interval_sec=5.0,
|
| 2233 |
+
)
|
| 2234 |
+
FIREBASE.set_document(
|
| 2235 |
+
"brain_runtime",
|
| 2236 |
+
state_id,
|
| 2237 |
+
{
|
| 2238 |
+
"brain_url": public_url,
|
| 2239 |
+
"reason": reason,
|
| 2240 |
+
"health": payload,
|
| 2241 |
+
"recent_logs": list(RUNTIME_LOG_BUFFER)[-25:],
|
| 2242 |
+
"updated_at": time.time(),
|
| 2243 |
+
},
|
| 2244 |
+
min_interval_sec=5.0,
|
| 2245 |
+
)
|
| 2246 |
+
return payload
|
| 2247 |
+
|
| 2248 |
+
|
| 2249 |
+
def _runtime_state_pulse() -> None:
|
| 2250 |
+
interval = max(20.0, float(os.getenv("BRAIN_STATE_SYNC_INTERVAL_SEC", "60") or 60))
|
| 2251 |
+
while True:
|
| 2252 |
+
try:
|
| 2253 |
+
_persist_runtime_state_snapshot(reason="background_pulse")
|
| 2254 |
+
except Exception:
|
| 2255 |
+
logger.warning("Background runtime state sync failed", exc_info=True)
|
| 2256 |
+
time.sleep(interval)
|
| 2257 |
+
|
| 2258 |
+
|
| 2259 |
+
@app.get("/health")
|
| 2260 |
+
async def health(executor_url: str | None = None, check_executor: bool = False):
|
| 2261 |
+
payload = _health_payload(check_executor=check_executor, executor_url=executor_url)
|
| 2262 |
+
_persist_runtime_state_snapshot(reason="health_endpoint")
|
| 2263 |
+
return payload
|
| 2264 |
+
|
| 2265 |
+
|
| 2266 |
+
# KAPO HF SPACE TRANSFORMERS PATCH
|
| 2267 |
+
def _kapo_hf_transformers_enabled() -> bool:
|
| 2268 |
+
return str(os.getenv('KAPO_HF_TRANSFORMERS_RUNTIME', '0')).strip().lower() in {'1', 'true', 'yes', 'on'}
|
| 2269 |
+
|
| 2270 |
+
def ensure_model_loaded(repo_id: str, filename: str, hf_token: str | None = None) -> None:
|
| 2271 |
+
global MODEL, MODEL_ERROR, MODEL_META
|
| 2272 |
+
repo_id = (repo_id or '').strip()
|
| 2273 |
+
filename = (filename or '').strip()
|
| 2274 |
+
if not repo_id:
|
| 2275 |
+
MODEL = None
|
| 2276 |
+
MODEL_ERROR = 'model repo missing'
|
| 2277 |
+
return
|
| 2278 |
+
if _kapo_hf_transformers_enabled():
|
| 2279 |
+
try:
|
| 2280 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 2281 |
+
tokenizer = AutoTokenizer.from_pretrained(repo_id, token=hf_token, trust_remote_code=True)
|
| 2282 |
+
model = AutoModelForCausalLM.from_pretrained(repo_id, token=hf_token, trust_remote_code=True, device_map='cpu')
|
| 2283 |
+
if hasattr(model, 'eval'):
|
| 2284 |
+
model.eval()
|
| 2285 |
+
MODEL = {'kind': 'transformers', 'model': model, 'tokenizer': tokenizer}
|
| 2286 |
+
MODEL_ERROR = None
|
| 2287 |
+
MODEL_META = {'repo_id': repo_id, 'filename': filename, 'path': None}
|
| 2288 |
+
logger.info('Loaded transformers model %s', repo_id)
|
| 2289 |
+
return
|
| 2290 |
+
except Exception as exc:
|
| 2291 |
+
MODEL = None
|
| 2292 |
+
MODEL_ERROR = f'transformers model load failed: {exc}'
|
| 2293 |
+
logger.exception('Transformers model load failed')
|
| 2294 |
+
return
|
| 2295 |
+
if not filename:
|
| 2296 |
+
MODEL = None
|
| 2297 |
+
MODEL_ERROR = 'model file missing'
|
| 2298 |
+
return
|
| 2299 |
+
try:
|
| 2300 |
+
model_path = _download_model(repo_id, filename, hf_token=hf_token)
|
| 2301 |
+
except Exception as exc:
|
| 2302 |
+
MODEL = None
|
| 2303 |
+
MODEL_ERROR = f'model download failed: {exc}'
|
| 2304 |
+
logger.exception('Model download failed')
|
| 2305 |
+
return
|
| 2306 |
+
try:
|
| 2307 |
+
from llama_cpp import Llama
|
| 2308 |
+
MODEL = Llama(model_path=model_path, n_ctx=4096)
|
| 2309 |
+
MODEL_ERROR = None
|
| 2310 |
+
MODEL_META = {'repo_id': repo_id, 'filename': filename, 'path': model_path}
|
| 2311 |
+
logger.info('Loaded model %s/%s', repo_id, filename)
|
| 2312 |
+
except Exception as exc:
|
| 2313 |
+
MODEL = None
|
| 2314 |
+
MODEL_ERROR = f'model load failed: {exc}'
|
| 2315 |
+
logger.exception('Model load failed')
|
| 2316 |
+
|
| 2317 |
+
def _generate_response(user_input: str, history: list[dict[str, str]], context_block: str) -> str:
|
| 2318 |
+
language = _detect_language(user_input)
|
| 2319 |
+
exact_reply = _extract_exact_reply_instruction_safe(user_input)
|
| 2320 |
+
if exact_reply:
|
| 2321 |
+
return exact_reply
|
| 2322 |
+
fast_reply = _project_specific_fast_reply(user_input)
|
| 2323 |
+
if fast_reply:
|
| 2324 |
+
return fast_reply
|
| 2325 |
+
if MODEL is None:
|
| 2326 |
+
try:
|
| 2327 |
+
_load_default_model()
|
| 2328 |
+
except Exception:
|
| 2329 |
+
logger.exception('Lazy model load failed')
|
| 2330 |
+
if MODEL is None:
|
| 2331 |
+
if language == 'ar':
|
| 2332 |
+
return 'الخدمة تعمل لكن توليد الرد الحر غير متاح الآن لأن النموذج غير محمل.'
|
| 2333 |
+
return 'The Brain is online, but natural chat generation is unavailable because the model is not loaded.'
|
| 2334 |
+
prompt = _build_chat_prompt(user_input, history, context_block)
|
| 2335 |
+
try:
|
| 2336 |
+
max_tokens = 80 if language == 'ar' else 96
|
| 2337 |
+
if isinstance(MODEL, dict) and MODEL.get('kind') == 'transformers':
|
| 2338 |
+
tokenizer = MODEL['tokenizer']
|
| 2339 |
+
model = MODEL['model']
|
| 2340 |
+
inputs = tokenizer(prompt, return_tensors='pt', truncation=True, max_length=2048)
|
| 2341 |
+
if hasattr(model, 'device'):
|
| 2342 |
+
inputs = {k: v.to(model.device) if hasattr(v, 'to') else v for k, v in inputs.items()}
|
| 2343 |
+
output_ids = model.generate(**inputs, max_new_tokens=max_tokens, do_sample=False, pad_token_id=tokenizer.eos_token_id)
|
| 2344 |
+
generated = output_ids[0][inputs['input_ids'].shape[1]:]
|
| 2345 |
+
text = tokenizer.decode(generated, skip_special_tokens=True).strip()
|
| 2346 |
+
else:
|
| 2347 |
+
output = MODEL(prompt, max_tokens=max_tokens, temperature=0.1, top_p=0.85, stop=['\nUser:', '\nUSER:', '\n###', '<|EOT|>'])
|
| 2348 |
+
text = output['choices'][0]['text'].strip()
|
| 2349 |
+
if _response_looks_bad(text, language):
|
| 2350 |
+
return _fallback_response(user_input)
|
| 2351 |
+
return text or ('تم استلام رسالتك.' if language == 'ar' else 'I received your message.')
|
| 2352 |
+
except Exception:
|
| 2353 |
+
logger.exception('Model generation failed')
|
| 2354 |
+
if language == 'ar':
|
| 2355 |
+
return 'فهمت طلبك، لكن حدث خطأ أثناء توليد الرد النصي.'
|
| 2356 |
+
return 'I understood your request, but text generation failed.'
|
brain_server/api/routes_analyze.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes for analyze/store."""
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
from fastapi import APIRouter, HTTPException
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from api.deps import get_logger
|
| 10 |
+
import requests
|
| 11 |
+
from memory.episodic_db import EpisodicDB
|
| 12 |
+
from api.deps import is_remote_brain_only, load_config
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
logger = get_logger("kapo.brain.analyze")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnalyzeRequest(BaseModel):
|
| 19 |
+
request_id: str
|
| 20 |
+
payload: Dict[str, Any]
|
| 21 |
+
auth_token: str | None = None
|
| 22 |
+
timestamp: float | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/analyze")
|
| 26 |
+
async def analyze(req: AnalyzeRequest):
|
| 27 |
+
"""????? ??????? ?? ??????? ??????? (Episodic)."""
|
| 28 |
+
try:
|
| 29 |
+
if not is_remote_brain_only():
|
| 30 |
+
db = EpisodicDB()
|
| 31 |
+
db.insert_experience(
|
| 32 |
+
task=req.payload.get("task", "unknown"),
|
| 33 |
+
plan=req.payload.get("plan", {}),
|
| 34 |
+
tools_used=req.payload.get("tools_used", {}),
|
| 35 |
+
result=req.payload.get("result", {}),
|
| 36 |
+
success=1 if req.payload.get("success") else 0,
|
| 37 |
+
)
|
| 38 |
+
else:
|
| 39 |
+
try:
|
| 40 |
+
hub = load_config().get("LOCAL_HUB_URL") or os.getenv("LOCAL_HUB_URL")
|
| 41 |
+
if hub:
|
| 42 |
+
requests.post(f"{hub}/memory/store", json={"request_id": req.request_id, "payload": req.payload}, timeout=10)
|
| 43 |
+
except Exception:
|
| 44 |
+
logger.warning("Local hub store failed")
|
| 45 |
+
return {"status": "stored", "timestamp": time.time()}
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
logger.exception("Analyze failed")
|
| 48 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
brain_server/api/routes_execute.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes for remote execution via the local executive agent."""
|
| 2 |
+
import time
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
from fastapi import APIRouter, HTTPException
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from agents.auto_heal_agent import AutoHealAgent
|
| 10 |
+
from agents.memory_agent import MemoryAgent
|
| 11 |
+
from agents.supervisor_agent import SupervisorAgent
|
| 12 |
+
from agents.tool_selector_agent import ToolSelectorAgent
|
| 13 |
+
from api.deps import get_executor_headers, get_executor_url, get_logger, is_remote_brain_only, load_config
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
logger = get_logger("kapo.brain.execute")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ExecuteRequest(BaseModel):
|
| 20 |
+
request_id: str
|
| 21 |
+
plan: dict[str, Any] | list[dict[str, Any]]
|
| 22 |
+
auth_token: str | None = None
|
| 23 |
+
timestamp: float | None = None
|
| 24 |
+
executor_url: str | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _normalize_executor_url(url: str | None) -> str:
|
| 28 |
+
if not url:
|
| 29 |
+
return ""
|
| 30 |
+
value = url.strip()
|
| 31 |
+
if not value:
|
| 32 |
+
return ""
|
| 33 |
+
if "://" not in value:
|
| 34 |
+
value = f"http://{value}"
|
| 35 |
+
return value.rstrip("/")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_steps(plan: dict[str, Any] | list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 39 |
+
if isinstance(plan, list):
|
| 40 |
+
return [_normalize_step(step, index) for index, step in enumerate(plan, start=1)]
|
| 41 |
+
if isinstance(plan, dict):
|
| 42 |
+
steps = plan.get("steps")
|
| 43 |
+
if isinstance(steps, list):
|
| 44 |
+
return [_normalize_step(step, index) for index, step in enumerate(steps, start=1)]
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _normalize_step(step: dict[str, Any], index: int) -> dict[str, Any]:
|
| 49 |
+
if not isinstance(step, dict):
|
| 50 |
+
return {"id": f"step-{index}", "action": "execute", "input": str(step), "tool_hint": "python"}
|
| 51 |
+
normalized = dict(step)
|
| 52 |
+
if not str(normalized.get("id", "")).strip():
|
| 53 |
+
normalized["id"] = f"step-{index}"
|
| 54 |
+
if not str(normalized.get("input", "")).strip():
|
| 55 |
+
for key in ("description", "title", "summary", "task", "prompt"):
|
| 56 |
+
value = str(normalized.get(key, "")).strip()
|
| 57 |
+
if value:
|
| 58 |
+
normalized["input"] = value
|
| 59 |
+
break
|
| 60 |
+
if not str(normalized.get("action", "")).strip():
|
| 61 |
+
normalized["action"] = "execute"
|
| 62 |
+
if not str(normalized.get("tool_hint", "")).strip():
|
| 63 |
+
normalized["tool_hint"] = "python"
|
| 64 |
+
return normalized
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post("/execute")
|
| 68 |
+
async def execute(req: ExecuteRequest):
|
| 69 |
+
cfg = load_config()
|
| 70 |
+
tool_selector = ToolSelectorAgent()
|
| 71 |
+
supervisor = SupervisorAgent()
|
| 72 |
+
auto_heal = AutoHealAgent()
|
| 73 |
+
memory = MemoryAgent()
|
| 74 |
+
|
| 75 |
+
base_exec_url = _normalize_executor_url(req.executor_url) or get_executor_url(cfg)
|
| 76 |
+
exec_url = f"{base_exec_url}/execute"
|
| 77 |
+
exec_headers = get_executor_headers(cfg)
|
| 78 |
+
timeout = int(cfg.get("REQUEST_TIMEOUT_SEC", 20) or 20)
|
| 79 |
+
retries = int(cfg.get("REQUEST_RETRIES", 2) or 2)
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
results: list[dict[str, Any]] = []
|
| 83 |
+
for step in _extract_steps(req.plan):
|
| 84 |
+
tool = tool_selector.select_tool(step)
|
| 85 |
+
payload = {
|
| 86 |
+
"request_id": req.request_id,
|
| 87 |
+
"step_id": step.get("id"),
|
| 88 |
+
"command": tool.get("command"),
|
| 89 |
+
"files": tool.get("files", {}),
|
| 90 |
+
"env": tool.get("env", {}),
|
| 91 |
+
"timestamp": time.time(),
|
| 92 |
+
}
|
| 93 |
+
attempt = 0
|
| 94 |
+
last_err = None
|
| 95 |
+
while attempt <= retries:
|
| 96 |
+
try:
|
| 97 |
+
response = requests.post(exec_url, json=payload, headers=exec_headers, timeout=timeout)
|
| 98 |
+
if response.status_code == 200:
|
| 99 |
+
item = response.json()
|
| 100 |
+
item["selected_tool"] = tool.get("tool")
|
| 101 |
+
results.append(item)
|
| 102 |
+
last_err = None
|
| 103 |
+
break
|
| 104 |
+
last_err = response.text
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
last_err = str(exc)
|
| 107 |
+
attempt += 1
|
| 108 |
+
|
| 109 |
+
if last_err:
|
| 110 |
+
results.append({"error": last_err, "step": step, "auto_heal": auto_heal.suggest(last_err, step)})
|
| 111 |
+
|
| 112 |
+
report = supervisor.review(results)
|
| 113 |
+
snapshot = {"execution": results, "report": report}
|
| 114 |
+
if not is_remote_brain_only():
|
| 115 |
+
memory.write_short_term(req.request_id, snapshot)
|
| 116 |
+
|
| 117 |
+
return {"status": "ok", "results": results, "report": report, "timestamp": time.time()}
|
| 118 |
+
except Exception as exc:
|
| 119 |
+
logger.exception("Execution failed")
|
| 120 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
brain_server/api/routes_plan.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes for planning."""
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any, List
|
| 6 |
+
from fastapi import APIRouter, HTTPException
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from api.deps import get_executor_headers, get_executor_url, get_logger, load_config
|
| 10 |
+
from agents.planner_agent import PlannerAgent
|
| 11 |
+
from agents.reasoning_agent import ReasoningAgent
|
| 12 |
+
from agents.memory_agent import MemoryAgent
|
| 13 |
+
from api.deps import is_remote_brain_only
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
logger = get_logger("kapo.brain.plan")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PlanRequest(BaseModel):
|
| 21 |
+
request_id: str
|
| 22 |
+
user_input: str
|
| 23 |
+
context: Dict[str, Any] = {}
|
| 24 |
+
auth_token: str | None = None
|
| 25 |
+
timestamp: float | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class PlanResponse(BaseModel):
|
| 29 |
+
plan_id: str
|
| 30 |
+
plan: List[Dict[str, Any]]
|
| 31 |
+
metadata: Dict[str, Any]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/plan", response_model=PlanResponse)
|
| 35 |
+
async def plan(req: PlanRequest):
|
| 36 |
+
"""????? ??? ????? ???????."""
|
| 37 |
+
try:
|
| 38 |
+
logger.info("Plan requested", extra={"request_id": req.request_id, "component": "plan"})
|
| 39 |
+
planner = PlannerAgent()
|
| 40 |
+
reasoning = ReasoningAgent()
|
| 41 |
+
memory = MemoryAgent()
|
| 42 |
+
plan_steps = planner.run(req.user_input, req.context)
|
| 43 |
+
rationale = reasoning.run(req.user_input, plan_steps)
|
| 44 |
+
plan_id = str(uuid.uuid4())
|
| 45 |
+
if not is_remote_brain_only():
|
| 46 |
+
memory.write_short_term(req.request_id, {"plan": plan_steps, "rationale": rationale})
|
| 47 |
+
else:
|
| 48 |
+
try:
|
| 49 |
+
cfg = load_config()
|
| 50 |
+
hub = get_executor_url(cfg)
|
| 51 |
+
if hub:
|
| 52 |
+
requests.post(
|
| 53 |
+
f"{hub}/memory/store",
|
| 54 |
+
json={"request_id": req.request_id, "payload": {"plan": plan_steps, "rationale": rationale}},
|
| 55 |
+
headers=get_executor_headers(cfg),
|
| 56 |
+
timeout=(3, 4),
|
| 57 |
+
)
|
| 58 |
+
except Exception:
|
| 59 |
+
logger.warning("Local hub store failed")
|
| 60 |
+
return PlanResponse(
|
| 61 |
+
plan_id=plan_id,
|
| 62 |
+
plan=plan_steps,
|
| 63 |
+
metadata={"rationale": rationale, "timestamp": time.time()},
|
| 64 |
+
)
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.exception("Plan failed")
|
| 67 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
brain_server/config/config.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ??????? Brain Server
|
| 2 |
+
MODEL_PATH: "${MODEL_PATH}"
|
| 3 |
+
BRAIN_HOST: "${BRAIN_HOST}"
|
| 4 |
+
BRAIN_PORT: "${BRAIN_PORT}"
|
| 5 |
+
EXECUTOR_HOST: "${EXECUTOR_HOST}"
|
| 6 |
+
EXECUTOR_PORT: "${EXECUTOR_PORT}"
|
| 7 |
+
DB_PATH: "${DB_PATH}"
|
| 8 |
+
TOOLS_DB_PATH: "${TOOLS_DB_PATH}"
|
| 9 |
+
FAISS_INDEX_PATH: "${FAISS_INDEX_PATH}"
|
| 10 |
+
EMBED_MODEL: "${EMBED_MODEL}"
|
| 11 |
+
LOG_LEVEL: "${LOG_LEVEL}"
|
| 12 |
+
WHITELIST_COMMANDS: "${WHITELIST_COMMANDS}"
|
| 13 |
+
BLACKLIST_COMMANDS: "${BLACKLIST_COMMANDS}"
|
| 14 |
+
REQUEST_TIMEOUT_SEC: 20
|
| 15 |
+
REQUEST_RETRIES: 2
|
| 16 |
+
REMOTE_BRAIN_ONLY: "${REMOTE_BRAIN_ONLY}"
|
| 17 |
+
BRAIN_REMOTE_KNOWLEDGE_ENABLED: "${BRAIN_REMOTE_KNOWLEDGE_ENABLED}"
|
| 18 |
+
BRAIN_REMOTE_WEB_SEARCH_ENABLED: "${BRAIN_REMOTE_WEB_SEARCH_ENABLED}"
|
| 19 |
+
BRAIN_REMOTE_TRACE_STORE_ENABLED: "${BRAIN_REMOTE_TRACE_STORE_ENABLED}"
|
| 20 |
+
BRAIN_REMOTE_AUTO_INGEST_ENABLED: "${BRAIN_REMOTE_AUTO_INGEST_ENABLED}"
|
| 21 |
+
BRAIN_LOCAL_RAG_FALLBACK_ENABLED: "${BRAIN_LOCAL_RAG_FALLBACK_ENABLED}"
|
| 22 |
+
EXECUTOR_CONNECT_TIMEOUT_SEC: "${EXECUTOR_CONNECT_TIMEOUT_SEC}"
|
| 23 |
+
BRAIN_REMOTE_KNOWLEDGE_TIMEOUT_SEC: "${BRAIN_REMOTE_KNOWLEDGE_TIMEOUT_SEC}"
|
| 24 |
+
BRAIN_REMOTE_WEB_SEARCH_TIMEOUT_SEC: "${BRAIN_REMOTE_WEB_SEARCH_TIMEOUT_SEC}"
|
| 25 |
+
BRAIN_REMOTE_TRACE_STORE_TIMEOUT_SEC: "${BRAIN_REMOTE_TRACE_STORE_TIMEOUT_SEC}"
|
| 26 |
+
BRAIN_REMOTE_AUTO_INGEST_TIMEOUT_SEC: "${BRAIN_REMOTE_AUTO_INGEST_TIMEOUT_SEC}"
|
brain_server/config/logging.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 1
|
| 2 |
+
formatters:
|
| 3 |
+
json:
|
| 4 |
+
class: pythonjsonlogger.jsonlogger.JsonFormatter
|
| 5 |
+
format: "%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s %(component)s"
|
| 6 |
+
console:
|
| 7 |
+
format: "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
| 8 |
+
handlers:
|
| 9 |
+
console:
|
| 10 |
+
class: logging.StreamHandler
|
| 11 |
+
formatter: console
|
| 12 |
+
json:
|
| 13 |
+
class: logging.StreamHandler
|
| 14 |
+
formatter: json
|
| 15 |
+
root:
|
| 16 |
+
level: INFO
|
| 17 |
+
handlers: [console]
|
| 18 |
+
loggers:
|
| 19 |
+
kapo:
|
| 20 |
+
level: INFO
|
| 21 |
+
handlers: [json]
|
| 22 |
+
propagate: false
|
brain_server/kaggle_bootstrap.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bootstrap a Kaggle dataset copy of brain_server into /kaggle/working and print run guidance."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def main() -> None:
|
| 10 |
+
source_root = Path(__file__).resolve().parent
|
| 11 |
+
runtime_root = Path(os.getenv("KAPO_RUNTIME_ROOT") or "/kaggle/working/brain_server").resolve()
|
| 12 |
+
|
| 13 |
+
if source_root != runtime_root:
|
| 14 |
+
if runtime_root.exists():
|
| 15 |
+
shutil.rmtree(runtime_root, ignore_errors=True)
|
| 16 |
+
shutil.copytree(source_root, runtime_root)
|
| 17 |
+
|
| 18 |
+
print(f"Bootstrapped brain_server to: {runtime_root}")
|
| 19 |
+
print("Next steps:")
|
| 20 |
+
print(f" %cd {runtime_root}")
|
| 21 |
+
print(" !pip install --default-timeout=1000 --no-cache-dir -r requirements.txt")
|
| 22 |
+
print(" !python -m uvicorn api.main:app --host 0.0.0.0 --port 7860")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
main()
|
brain_server/langgraph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""LangGraph wrapper package."""
|
brain_server/langgraph/agent_prompts.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompts ??? ???? (Arabic + short English example)."""
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger("kapo.prompts")
|
| 5 |
+
|
| 6 |
+
PLANNER_PROMPT = """
|
| 7 |
+
[Planner Agent]
|
| 8 |
+
??? ????? ?? ????? ???? ???????? ??? ??? ??????? JSON.
|
| 9 |
+
|
| 10 |
+
??????? (JSON Schema):
|
| 11 |
+
{
|
| 12 |
+
"request_id": "str",
|
| 13 |
+
"user_input": "str",
|
| 14 |
+
"context": {"...": "..."}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
??????? ??????? (JSON Schema):
|
| 18 |
+
{
|
| 19 |
+
"steps": [
|
| 20 |
+
{"id": "str", "action": "str", "tool_hint": "str", "files": {}, "env": {}}
|
| 21 |
+
],
|
| 22 |
+
"assumptions": ["..."]
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
???? ????:
|
| 26 |
+
Input: {"request_id":"r1","user_input":"??? ????","context":{}}
|
| 27 |
+
Output: {"steps":[{"id":"s1","action":"analyze","tool_hint":"python"}],"assumptions":[]}
|
| 28 |
+
|
| 29 |
+
English short example:
|
| 30 |
+
Input: {"request_id":"r1","user_input":"Summarize logs"}
|
| 31 |
+
Output: {"steps":[{"id":"s1","action":"summarize","tool_hint":"python"}]}
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
REASONING_PROMPT = """
|
| 35 |
+
[Reasoning Agent]
|
| 36 |
+
??? ????? ???? ??????? ???????.
|
| 37 |
+
|
| 38 |
+
Input Schema:
|
| 39 |
+
{"user_input":"str","plan":{"steps":[...]}}
|
| 40 |
+
|
| 41 |
+
Output Schema:
|
| 42 |
+
{"rationale":"str","risks":["..."],"notes":"str"}
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
TOOL_SELECTOR_PROMPT = """
|
| 46 |
+
[Tool Selector Agent]
|
| 47 |
+
???? ?????? ?????? ??? ????.
|
| 48 |
+
|
| 49 |
+
Input Schema:
|
| 50 |
+
{"step":{"id":"str","action":"str"},"tools":[{"tool_name":"str"}]}
|
| 51 |
+
|
| 52 |
+
Output Schema:
|
| 53 |
+
{"tool_name":"str","command":"str","reason":"str"}
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
SUPERVISOR_PROMPT = """
|
| 57 |
+
[Supervisor Agent]
|
| 58 |
+
??? ????? ??????? ???? ?????? ?? ????? ?? ??????.
|
| 59 |
+
|
| 60 |
+
Input Schema:
|
| 61 |
+
{"results":[{"exit_code":0,"stdout":""}]}
|
| 62 |
+
|
| 63 |
+
Output Schema:
|
| 64 |
+
{"success":true,"report":"str","next_actions":["..."]}
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
AUTO_HEAL_PROMPT = """
|
| 68 |
+
[Auto-Heal Agent]
|
| 69 |
+
??? ??????? ?????? ??????? ????? ???????.
|
| 70 |
+
|
| 71 |
+
Input Schema:
|
| 72 |
+
{"error_text":"str","context":{"step":{}}}
|
| 73 |
+
|
| 74 |
+
Output Schema:
|
| 75 |
+
{"suggested_fix":"str","reexecute":true}
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
MEMORY_PROMPT = """
|
| 79 |
+
[Memory Agent]
|
| 80 |
+
??? ?? ??? ?????? ?? ??????? ?????? ????????.
|
| 81 |
+
|
| 82 |
+
Input Schema:
|
| 83 |
+
{"event":{...},"policy":"str"}
|
| 84 |
+
|
| 85 |
+
Output Schema:
|
| 86 |
+
{"store_short_term":true,"store_episodic":true,"keys":["..."]}
|
| 87 |
+
"""
|
brain_server/langgraph/graph_definition.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""????? ???? LangGraph ?? ???? ????."""
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
from agents.planner_agent import PlannerAgent
|
| 5 |
+
from agents.reasoning_agent import ReasoningAgent
|
| 6 |
+
from agents.tool_selector_agent import ToolSelectorAgent
|
| 7 |
+
from agents.supervisor_agent import SupervisorAgent
|
| 8 |
+
from agents.auto_heal_agent import AutoHealAgent
|
| 9 |
+
from agents.memory_agent import MemoryAgent
|
| 10 |
+
from api.deps import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger("kapo.langgraph")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SimpleGraph:
|
| 16 |
+
"""???? ???? ?? LangGraph ??? ???? ???????."""
|
| 17 |
+
|
| 18 |
+
def run(self, user_input: str, context: Dict[str, Any]):
|
| 19 |
+
planner = PlannerAgent()
|
| 20 |
+
reasoning = ReasoningAgent()
|
| 21 |
+
tool_selector = ToolSelectorAgent()
|
| 22 |
+
supervisor = SupervisorAgent()
|
| 23 |
+
auto_heal = AutoHealAgent()
|
| 24 |
+
memory = MemoryAgent()
|
| 25 |
+
|
| 26 |
+
plan = planner.run(user_input, context)
|
| 27 |
+
rationale = reasoning.run(user_input, plan)
|
| 28 |
+
memory.write_short_term("last_plan", {"plan": plan, "rationale": rationale})
|
| 29 |
+
return {
|
| 30 |
+
"plan": plan,
|
| 31 |
+
"rationale": rationale,
|
| 32 |
+
"tool_selector": tool_selector,
|
| 33 |
+
"supervisor": supervisor,
|
| 34 |
+
"auto_heal": auto_heal,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_graph():
|
| 39 |
+
"""????? ??????? LangGraph ?? ????? ???? ?????? SimpleGraph."""
|
| 40 |
+
try:
|
| 41 |
+
import langgraph # noqa: F401
|
| 42 |
+
logger.info("Using langgraph")
|
| 43 |
+
return SimpleGraph()
|
| 44 |
+
except Exception:
|
| 45 |
+
logger.warning("LangGraph not available; using SimpleGraph")
|
| 46 |
+
return SimpleGraph()
|
brain_server/memory/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Memory package."""
|
brain_server/memory/episodic_db.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Episodic SQLite storage."""
|
| 2 |
+
import json
|
| 3 |
+
import sqlite3
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Dict, Any, List
|
| 7 |
+
from api.deps import load_config, get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger("kapo.memory.episodic")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EpisodicDB:
|
| 13 |
+
def __init__(self):
|
| 14 |
+
cfg = load_config()
|
| 15 |
+
self.db_path = cfg.get("DB_PATH") or "./episodic.db"
|
| 16 |
+
self._init_db()
|
| 17 |
+
|
| 18 |
+
def _init_db(self):
|
| 19 |
+
conn = sqlite3.connect(self.db_path)
|
| 20 |
+
cur = conn.cursor()
|
| 21 |
+
cur.execute(
|
| 22 |
+
"""
|
| 23 |
+
CREATE TABLE IF NOT EXISTS experiences (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
task TEXT,
|
| 26 |
+
plan TEXT,
|
| 27 |
+
tools_used TEXT,
|
| 28 |
+
result TEXT,
|
| 29 |
+
success INTEGER,
|
| 30 |
+
timestamp TEXT
|
| 31 |
+
)
|
| 32 |
+
"""
|
| 33 |
+
)
|
| 34 |
+
conn.commit()
|
| 35 |
+
conn.close()
|
| 36 |
+
|
| 37 |
+
def insert_experience(self, task: str, plan: Dict[str, Any], tools_used: Dict[str, Any], result: Dict[str, Any], success: int):
|
| 38 |
+
conn = sqlite3.connect(self.db_path)
|
| 39 |
+
cur = conn.cursor()
|
| 40 |
+
cur.execute(
|
| 41 |
+
"""INSERT INTO experiences(task, plan, tools_used, result, success, timestamp)
|
| 42 |
+
VALUES(?,?,?,?,?,?)""",
|
| 43 |
+
(task, json.dumps(plan), json.dumps(tools_used), json.dumps(result), success, time.strftime("%Y-%m-%dT%H:%M:%S")),
|
| 44 |
+
)
|
| 45 |
+
conn.commit()
|
| 46 |
+
conn.close()
|
| 47 |
+
|
| 48 |
+
def list_recent(self, limit: int = 20) -> List[Dict[str, Any]]:
|
| 49 |
+
conn = sqlite3.connect(self.db_path)
|
| 50 |
+
cur = conn.cursor()
|
| 51 |
+
cur.execute("SELECT task, plan, tools_used, result, success, timestamp FROM experiences ORDER BY id DESC LIMIT ?", (limit,))
|
| 52 |
+
rows = cur.fetchall()
|
| 53 |
+
conn.close()
|
| 54 |
+
out = []
|
| 55 |
+
for r in rows:
|
| 56 |
+
out.append({
|
| 57 |
+
"task": r[0],
|
| 58 |
+
"plan": json.loads(r[1]),
|
| 59 |
+
"tools_used": json.loads(r[2]),
|
| 60 |
+
"result": json.loads(r[3]),
|
| 61 |
+
"success": r[4],
|
| 62 |
+
"timestamp": r[5],
|
| 63 |
+
})
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
def search_similar(self, text: str, top_k: int = 3):
|
| 67 |
+
"""??? ?????? ??? embeddings ?????? (?????? ?????)."""
|
| 68 |
+
try:
|
| 69 |
+
from sentence_transformers import SentenceTransformer
|
| 70 |
+
cfg = load_config()
|
| 71 |
+
model = cfg.get("EMBED_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
|
| 72 |
+
embedder = SentenceTransformer(model)
|
| 73 |
+
records = self.list_recent(limit=200)
|
| 74 |
+
if not records:
|
| 75 |
+
return []
|
| 76 |
+
texts = [r.get("task", "") for r in records]
|
| 77 |
+
vectors = embedder.encode(texts, show_progress_bar=False)
|
| 78 |
+
qv = embedder.encode([text])[0]
|
| 79 |
+
# cosine similarity
|
| 80 |
+
def cos(a, b):
|
| 81 |
+
import math
|
| 82 |
+
dot = sum(x*y for x, y in zip(a, b))
|
| 83 |
+
na = math.sqrt(sum(x*x for x in a))
|
| 84 |
+
nb = math.sqrt(sum(x*x for x in b))
|
| 85 |
+
return dot / (na*nb + 1e-9)
|
| 86 |
+
scored = [(cos(qv, v), r) for v, r in zip(vectors, records)]
|
| 87 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 88 |
+
return [r for _, r in scored[:top_k]]
|
| 89 |
+
except Exception:
|
| 90 |
+
logger.exception("Similarity search failed")
|
| 91 |
+
return []
|
brain_server/memory/knowledge_vector.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge vector store using sentence-transformers + FAISS."""
|
| 2 |
+
import os
|
| 3 |
+
import sqlite3
|
| 4 |
+
import logging
|
| 5 |
+
from typing import List
|
| 6 |
+
from api.deps import load_config, get_logger
|
| 7 |
+
|
| 8 |
+
logger = get_logger("kapo.memory.knowledge")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class KnowledgeVectorStore:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
cfg = load_config()
|
| 14 |
+
self.index_path = cfg.get("FAISS_INDEX_PATH") or "./faiss.index"
|
| 15 |
+
self.meta_db = self.index_path + ".meta.db"
|
| 16 |
+
self.embed_model = cfg.get("EMBED_MODEL") or "sentence-transformers/all-MiniLM-L6-v2"
|
| 17 |
+
self._init_meta()
|
| 18 |
+
|
| 19 |
+
def _init_meta(self):
|
| 20 |
+
conn = sqlite3.connect(self.meta_db)
|
| 21 |
+
cur = conn.cursor()
|
| 22 |
+
cur.execute(
|
| 23 |
+
"""
|
| 24 |
+
CREATE TABLE IF NOT EXISTS vectors (
|
| 25 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 26 |
+
source TEXT,
|
| 27 |
+
content TEXT
|
| 28 |
+
)
|
| 29 |
+
"""
|
| 30 |
+
)
|
| 31 |
+
conn.commit()
|
| 32 |
+
conn.close()
|
| 33 |
+
|
| 34 |
+
def _load_embedder(self):
|
| 35 |
+
from sentence_transformers import SentenceTransformer
|
| 36 |
+
return SentenceTransformer(self.embed_model)
|
| 37 |
+
|
| 38 |
+
def _load_index(self, dim: int):
|
| 39 |
+
import faiss
|
| 40 |
+
if os.path.exists(self.index_path):
|
| 41 |
+
return faiss.read_index(self.index_path)
|
| 42 |
+
return faiss.IndexFlatL2(dim)
|
| 43 |
+
|
| 44 |
+
def add_texts(self, texts: List[str], source: str = "unknown"):
|
| 45 |
+
try:
|
| 46 |
+
embedder = self._load_embedder()
|
| 47 |
+
embeddings = embedder.encode(texts, show_progress_bar=False)
|
| 48 |
+
dim = len(embeddings[0])
|
| 49 |
+
index = self._load_index(dim)
|
| 50 |
+
index.add(embeddings)
|
| 51 |
+
import faiss
|
| 52 |
+
faiss.write_index(index, self.index_path)
|
| 53 |
+
|
| 54 |
+
conn = sqlite3.connect(self.meta_db)
|
| 55 |
+
cur = conn.cursor()
|
| 56 |
+
for t in texts:
|
| 57 |
+
cur.execute("INSERT INTO vectors(source, content) VALUES(?,?)", (source, t))
|
| 58 |
+
conn.commit()
|
| 59 |
+
conn.close()
|
| 60 |
+
except Exception:
|
| 61 |
+
logger.exception("Failed to add texts")
|
| 62 |
+
|
| 63 |
+
def query(self, q: str, top_k: int = 3):
|
| 64 |
+
try:
|
| 65 |
+
embedder = self._load_embedder()
|
| 66 |
+
qv = embedder.encode([q])
|
| 67 |
+
import faiss
|
| 68 |
+
if not os.path.exists(self.index_path):
|
| 69 |
+
return []
|
| 70 |
+
index = faiss.read_index(self.index_path)
|
| 71 |
+
scores, ids = index.search(qv, top_k)
|
| 72 |
+
conn = sqlite3.connect(self.meta_db)
|
| 73 |
+
cur = conn.cursor()
|
| 74 |
+
results = []
|
| 75 |
+
for idx in ids[0]:
|
| 76 |
+
cur.execute("SELECT source, content FROM vectors WHERE id=?", (int(idx) + 1,))
|
| 77 |
+
row = cur.fetchone()
|
| 78 |
+
if row:
|
| 79 |
+
results.append({"source": row[0], "content": row[1]})
|
| 80 |
+
conn.close()
|
| 81 |
+
return results
|
| 82 |
+
except Exception:
|
| 83 |
+
logger.exception("Query failed")
|
| 84 |
+
return []
|
brain_server/memory/short_term.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Short-term in-memory store with TTL."""
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("kapo.memory.short_term")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ShortTermMemory:
|
| 10 |
+
def __init__(self, ttl_sec: int = 1800, max_items: int = 500):
|
| 11 |
+
self.ttl_sec = ttl_sec
|
| 12 |
+
self.max_items = max_items
|
| 13 |
+
self._store: Dict[str, Any] = {}
|
| 14 |
+
self._ts: Dict[str, float] = {}
|
| 15 |
+
|
| 16 |
+
def _cleanup(self):
|
| 17 |
+
try:
|
| 18 |
+
now = time.time()
|
| 19 |
+
expired = [k for k, t in self._ts.items() if now - t > self.ttl_sec]
|
| 20 |
+
for k in expired:
|
| 21 |
+
self._store.pop(k, None)
|
| 22 |
+
self._ts.pop(k, None)
|
| 23 |
+
if len(self._store) > self.max_items:
|
| 24 |
+
for k in list(self._store.keys())[: len(self._store) - self.max_items]:
|
| 25 |
+
self._store.pop(k, None)
|
| 26 |
+
self._ts.pop(k, None)
|
| 27 |
+
except Exception:
|
| 28 |
+
logger.exception("Cleanup failed")
|
| 29 |
+
|
| 30 |
+
def set(self, key: str, value: Any):
|
| 31 |
+
self._store[key] = value
|
| 32 |
+
self._ts[key] = time.time()
|
| 33 |
+
self._cleanup()
|
| 34 |
+
|
| 35 |
+
def get(self, key: str):
|
| 36 |
+
self._cleanup()
|
| 37 |
+
return self._store.get(key)
|
brain_server/rag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""RAG package."""
|
brain_server/rag/loader.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Loader: ????? ????? ?? ???????."""
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List
|
| 5 |
+
from api.deps import get_logger
|
| 6 |
+
|
| 7 |
+
logger = get_logger("kapo.rag.loader")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_texts(paths: List[str]) -> List[str]:
|
| 11 |
+
texts = []
|
| 12 |
+
for p in paths:
|
| 13 |
+
try:
|
| 14 |
+
if os.path.exists(p):
|
| 15 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 16 |
+
texts.append(f.read())
|
| 17 |
+
except Exception:
|
| 18 |
+
logger.exception("Failed to load %s", p)
|
| 19 |
+
return texts
|
brain_server/rag/retriever.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Retriever: ??????? FAISS."""
|
| 2 |
+
import logging
|
| 3 |
+
from memory.knowledge_vector import KnowledgeVectorStore
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger("kapo.rag.retriever")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def retrieve(query: str, top_k: int = 3):
|
| 9 |
+
try:
|
| 10 |
+
store = KnowledgeVectorStore()
|
| 11 |
+
return store.query(query, top_k=top_k)
|
| 12 |
+
except Exception:
|
| 13 |
+
logger.exception("Retrieve failed")
|
| 14 |
+
return []
|
brain_server/requirements.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy<2.0.0
|
| 2 |
+
fastapi>=0.115.2,<1.0
|
| 3 |
+
uvicorn==0.30.1
|
| 4 |
+
pydantic==2.7.3
|
| 5 |
+
python-dotenv==1.0.1
|
| 6 |
+
requests==2.32.3
|
| 7 |
+
python-multipart>=0.0.18
|
| 8 |
+
sqlalchemy==2.0.30
|
| 9 |
+
sentence-transformers==3.0.1
|
| 10 |
+
faiss-cpu==1.8.0
|
| 11 |
+
llama-cpp-python==0.2.79
|
| 12 |
+
python-json-logger==2.0.7
|
| 13 |
+
langgraph==0.0.50
|
| 14 |
+
huggingface_hub>=0.33.5,<2.0
|
| 15 |
+
pyngrok==7.1.6
|
| 16 |
+
firebase-admin==6.5.0
|
| 17 |
+
starlette>=0.40.0,<1.0
|
| 18 |
+
websockets==12.0
|
| 19 |
+
|
| 20 |
+
sentence-transformers
|
| 21 |
+
transformers
|
| 22 |
+
|
| 23 |
+
peft
|
| 24 |
+
bitsandbytes
|
| 25 |
+
trl
|
| 26 |
+
accelerate
|
| 27 |
+
datasets
|
kapo.env
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BRAIN_AUTO_NGROK=0
|
| 2 |
+
BRAIN_AUTO_PUBLISH_URL_ON_STARTUP=0
|
| 3 |
+
BRAIN_LANGUAGES=ar,en
|
| 4 |
+
BRAIN_PLATFORM_NAME=ai_coder_main
|
| 5 |
+
BRAIN_PROVIDER=huggingface
|
| 6 |
+
BRAIN_REUSE_PUBLIC_URL_ON_RESTART=0
|
| 7 |
+
BRAIN_ROLES=coding,planner,fallback
|
| 8 |
+
BRAIN_TEMPLATE=hf-space-cpu
|
| 9 |
+
BRAIN_TUNNEL_PROVIDER=none
|
| 10 |
+
FIREBASE_ENABLED=0
|
| 11 |
+
FIREBASE_NAMESPACE=kapo
|
| 12 |
+
FIREBASE_PROJECT_ID=citadel4travels
|
| 13 |
+
GOOGLE_DRIVE_BOOTSTRAP_URL=https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG
|
| 14 |
+
HF_ACCELERATOR=cpu
|
| 15 |
+
HF_SPACE_DOCKER=1
|
| 16 |
+
KAGGLE_AUTO_BOOTSTRAP=0
|
| 17 |
+
KAPO_BOOTSTRAP_URL=https://drive.google.com/uc?export=download&id=19jyBWsQ9ciJVPi2PUigu5ti3gJ24A6TG
|
| 18 |
+
KAPO_COMPUTE_PROFILE=cpu
|
| 19 |
+
KAPO_HF_INFERENCE_API=1
|
| 20 |
+
KAPO_HF_TRANSFORMERS_RUNTIME=0
|
| 21 |
+
KAPO_LAZY_EMBED_STARTUP=1
|
| 22 |
+
KAPO_LAZY_MODEL_STARTUP=1
|
| 23 |
+
KAPO_PATCH_BUNDLE_URL=https://drive.google.com/uc?export=download&id=16rIe05GZihhAz7ba8E-WibKaJKbh9eu1
|
| 24 |
+
KAPO_PATCH_MANIFEST_URL=https://drive.google.com/uc?export=download&id=1jLuPMCA3hp9qstZZtpBNzTK0XOmLrV8b
|
| 25 |
+
KAPO_REMOTE_ENV_PASSWORD_B64=cENvSnljQ0Z6RUN2azRpcUFLVUdQLW9BbVBoTEtNOFE
|
| 26 |
+
KAPO_REMOTE_ENV_URL_B64=aHR0cHM6Ly9kcml2ZS5nb29nbGUuY29tL3VjP2V4cG9ydD1kb3dubG9hZCZpZD0xdjlZRDlNYlBKNUdYNk9WanVHM3hOR05xLWJfQlJfTGY
|
| 27 |
+
KAPO_SHARED_STATE_BACKEND=google_drive
|
| 28 |
+
MODEL_PROFILE_ID=hf-coder-qwen25-coder-7b-instruct
|
| 29 |
+
MODEL_REPO=Qwen/Qwen2.5-Coder-1.5B-Instruct
|
| 30 |
+
REMOTE_BRAIN_ONLY=1
|
| 31 |
+
SPACE_PUBLIC_URL=https://MrA7A1-AiCoder.hf.space
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.2,<1.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
pydantic==2.7.3
|
| 4 |
+
python-dotenv==1.0.1
|
| 5 |
+
PyYAML>=6.0,<7.0
|
| 6 |
+
requests==2.32.3
|
| 7 |
+
python-json-logger==2.0.7
|
| 8 |
+
huggingface_hub>=0.33.5,<2.0
|
| 9 |
+
starlette>=0.40.0,<1.0
|
shared/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities for KAPO runtimes."""
|
shared/google_drive_state.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Drive backed shared-state utilities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import sqlite3
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _safe_name(value: str, default: str = "item") -> str:
|
| 19 |
+
text = str(value or "").strip() or default
|
| 20 |
+
return "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in text)[:180]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GoogleDriveStateClient:
|
| 24 |
+
def __init__(self, logger: logging.Logger) -> None:
|
| 25 |
+
self.logger = logger
|
| 26 |
+
self._folder_cache: dict[str, str] = {}
|
| 27 |
+
self._file_cache: dict[str, str] = {}
|
| 28 |
+
self._bootstrap_lock = threading.Lock()
|
| 29 |
+
self._bootstrap_loaded_at = 0.0
|
| 30 |
+
self._bootstrap_payload: dict[str, Any] = {}
|
| 31 |
+
|
| 32 |
+
def _bootstrap_url(self) -> str:
|
| 33 |
+
return str(os.getenv("GOOGLE_DRIVE_BOOTSTRAP_URL", "") or os.getenv("KAPO_BOOTSTRAP_URL", "") or "").strip()
|
| 34 |
+
|
| 35 |
+
def _bootstrap_ttl(self) -> float:
|
| 36 |
+
return float(os.getenv("GOOGLE_DRIVE_BOOTSTRAP_TTL_SEC", "300") or 300)
|
| 37 |
+
|
| 38 |
+
def _apply_bootstrap_payload(self, payload: dict[str, Any]) -> None:
|
| 39 |
+
mappings = {
|
| 40 |
+
"shared_state_backend": "KAPO_SHARED_STATE_BACKEND",
|
| 41 |
+
"shared_state_folder_id": "GOOGLE_DRIVE_SHARED_STATE_FOLDER_ID",
|
| 42 |
+
"shared_state_prefix": "GOOGLE_DRIVE_SHARED_STATE_PREFIX",
|
| 43 |
+
"storage_folder_id": "GOOGLE_DRIVE_STORAGE_FOLDER_ID",
|
| 44 |
+
"storage_prefix": "GOOGLE_DRIVE_STORAGE_PREFIX",
|
| 45 |
+
"access_token": "GOOGLE_DRIVE_ACCESS_TOKEN",
|
| 46 |
+
"refresh_token": "GOOGLE_DRIVE_REFRESH_TOKEN",
|
| 47 |
+
"client_secret_json": "GOOGLE_DRIVE_CLIENT_SECRET_JSON",
|
| 48 |
+
"client_secret_json_base64": "GOOGLE_DRIVE_CLIENT_SECRET_JSON_BASE64",
|
| 49 |
+
"client_secret_path": "GOOGLE_DRIVE_CLIENT_SECRET_PATH",
|
| 50 |
+
"token_expires_at": "GOOGLE_DRIVE_TOKEN_EXPIRES_AT",
|
| 51 |
+
"firebase_enabled": "FIREBASE_ENABLED",
|
| 52 |
+
"executor_url": "EXECUTOR_URL",
|
| 53 |
+
"executor_public_url": "EXECUTOR_PUBLIC_URL",
|
| 54 |
+
"ngrok_authtoken": "NGROK_AUTHTOKEN",
|
| 55 |
+
"brain_public_url": "BRAIN_PUBLIC_URL",
|
| 56 |
+
"brain_roles": "BRAIN_ROLES",
|
| 57 |
+
"brain_languages": "BRAIN_LANGUAGES",
|
| 58 |
+
}
|
| 59 |
+
for key, env_name in mappings.items():
|
| 60 |
+
value = payload.get(key)
|
| 61 |
+
if value not in (None, ""):
|
| 62 |
+
os.environ[env_name] = str(value)
|
| 63 |
+
|
| 64 |
+
def ensure_bootstrap_loaded(self, force: bool = False) -> dict[str, Any]:
|
| 65 |
+
bootstrap_url = self._bootstrap_url()
|
| 66 |
+
if not bootstrap_url:
|
| 67 |
+
return {}
|
| 68 |
+
now = time.time()
|
| 69 |
+
if not force and self._bootstrap_payload and (now - self._bootstrap_loaded_at) < self._bootstrap_ttl():
|
| 70 |
+
return dict(self._bootstrap_payload)
|
| 71 |
+
with self._bootstrap_lock:
|
| 72 |
+
now = time.time()
|
| 73 |
+
if not force and self._bootstrap_payload and (now - self._bootstrap_loaded_at) < self._bootstrap_ttl():
|
| 74 |
+
return dict(self._bootstrap_payload)
|
| 75 |
+
try:
|
| 76 |
+
response = requests.get(bootstrap_url, timeout=20)
|
| 77 |
+
response.raise_for_status()
|
| 78 |
+
payload = dict(response.json() or {})
|
| 79 |
+
self._bootstrap_payload = payload
|
| 80 |
+
self._bootstrap_loaded_at = time.time()
|
| 81 |
+
self._apply_bootstrap_payload(payload)
|
| 82 |
+
return dict(payload)
|
| 83 |
+
except Exception:
|
| 84 |
+
self.logger.warning("Failed to load Google Drive bootstrap manifest", exc_info=True)
|
| 85 |
+
return dict(self._bootstrap_payload)
|
| 86 |
+
|
| 87 |
+
def enabled(self) -> bool:
|
| 88 |
+
self.ensure_bootstrap_loaded()
|
| 89 |
+
return bool(self.root_folder_id())
|
| 90 |
+
|
| 91 |
+
def root_folder_id(self) -> str:
|
| 92 |
+
return str(
|
| 93 |
+
os.getenv("GOOGLE_DRIVE_SHARED_STATE_FOLDER_ID", "")
|
| 94 |
+
or os.getenv("GOOGLE_DRIVE_STORAGE_FOLDER_ID", "")
|
| 95 |
+
or ""
|
| 96 |
+
).strip()
|
| 97 |
+
|
| 98 |
+
def prefix(self) -> str:
|
| 99 |
+
return str(
|
| 100 |
+
os.getenv("GOOGLE_DRIVE_SHARED_STATE_PREFIX", "")
|
| 101 |
+
or os.getenv("GOOGLE_DRIVE_STORAGE_PREFIX", "")
|
| 102 |
+
or "kapo/shared_state"
|
| 103 |
+
).strip("/ ")
|
| 104 |
+
|
| 105 |
+
def _control_plane_token(self, label: str) -> str:
|
| 106 |
+
candidates = [
|
| 107 |
+
Path.cwd().resolve() / "data" / "local" / "control_plane.db",
|
| 108 |
+
Path.cwd().resolve() / "data" / "drive_cache" / "control_plane.db",
|
| 109 |
+
]
|
| 110 |
+
for db_path in candidates:
|
| 111 |
+
if not db_path.exists():
|
| 112 |
+
continue
|
| 113 |
+
try:
|
| 114 |
+
conn = sqlite3.connect(db_path)
|
| 115 |
+
conn.row_factory = sqlite3.Row
|
| 116 |
+
row = conn.execute(
|
| 117 |
+
"SELECT token FROM ngrok_tokens WHERE label = ? AND enabled = 1",
|
| 118 |
+
(label,),
|
| 119 |
+
).fetchone()
|
| 120 |
+
conn.close()
|
| 121 |
+
token = str(row["token"]) if row else ""
|
| 122 |
+
if token:
|
| 123 |
+
return token
|
| 124 |
+
except Exception:
|
| 125 |
+
self.logger.warning("Failed to read Google Drive token from %s", db_path, exc_info=True)
|
| 126 |
+
return ""
|
| 127 |
+
|
| 128 |
+
def _client_secret_payload(self) -> dict[str, Any] | None:
|
| 129 |
+
raw = str(os.getenv("GOOGLE_DRIVE_CLIENT_SECRET_JSON", "")).strip()
|
| 130 |
+
raw_b64 = str(os.getenv("GOOGLE_DRIVE_CLIENT_SECRET_JSON_BASE64", "")).strip()
|
| 131 |
+
path = str(os.getenv("GOOGLE_DRIVE_CLIENT_SECRET_PATH", "")).strip()
|
| 132 |
+
if raw:
|
| 133 |
+
try:
|
| 134 |
+
return json.loads(raw)
|
| 135 |
+
except Exception:
|
| 136 |
+
self.logger.warning("Invalid GOOGLE_DRIVE_CLIENT_SECRET_JSON", exc_info=True)
|
| 137 |
+
if raw_b64:
|
| 138 |
+
try:
|
| 139 |
+
return json.loads(base64.b64decode(raw_b64).decode("utf-8"))
|
| 140 |
+
except Exception:
|
| 141 |
+
self.logger.warning("Invalid GOOGLE_DRIVE_CLIENT_SECRET_JSON_BASE64", exc_info=True)
|
| 142 |
+
if path:
|
| 143 |
+
try:
|
| 144 |
+
return json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
|
| 145 |
+
except Exception:
|
| 146 |
+
self.logger.warning("Failed to load Google Drive client secret path %s", path, exc_info=True)
|
| 147 |
+
client_id = str(os.getenv("GOOGLE_DRIVE_CLIENT_ID", "")).strip()
|
| 148 |
+
client_secret = str(os.getenv("GOOGLE_DRIVE_CLIENT_SECRET", "")).strip()
|
| 149 |
+
token_uri = str(os.getenv("GOOGLE_DRIVE_TOKEN_URI", "https://oauth2.googleapis.com/token")).strip()
|
| 150 |
+
if client_id and client_secret:
|
| 151 |
+
return {
|
| 152 |
+
"installed": {
|
| 153 |
+
"client_id": client_id,
|
| 154 |
+
"client_secret": client_secret,
|
| 155 |
+
"token_uri": token_uri,
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
def _oauth_client(self) -> dict[str, str]:
|
| 161 |
+
payload = self._client_secret_payload() or {}
|
| 162 |
+
installed = payload.get("installed") or payload.get("web") or {}
|
| 163 |
+
return {
|
| 164 |
+
"client_id": str(installed.get("client_id") or "").strip(),
|
| 165 |
+
"client_secret": str(installed.get("client_secret") or "").strip(),
|
| 166 |
+
"token_uri": str(installed.get("token_uri") or "https://oauth2.googleapis.com/token").strip(),
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
def _refresh_token(self) -> str:
|
| 170 |
+
return str(os.getenv("GOOGLE_DRIVE_REFRESH_TOKEN", "") or self._control_plane_token("google_drive_refresh_token") or "").strip()
|
| 171 |
+
|
| 172 |
+
def _access_token(self) -> str:
|
| 173 |
+
token = str(os.getenv("GOOGLE_DRIVE_ACCESS_TOKEN", "") or self._control_plane_token("google_drive_access_token") or "").strip()
|
| 174 |
+
expires_at = float(str(os.getenv("GOOGLE_DRIVE_TOKEN_EXPIRES_AT", "0") or "0") or 0)
|
| 175 |
+
if token and (not expires_at or expires_at > (time.time() + 60)):
|
| 176 |
+
return token
|
| 177 |
+
refreshed = self._refresh_access_token()
|
| 178 |
+
return refreshed or token
|
| 179 |
+
|
| 180 |
+
def _refresh_access_token(self) -> str:
|
| 181 |
+
refresh_token = self._refresh_token()
|
| 182 |
+
client = self._oauth_client()
|
| 183 |
+
if not refresh_token or not client["client_id"] or not client["client_secret"]:
|
| 184 |
+
return ""
|
| 185 |
+
response = requests.post(
|
| 186 |
+
client["token_uri"],
|
| 187 |
+
data={
|
| 188 |
+
"grant_type": "refresh_token",
|
| 189 |
+
"refresh_token": refresh_token,
|
| 190 |
+
"client_id": client["client_id"],
|
| 191 |
+
"client_secret": client["client_secret"],
|
| 192 |
+
},
|
| 193 |
+
timeout=30,
|
| 194 |
+
)
|
| 195 |
+
response.raise_for_status()
|
| 196 |
+
payload = response.json()
|
| 197 |
+
access_token = str(payload.get("access_token") or "").strip()
|
| 198 |
+
expires_in = float(payload.get("expires_in") or 0)
|
| 199 |
+
if access_token:
|
| 200 |
+
os.environ["GOOGLE_DRIVE_ACCESS_TOKEN"] = access_token
|
| 201 |
+
os.environ["GOOGLE_DRIVE_TOKEN_EXPIRES_AT"] = str(time.time() + expires_in) if expires_in else "0"
|
| 202 |
+
return access_token
|
| 203 |
+
|
| 204 |
+
def _request(self, method: str, url: str, *, headers: dict[str, str] | None = None, timeout: int = 60, **kwargs) -> requests.Response:
|
| 205 |
+
token = self._access_token()
|
| 206 |
+
if not token:
|
| 207 |
+
raise ValueError("Google Drive access token is not configured")
|
| 208 |
+
base_headers = dict(headers or {})
|
| 209 |
+
response: requests.Response | None = None
|
| 210 |
+
for _ in range(2):
|
| 211 |
+
response = requests.request(
|
| 212 |
+
method,
|
| 213 |
+
url,
|
| 214 |
+
headers={**base_headers, "Authorization": f"Bearer {token}"},
|
| 215 |
+
timeout=timeout,
|
| 216 |
+
**kwargs,
|
| 217 |
+
)
|
| 218 |
+
if response.status_code != 401:
|
| 219 |
+
response.raise_for_status()
|
| 220 |
+
return response
|
| 221 |
+
token = self._refresh_access_token()
|
| 222 |
+
if not token:
|
| 223 |
+
break
|
| 224 |
+
assert response is not None
|
| 225 |
+
response.raise_for_status()
|
| 226 |
+
return response
|
| 227 |
+
|
| 228 |
+
@staticmethod
|
| 229 |
+
def _escape_query(value: str) -> str:
|
| 230 |
+
return str(value or "").replace("\\", "\\\\").replace("'", "\\'")
|
| 231 |
+
|
| 232 |
+
def _find_folder(self, name: str, parent_id: str) -> str:
|
| 233 |
+
query = (
|
| 234 |
+
"mimeType = 'application/vnd.google-apps.folder' and trashed = false "
|
| 235 |
+
f"and name = '{self._escape_query(name)}'"
|
| 236 |
+
)
|
| 237 |
+
if parent_id:
|
| 238 |
+
query += f" and '{self._escape_query(parent_id)}' in parents"
|
| 239 |
+
response = self._request(
|
| 240 |
+
"GET",
|
| 241 |
+
"https://www.googleapis.com/drive/v3/files",
|
| 242 |
+
params={"q": query, "fields": "files(id,name)", "pageSize": 10, "supportsAllDrives": "true"},
|
| 243 |
+
)
|
| 244 |
+
files = response.json().get("files", [])
|
| 245 |
+
return str(files[0].get("id") or "").strip() if files else ""
|
| 246 |
+
|
| 247 |
+
def _create_folder(self, name: str, parent_id: str) -> str:
|
| 248 |
+
payload: dict[str, Any] = {"name": name, "mimeType": "application/vnd.google-apps.folder"}
|
| 249 |
+
if parent_id:
|
| 250 |
+
payload["parents"] = [parent_id]
|
| 251 |
+
response = self._request(
|
| 252 |
+
"POST",
|
| 253 |
+
"https://www.googleapis.com/drive/v3/files?supportsAllDrives=true",
|
| 254 |
+
headers={"Content-Type": "application/json; charset=UTF-8"},
|
| 255 |
+
json=payload,
|
| 256 |
+
)
|
| 257 |
+
return str(response.json().get("id") or "").strip()
|
| 258 |
+
|
| 259 |
+
def _ensure_folder_path(self, relative_path: str) -> str:
|
| 260 |
+
current_parent = self.root_folder_id()
|
| 261 |
+
parts = [part for part in f"{self.prefix()}/{relative_path}".replace("\\", "/").split("/") if part.strip()]
|
| 262 |
+
for part in parts:
|
| 263 |
+
safe_part = _safe_name(part, "folder")
|
| 264 |
+
cache_key = f"{current_parent}:{safe_part}"
|
| 265 |
+
cached = self._folder_cache.get(cache_key)
|
| 266 |
+
if cached:
|
| 267 |
+
current_parent = cached
|
| 268 |
+
continue
|
| 269 |
+
folder_id = self._find_folder(safe_part, current_parent)
|
| 270 |
+
if not folder_id:
|
| 271 |
+
folder_id = self._create_folder(safe_part, current_parent)
|
| 272 |
+
self._folder_cache[cache_key] = folder_id
|
| 273 |
+
current_parent = folder_id
|
| 274 |
+
return current_parent
|
| 275 |
+
|
| 276 |
+
def _find_file(self, folder_id: str, file_name: str) -> str:
|
| 277 |
+
cache_key = f"{folder_id}:{file_name}"
|
| 278 |
+
cached = self._file_cache.get(cache_key)
|
| 279 |
+
if cached:
|
| 280 |
+
return cached
|
| 281 |
+
query = (
|
| 282 |
+
"trashed = false "
|
| 283 |
+
f"and name = '{self._escape_query(file_name)}' "
|
| 284 |
+
f"and '{self._escape_query(folder_id)}' in parents"
|
| 285 |
+
)
|
| 286 |
+
response = self._request(
|
| 287 |
+
"GET",
|
| 288 |
+
"https://www.googleapis.com/drive/v3/files",
|
| 289 |
+
params={"q": query, "fields": "files(id,name)", "pageSize": 10, "supportsAllDrives": "true"},
|
| 290 |
+
)
|
| 291 |
+
files = response.json().get("files", [])
|
| 292 |
+
file_id = str(files[0].get("id") or "").strip() if files else ""
|
| 293 |
+
if file_id:
|
| 294 |
+
self._file_cache[cache_key] = file_id
|
| 295 |
+
return file_id
|
| 296 |
+
|
| 297 |
+
def _create_file(self, folder_id: str, file_name: str) -> str:
|
| 298 |
+
response = self._request(
|
| 299 |
+
"POST",
|
| 300 |
+
"https://www.googleapis.com/drive/v3/files?supportsAllDrives=true",
|
| 301 |
+
headers={"Content-Type": "application/json; charset=UTF-8"},
|
| 302 |
+
json={"name": file_name, "parents": [folder_id]},
|
| 303 |
+
)
|
| 304 |
+
file_id = str(response.json().get("id") or "").strip()
|
| 305 |
+
if file_id:
|
| 306 |
+
self._file_cache[f"{folder_id}:{file_name}"] = file_id
|
| 307 |
+
return file_id
|
| 308 |
+
|
| 309 |
+
def _upload_json(self, file_id: str, payload: dict[str, Any]) -> None:
|
| 310 |
+
self._request(
|
| 311 |
+
"PATCH",
|
| 312 |
+
f"https://www.googleapis.com/upload/drive/v3/files/{file_id}?uploadType=media&supportsAllDrives=true",
|
| 313 |
+
headers={"Content-Type": "application/json; charset=UTF-8"},
|
| 314 |
+
data=json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8"),
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
def _download_json(self, file_id: str) -> dict[str, Any]:
|
| 318 |
+
response = self._request(
|
| 319 |
+
"GET",
|
| 320 |
+
f"https://www.googleapis.com/drive/v3/files/{file_id}?alt=media&supportsAllDrives=true",
|
| 321 |
+
)
|
| 322 |
+
return dict(response.json() or {})
|
| 323 |
+
|
| 324 |
+
def get_document(self, collection: str, doc_id: str) -> dict[str, Any]:
|
| 325 |
+
if not self.enabled():
|
| 326 |
+
return {}
|
| 327 |
+
folder_id = self._ensure_folder_path(_safe_name(collection, "collection"))
|
| 328 |
+
file_name = f"{_safe_name(doc_id)}.json"
|
| 329 |
+
file_id = self._find_file(folder_id, file_name)
|
| 330 |
+
if not file_id:
|
| 331 |
+
return {}
|
| 332 |
+
try:
|
| 333 |
+
payload = self._download_json(file_id)
|
| 334 |
+
payload.setdefault("id", _safe_name(doc_id))
|
| 335 |
+
return payload
|
| 336 |
+
except Exception:
|
| 337 |
+
self.logger.warning("Failed to download shared-state file %s/%s", collection, doc_id, exc_info=True)
|
| 338 |
+
return {}
|
| 339 |
+
|
| 340 |
+
def set_document(self, collection: str, doc_id: str, payload: dict[str, Any], *, merge: bool = True) -> bool:
|
| 341 |
+
if not self.enabled():
|
| 342 |
+
return False
|
| 343 |
+
safe_doc = _safe_name(doc_id)
|
| 344 |
+
try:
|
| 345 |
+
existing = self.get_document(collection, safe_doc) if merge else {}
|
| 346 |
+
combined = {**existing, **dict(payload or {})} if merge else dict(payload or {})
|
| 347 |
+
combined.setdefault("id", safe_doc)
|
| 348 |
+
folder_id = self._ensure_folder_path(_safe_name(collection, "collection"))
|
| 349 |
+
file_name = f"{safe_doc}.json"
|
| 350 |
+
file_id = self._find_file(folder_id, file_name)
|
| 351 |
+
if not file_id:
|
| 352 |
+
file_id = self._create_file(folder_id, file_name)
|
| 353 |
+
self._upload_json(file_id, combined)
|
| 354 |
+
return True
|
| 355 |
+
except Exception:
|
| 356 |
+
self.logger.warning("Failed to upload shared-state file %s/%s", collection, safe_doc, exc_info=True)
|
| 357 |
+
return False
|
| 358 |
+
|
| 359 |
+
def list_documents(self, collection: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
| 360 |
+
if not self.enabled():
|
| 361 |
+
return []
|
| 362 |
+
try:
|
| 363 |
+
folder_id = self._ensure_folder_path(_safe_name(collection, "collection"))
|
| 364 |
+
query = f"trashed = false and '{self._escape_query(folder_id)}' in parents"
|
| 365 |
+
response = self._request(
|
| 366 |
+
"GET",
|
| 367 |
+
"https://www.googleapis.com/drive/v3/files",
|
| 368 |
+
params={
|
| 369 |
+
"q": query,
|
| 370 |
+
"fields": "files(id,name,modifiedTime)",
|
| 371 |
+
"pageSize": max(1, int(limit)),
|
| 372 |
+
"supportsAllDrives": "true",
|
| 373 |
+
"orderBy": "modifiedTime desc",
|
| 374 |
+
},
|
| 375 |
+
)
|
| 376 |
+
items: list[dict[str, Any]] = []
|
| 377 |
+
for file in response.json().get("files", []):
|
| 378 |
+
file_id = str(file.get("id") or "").strip()
|
| 379 |
+
file_name = str(file.get("name") or "").strip()
|
| 380 |
+
if not file_id or not file_name.endswith(".json"):
|
| 381 |
+
continue
|
| 382 |
+
self._file_cache[f"{folder_id}:{file_name}"] = file_id
|
| 383 |
+
payload = self._download_json(file_id)
|
| 384 |
+
payload.setdefault("id", file_name[:-5])
|
| 385 |
+
items.append(payload)
|
| 386 |
+
return items
|
| 387 |
+
except Exception:
|
| 388 |
+
self.logger.warning("Failed to list shared-state collection %s", collection, exc_info=True)
|
| 389 |
+
return []
|
| 390 |
+
|
| 391 |
+
def delete_document(self, collection: str, doc_id: str) -> bool:
|
| 392 |
+
if not self.enabled():
|
| 393 |
+
return False
|
| 394 |
+
safe_doc = _safe_name(doc_id)
|
| 395 |
+
try:
|
| 396 |
+
folder_id = self._ensure_folder_path(_safe_name(collection, "collection"))
|
| 397 |
+
file_name = f"{safe_doc}.json"
|
| 398 |
+
file_id = self._find_file(folder_id, file_name)
|
| 399 |
+
if not file_id:
|
| 400 |
+
return True
|
| 401 |
+
self._request(
|
| 402 |
+
"DELETE",
|
| 403 |
+
f"https://www.googleapis.com/drive/v3/files/{file_id}?supportsAllDrives=true",
|
| 404 |
+
)
|
| 405 |
+
self._file_cache.pop(f"{folder_id}:{file_name}", None)
|
| 406 |
+
return True
|
| 407 |
+
except Exception:
|
| 408 |
+
self.logger.warning("Failed to delete shared-state file %s/%s", collection, safe_doc, exc_info=True)
|
| 409 |
+
return False
|
shared/remote_env.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encrypted remote .env loader and publisher helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
from cryptography.fernet import Fernet
|
| 14 |
+
from cryptography.hazmat.primitives import hashes
|
| 15 |
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
| 16 |
+
from dotenv import dotenv_values
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _decode_maybe_b64(value: str) -> str:
|
| 20 |
+
text = str(value or "").strip()
|
| 21 |
+
if not text:
|
| 22 |
+
return ""
|
| 23 |
+
try:
|
| 24 |
+
padded = text + "=" * (-len(text) % 4)
|
| 25 |
+
decoded = base64.urlsafe_b64decode(padded.encode("utf-8")).decode("utf-8")
|
| 26 |
+
if decoded:
|
| 27 |
+
return decoded
|
| 28 |
+
except Exception:
|
| 29 |
+
pass
|
| 30 |
+
return text
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _derive_key(password: str, salt: bytes) -> bytes:
|
| 34 |
+
kdf = PBKDF2HMAC(
|
| 35 |
+
algorithm=hashes.SHA256(),
|
| 36 |
+
length=32,
|
| 37 |
+
salt=salt,
|
| 38 |
+
iterations=390000,
|
| 39 |
+
)
|
| 40 |
+
return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def build_remote_env_bundle(env_text: str, password: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 44 |
+
salt = os.urandom(16)
|
| 45 |
+
key = _derive_key(password, salt)
|
| 46 |
+
token = Fernet(key).encrypt((env_text or "").encode("utf-8"))
|
| 47 |
+
return {
|
| 48 |
+
"schema": "kapo-remote-env-v1",
|
| 49 |
+
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
| 50 |
+
"token": token.decode("utf-8"),
|
| 51 |
+
"metadata": dict(metadata or {}),
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def decrypt_remote_env_bundle(payload: dict[str, Any], password: str) -> str:
|
| 56 |
+
salt_raw = str(payload.get("salt") or "").strip()
|
| 57 |
+
token = str(payload.get("token") or "").strip()
|
| 58 |
+
if not salt_raw or not token:
|
| 59 |
+
raise ValueError("Remote env payload is incomplete")
|
| 60 |
+
salt = base64.urlsafe_b64decode(salt_raw.encode("utf-8"))
|
| 61 |
+
key = _derive_key(password, salt)
|
| 62 |
+
return Fernet(key).decrypt(token.encode("utf-8")).decode("utf-8")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _remote_env_url() -> str:
|
| 66 |
+
return _decode_maybe_b64(
|
| 67 |
+
str(os.getenv("KAPO_REMOTE_ENV_URL", "") or os.getenv("KAPO_REMOTE_ENV_URL_B64", "") or "").strip()
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _remote_env_password() -> str:
|
| 72 |
+
return _decode_maybe_b64(
|
| 73 |
+
str(os.getenv("KAPO_REMOTE_ENV_PASSWORD", "") or os.getenv("KAPO_REMOTE_ENV_PASSWORD_B64", "") or "").strip()
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def load_remote_env_if_configured(*, override: bool = True, logger_name: str = "kapo.remote_env") -> dict[str, str]:
|
| 78 |
+
logger = logging.getLogger(logger_name)
|
| 79 |
+
if str(os.getenv("KAPO_REMOTE_ENV_LOADED", "")).strip().lower() in {"1", "true", "yes", "on"}:
|
| 80 |
+
return {}
|
| 81 |
+
url = _remote_env_url()
|
| 82 |
+
password = _remote_env_password()
|
| 83 |
+
if not url or not password:
|
| 84 |
+
return {}
|
| 85 |
+
try:
|
| 86 |
+
response = requests.get(url, timeout=30)
|
| 87 |
+
response.raise_for_status()
|
| 88 |
+
payload = dict(response.json() or {})
|
| 89 |
+
env_text = decrypt_remote_env_bundle(payload, password)
|
| 90 |
+
parsed = {
|
| 91 |
+
str(key): str(value)
|
| 92 |
+
for key, value in dotenv_values(stream=io.StringIO(env_text)).items()
|
| 93 |
+
if key and value is not None
|
| 94 |
+
}
|
| 95 |
+
for key, value in parsed.items():
|
| 96 |
+
if override or key not in os.environ or not str(os.getenv(key) or "").strip():
|
| 97 |
+
os.environ[key] = value
|
| 98 |
+
os.environ["KAPO_REMOTE_ENV_LOADED"] = "1"
|
| 99 |
+
return parsed
|
| 100 |
+
except Exception:
|
| 101 |
+
logger.warning("Failed to load encrypted remote env bundle", exc_info=True)
|
| 102 |
+
return {}
|