HomePilot Deploy Bot commited on
Commit Β·
e5bf219
1
Parent(s): 0a15291
chore(hf): sync HomePilot to HF Space
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitattributes +3 -3
- Dockerfile +9 -0
- backend/app/asset_registry.py +32 -16
- backend/app/background.py +4 -8
- backend/app/comfy.py +8 -27
- backend/app/download_script.py +32 -0
- backend/app/files.py +333 -130
- backend/app/intent_guard.py +28 -0
- backend/app/inventory.py +9 -2
- backend/app/main.py +138 -79
- backend/app/media_links.py +86 -0
- backend/app/media_ops.py +360 -0
- backend/app/media_quota.py +60 -0
- backend/app/media_storage/__init__.py +155 -0
- backend/app/media_storage/base.py +89 -0
- backend/app/media_storage/local.py +111 -0
- backend/app/media_storage/s3.py +265 -0
- backend/app/media_validate.py +81 -0
- backend/app/model_advisor/hardware.py +43 -5
- backend/app/model_resolver.py +90 -8
- backend/app/projects.py +5 -1
- backend/app/storage.py +32 -0
- backend/app/studio/bundles.py +2 -1
- backend/app/users.py +8 -16
- backend/requirements.txt +3 -0
- deploy/huggingface-space/hf_wrapper.py +40 -0
- expert/backend/app/expert/__init__.py +5 -0
- expert/backend/app/expert/capability.py +275 -0
- expert/backend/app/expert/compaction.py +168 -0
- expert/backend/app/expert/config.py +190 -0
- expert/backend/app/expert/deep.py +481 -0
- expert/backend/app/expert/eval_bench.py +30 -0
- expert/backend/app/expert/grounding.py +65 -0
- expert/backend/app/expert/heavy.py +487 -0
- expert/backend/app/expert/mcp_catalog.py +113 -0
- expert/backend/app/expert/mcp_gateway.py +197 -0
- expert/backend/app/expert/mcp_tools.py +56 -0
- expert/backend/app/expert/observability.py +33 -0
- expert/backend/app/expert/persistent_memory.py +65 -0
- expert/backend/app/expert/policies.py +126 -0
- expert/backend/app/expert/project_context.py +107 -0
- expert/backend/app/expert/providers.py +1282 -0
- expert/backend/app/expert/readiness.py +103 -0
- expert/backend/app/expert/router.py +521 -0
- expert/backend/app/expert/routes.py +865 -0
- expert/backend/app/expert/safety_policy.py +31 -0
- expert/backend/app/expert/schemas.py +230 -0
- expert/backend/app/expert/state_store.py +227 -0
- expert/backend/app/expert/target.py +92 -0
- expert/backend/app/expert/thinking.py +311 -0
.gitattributes
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.svg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 4 |
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.hpersona filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.
|
| 8 |
-
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 1 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.svg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.hpersona filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 8 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -56,6 +56,15 @@ COPY backend/app ./app
|
|
| 56 |
# Ensure __init__.py exists (created by deploy script)
|
| 57 |
RUN touch ./app/__init__.py
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Community persona samples
|
| 60 |
COPY community/sample ./community/sample
|
| 61 |
|
|
|
|
| 56 |
# Ensure __init__.py exists (created by deploy script)
|
| 57 |
RUN touch ./app/__init__.py
|
| 58 |
|
| 59 |
+
# The model installer (POST /models/install) runs this for ComfyUI and Civitai downloads;
|
| 60 |
+
# the backend finds the copy next to its package (app/download_script.py).
|
| 61 |
+
COPY scripts/download.py ./scripts/download.py
|
| 62 |
+
|
| 63 |
+
# Expert β the chat's Fast / Expert / Heavy path (/v1/expert/*). The backend looks for it
|
| 64 |
+
# next to its package when there is no repo around (app/main.py). Without it every chat
|
| 65 |
+
# POST fell through to the frontend catch-all below and came back HTTP 405.
|
| 66 |
+
COPY expert/backend/app/expert ./expert
|
| 67 |
+
|
| 68 |
# Community persona samples
|
| 69 |
COPY community/sample ./community/sample
|
| 70 |
|
backend/app/asset_registry.py
CHANGED
|
@@ -20,7 +20,9 @@ from datetime import datetime
|
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Any, Dict, List, Optional
|
| 22 |
|
| 23 |
-
from .config import UPLOAD_DIR, SQLITE_PATH
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# Known filename patterns β feature classification
|
| 26 |
_FEATURE_PATTERNS = {
|
|
@@ -52,9 +54,7 @@ def _db() -> sqlite3.Connection:
|
|
| 52 |
|
| 53 |
|
| 54 |
def _upload_root() -> Path:
|
| 55 |
-
p =
|
| 56 |
-
if not p.is_absolute():
|
| 57 |
-
p = Path(__file__).resolve().parents[1] / "data" / "uploads"
|
| 58 |
p.mkdir(parents=True, exist_ok=True)
|
| 59 |
return p
|
| 60 |
|
|
@@ -106,19 +106,25 @@ def register_asset(
|
|
| 106 |
sha256: str = "",
|
| 107 |
width: int = 0,
|
| 108 |
height: int = 0,
|
|
|
|
| 109 |
) -> str:
|
| 110 |
"""
|
| 111 |
Register a new asset. Returns the asset_id.
|
| 112 |
-
If an asset with the same storage_key already exists, returns its ID.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
"""
|
|
|
|
| 114 |
_ensure_assets_table()
|
| 115 |
con = _db()
|
| 116 |
cur = con.cursor()
|
| 117 |
|
| 118 |
# Check if already exists
|
| 119 |
cur.execute(
|
| 120 |
-
"SELECT id FROM assets WHERE storage_backend =
|
| 121 |
-
(
|
| 122 |
)
|
| 123 |
row = cur.fetchone()
|
| 124 |
if row:
|
|
@@ -138,9 +144,9 @@ def register_asset(
|
|
| 138 |
id, kind, mime, storage_backend, storage_key,
|
| 139 |
size_bytes, width, height, sha256,
|
| 140 |
origin, source_hint, feature, project_id, user_id
|
| 141 |
-
) VALUES (?,?,?,
|
| 142 |
""",
|
| 143 |
-
(asset_id, kind, mime or "", storage_key,
|
| 144 |
size_bytes, width, height, sha256 or "",
|
| 145 |
origin, source_hint or "", feature or "",
|
| 146 |
project_id or "", user_id or ""),
|
|
@@ -161,23 +167,25 @@ def get_asset(asset_id: str) -> Optional[Dict[str, Any]]:
|
|
| 161 |
return dict(row) if row else None
|
| 162 |
|
| 163 |
|
| 164 |
-
def find_asset_id_by_storage_key(storage_key: str) -> Optional[str]:
|
| 165 |
-
"""Reverse-lookup: storage_key β asset_id.
|
| 166 |
|
| 167 |
Used by the persona-library link phase to backfill the
|
| 168 |
``registry_asset_id`` field on older library rows that were
|
| 169 |
saved before that field existed. The ``UNIQUE(storage_backend,
|
| 170 |
storage_key)`` constraint on the assets table guarantees at
|
| 171 |
-
most one row per
|
|
|
|
| 172 |
"""
|
| 173 |
if not storage_key:
|
| 174 |
return None
|
|
|
|
| 175 |
_ensure_assets_table()
|
| 176 |
con = _db()
|
| 177 |
cur = con.cursor()
|
| 178 |
cur.execute(
|
| 179 |
-
"SELECT id FROM assets WHERE storage_backend =
|
| 180 |
-
(
|
| 181 |
)
|
| 182 |
row = cur.fetchone()
|
| 183 |
con.close()
|
|
@@ -412,10 +420,14 @@ def register_comfy_output(
|
|
| 412 |
project_id: str = "",
|
| 413 |
user_id: str = "",
|
| 414 |
workflow_name: str = "",
|
|
|
|
|
|
|
| 415 |
) -> str:
|
| 416 |
"""
|
| 417 |
Register a file that was downloaded from ComfyUI.
|
| 418 |
-
Call this right after saving the file to
|
|
|
|
|
|
|
| 419 |
|
| 420 |
Returns the asset_id.
|
| 421 |
"""
|
|
@@ -426,7 +438,10 @@ def register_comfy_output(
|
|
| 426 |
except ValueError:
|
| 427 |
rel_path = str(abs_path)
|
| 428 |
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
| 430 |
ext = abs_path.suffix.lower()
|
| 431 |
kind = "video" if ext in VIDEO_EXTS else "image"
|
| 432 |
mime = mimetypes.guess_type(str(abs_path))[0] or ""
|
|
@@ -444,4 +459,5 @@ def register_comfy_output(
|
|
| 444 |
feature=feature,
|
| 445 |
project_id=project_id,
|
| 446 |
user_id=user_id,
|
|
|
|
| 447 |
)
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Any, Dict, List, Optional
|
| 22 |
|
| 23 |
+
from .config import UPLOAD_DIR, SQLITE_PATH # noqa: F401 (UPLOAD_DIR: kept for importers)
|
| 24 |
+
from .media_storage import backend_name as _storage_backend_name
|
| 25 |
+
from .media_storage import upload_root as _storage_root
|
| 26 |
|
| 27 |
# Known filename patterns β feature classification
|
| 28 |
_FEATURE_PATTERNS = {
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def _upload_root() -> Path:
|
| 57 |
+
p = _storage_root()
|
|
|
|
|
|
|
| 58 |
p.mkdir(parents=True, exist_ok=True)
|
| 59 |
return p
|
| 60 |
|
|
|
|
| 106 |
sha256: str = "",
|
| 107 |
width: int = 0,
|
| 108 |
height: int = 0,
|
| 109 |
+
storage_backend: str = "",
|
| 110 |
) -> str:
|
| 111 |
"""
|
| 112 |
Register a new asset. Returns the asset_id.
|
| 113 |
+
If an asset with the same (storage_backend, storage_key) already exists, returns its ID.
|
| 114 |
+
|
| 115 |
+
``storage_backend`` is the MediaStorage backend holding the bytes β the configured one
|
| 116 |
+
when not given (``local`` today). The table always had the column; the queries assumed
|
| 117 |
+
'local', which is what a second backend trips over.
|
| 118 |
"""
|
| 119 |
+
backend = (storage_backend or _storage_backend_name()).strip().lower()
|
| 120 |
_ensure_assets_table()
|
| 121 |
con = _db()
|
| 122 |
cur = con.cursor()
|
| 123 |
|
| 124 |
# Check if already exists
|
| 125 |
cur.execute(
|
| 126 |
+
"SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?",
|
| 127 |
+
(backend, storage_key),
|
| 128 |
)
|
| 129 |
row = cur.fetchone()
|
| 130 |
if row:
|
|
|
|
| 144 |
id, kind, mime, storage_backend, storage_key,
|
| 145 |
size_bytes, width, height, sha256,
|
| 146 |
origin, source_hint, feature, project_id, user_id
|
| 147 |
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
| 148 |
""",
|
| 149 |
+
(asset_id, kind, mime or "", backend, storage_key,
|
| 150 |
size_bytes, width, height, sha256 or "",
|
| 151 |
origin, source_hint or "", feature or "",
|
| 152 |
project_id or "", user_id or ""),
|
|
|
|
| 167 |
return dict(row) if row else None
|
| 168 |
|
| 169 |
|
| 170 |
+
def find_asset_id_by_storage_key(storage_key: str, storage_backend: str = "") -> Optional[str]:
|
| 171 |
+
"""Reverse-lookup: (storage_backend, storage_key) β asset_id.
|
| 172 |
|
| 173 |
Used by the persona-library link phase to backfill the
|
| 174 |
``registry_asset_id`` field on older library rows that were
|
| 175 |
saved before that field existed. The ``UNIQUE(storage_backend,
|
| 176 |
storage_key)`` constraint on the assets table guarantees at
|
| 177 |
+
most one row per key and backend, so this is a safe O(1) lookup.
|
| 178 |
+
The configured backend when ``storage_backend`` is not given.
|
| 179 |
"""
|
| 180 |
if not storage_key:
|
| 181 |
return None
|
| 182 |
+
backend = (storage_backend or _storage_backend_name()).strip().lower()
|
| 183 |
_ensure_assets_table()
|
| 184 |
con = _db()
|
| 185 |
cur = con.cursor()
|
| 186 |
cur.execute(
|
| 187 |
+
"SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?",
|
| 188 |
+
(backend, storage_key),
|
| 189 |
)
|
| 190 |
row = cur.fetchone()
|
| 191 |
con.close()
|
|
|
|
| 420 |
project_id: str = "",
|
| 421 |
user_id: str = "",
|
| 422 |
workflow_name: str = "",
|
| 423 |
+
storage_backend: str = "local",
|
| 424 |
+
size_bytes: Optional[int] = None,
|
| 425 |
) -> str:
|
| 426 |
"""
|
| 427 |
Register a file that was downloaded from ComfyUI.
|
| 428 |
+
Call this right after saving the file to media storage β ``dest_path`` is where it would
|
| 429 |
+
be on this disk (the key is its path relative to the upload root); ``storage_backend``
|
| 430 |
+
and ``size_bytes`` say where it actually went when that is not the local disk.
|
| 431 |
|
| 432 |
Returns the asset_id.
|
| 433 |
"""
|
|
|
|
| 438 |
except ValueError:
|
| 439 |
rel_path = str(abs_path)
|
| 440 |
|
| 441 |
+
if size_bytes is not None:
|
| 442 |
+
size = int(size_bytes)
|
| 443 |
+
else:
|
| 444 |
+
size = abs_path.stat().st_size if abs_path.exists() else 0
|
| 445 |
ext = abs_path.suffix.lower()
|
| 446 |
kind = "video" if ext in VIDEO_EXTS else "image"
|
| 447 |
mime = mimetypes.guess_type(str(abs_path))[0] or ""
|
|
|
|
| 459 |
feature=feature,
|
| 460 |
project_id=project_id,
|
| 461 |
user_id=user_id,
|
| 462 |
+
storage_backend=storage_backend or "local",
|
| 463 |
)
|
backend/app/background.py
CHANGED
|
@@ -128,15 +128,11 @@ def _get_image_and_size(url: str) -> Tuple[bytes, Tuple[int, int]]:
|
|
| 128 |
|
| 129 |
|
| 130 |
def _save_image_to_uploads(img_bytes: bytes, prefix: str = "bg", ext: str = "png") -> str:
|
| 131 |
-
"""Save image bytes to
|
| 132 |
-
|
| 133 |
-
upload_path = Path(UPLOAD_DIR)
|
| 134 |
-
upload_path.mkdir(parents=True, exist_ok=True)
|
| 135 |
-
|
| 136 |
-
file_path = upload_path / filename
|
| 137 |
-
with open(file_path, 'wb') as f:
|
| 138 |
-
f.write(img_bytes)
|
| 139 |
|
|
|
|
|
|
|
| 140 |
return f"/files/{filename}"
|
| 141 |
|
| 142 |
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def _save_image_to_uploads(img_bytes: bytes, prefix: str = "bg", ext: str = "png") -> str:
|
| 131 |
+
"""Save image bytes to media storage (a flat key, as before) and return the URL path."""
|
| 132 |
+
from .media_storage import get_storage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{ext}"
|
| 135 |
+
get_storage().put(filename, img_bytes)
|
| 136 |
return f"/files/{filename}"
|
| 137 |
|
| 138 |
|
backend/app/comfy.py
CHANGED
|
@@ -469,23 +469,10 @@ def _get_local_file_path(url: str) -> Path | None:
|
|
| 469 |
if not file_ref or '..' in file_ref:
|
| 470 |
return None
|
| 471 |
|
| 472 |
-
#
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
# 2) Try resolving as an asset_id via the file_assets DB table
|
| 478 |
-
try:
|
| 479 |
-
from .files import get_asset, _upload_root
|
| 480 |
-
asset = get_asset(file_ref)
|
| 481 |
-
if asset and asset.get("rel_path"):
|
| 482 |
-
asset_path = _upload_root() / asset["rel_path"]
|
| 483 |
-
if asset_path.exists():
|
| 484 |
-
return asset_path
|
| 485 |
-
except Exception:
|
| 486 |
-
pass
|
| 487 |
-
|
| 488 |
-
return None
|
| 489 |
except Exception:
|
| 490 |
return None
|
| 491 |
|
|
@@ -602,16 +589,10 @@ def _preprocess_image_paths(variables: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 602 |
elif value.startswith('/files/'):
|
| 603 |
try:
|
| 604 |
file_ref = value.replace('/files/', '', 1).split('?')[0]
|
| 605 |
-
#
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
from .files import get_asset, _upload_root
|
| 610 |
-
asset = get_asset(file_ref)
|
| 611 |
-
if asset and asset.get("rel_path"):
|
| 612 |
-
local_path = _upload_root() / asset["rel_path"]
|
| 613 |
-
except Exception:
|
| 614 |
-
pass
|
| 615 |
|
| 616 |
if local_path.exists():
|
| 617 |
# Copy to ComfyUI's input directory
|
|
|
|
| 469 |
if not file_ref or '..' in file_ref:
|
| 470 |
return None
|
| 471 |
|
| 472 |
+
# A flat key or a file_assets id β on this disk, or fetched from the configured
|
| 473 |
+
# storage into a local cache when the media lives elsewhere (files.py).
|
| 474 |
+
from .files import resolve_local_file
|
| 475 |
+
return resolve_local_file(file_ref)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
except Exception:
|
| 477 |
return None
|
| 478 |
|
|
|
|
| 589 |
elif value.startswith('/files/'):
|
| 590 |
try:
|
| 591 |
file_ref = value.replace('/files/', '', 1).split('?')[0]
|
| 592 |
+
# A flat key or a file_assets id, on this disk or fetched from the
|
| 593 |
+
# configured storage (files.py).
|
| 594 |
+
from .files import resolve_local_file
|
| 595 |
+
local_path = resolve_local_file(file_ref) or (Path(UPLOAD_DIR) / file_ref)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
if local_path.exists():
|
| 598 |
# Copy to ComfyUI's input directory
|
backend/app/download_script.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Where ``scripts/download.py`` is.
|
| 3 |
+
|
| 4 |
+
The model installer (POST /models/install, Creator Studio bundles) runs the repo's downloader
|
| 5 |
+
as a subprocess. In the repo it is two levels above this package; a packaged image has no
|
| 6 |
+
repo around it β the Hugging Face Space copies ``backend/app`` to ``/app/app``, so the old
|
| 7 |
+
``parents[2] / "scripts"`` was ``/scripts/download.py`` and every install there failed with
|
| 8 |
+
"Download script not found", Ollama pulls included. An image ships a copy next to the
|
| 9 |
+
package instead (``/app/scripts/download.py``), and ``HOMEPILOT_DOWNLOAD_SCRIPT`` names any
|
| 10 |
+
other location.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def download_script(package_dir: Optional[Path] = None) -> Path:
|
| 22 |
+
"""The downloader's path: the configured one, else the first that exists, else the repo's."""
|
| 23 |
+
configured = (os.getenv("HOMEPILOT_DOWNLOAD_SCRIPT") or "").strip()
|
| 24 |
+
if configured:
|
| 25 |
+
return Path(configured).expanduser()
|
| 26 |
+
pkg = (package_dir or _PACKAGE_DIR).resolve() # <repo>/backend/app, or /app/app
|
| 27 |
+
candidates = [pkg.parents[1] / "scripts" / "download.py", # <repo>/scripts
|
| 28 |
+
pkg.parent / "scripts" / "download.py"] # /app/scripts (packaged image)
|
| 29 |
+
for path in candidates:
|
| 30 |
+
if path.is_file():
|
| 31 |
+
return path
|
| 32 |
+
return candidates[0]
|
backend/app/files.py
CHANGED
|
@@ -15,18 +15,24 @@ ADDITIVE ONLY β does not modify any existing module.
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
| 18 |
import mimetypes
|
| 19 |
import os
|
| 20 |
import sqlite3
|
|
|
|
| 21 |
import uuid
|
| 22 |
from pathlib import Path
|
| 23 |
from typing import Any, Dict, Optional
|
| 24 |
|
| 25 |
from fastapi import APIRouter, Cookie, Header, HTTPException, Query, UploadFile, File
|
| 26 |
-
from fastapi.responses import FileResponse, JSONResponse
|
| 27 |
|
| 28 |
-
from .config import UPLOAD_DIR, MAX_UPLOAD_MB
|
| 29 |
-
from .
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# Cache headers for served files β prevents image flickering on React re-renders.
|
| 32 |
# Uploaded images are immutable (content-addressed), so long max-age is safe.
|
|
@@ -46,31 +52,30 @@ def _db() -> sqlite3.Connection:
|
|
| 46 |
|
| 47 |
|
| 48 |
def _upload_root() -> Path:
|
| 49 |
-
"""
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
p = Path(__file__).resolve().parents[1] / "data" / "uploads"
|
| 53 |
p.mkdir(parents=True, exist_ok=True)
|
| 54 |
return p
|
| 55 |
|
| 56 |
|
| 57 |
-
def
|
| 58 |
-
"""
|
| 59 |
-
|
| 60 |
-
Layout: users/<user_id>/{uploads|images|projects/<project_id>}
|
| 61 |
-
"""
|
| 62 |
-
root = _upload_root() / "users" / user_id
|
| 63 |
if kind == "upload":
|
| 64 |
-
|
| 65 |
elif kind == "image":
|
| 66 |
-
|
| 67 |
elif kind == "project_asset":
|
| 68 |
-
|
| 69 |
-
project_id = "unknown"
|
| 70 |
-
p = root / "projects" / project_id
|
| 71 |
else:
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
|
|
|
|
|
|
|
|
|
| 74 |
p.mkdir(parents=True, exist_ok=True)
|
| 75 |
return p
|
| 76 |
|
|
@@ -125,6 +130,30 @@ def _resolve_user(
|
|
| 125 |
# Asset CRUD
|
| 126 |
# ---------------------------------------------------------------------------
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def insert_asset(
|
| 129 |
user_id: str,
|
| 130 |
kind: str,
|
|
@@ -134,74 +163,103 @@ def insert_asset(
|
|
| 134 |
original_name: str = "",
|
| 135 |
project_id: str = "",
|
| 136 |
conversation_id: str = "",
|
|
|
|
|
|
|
|
|
|
| 137 |
) -> str:
|
| 138 |
-
"""Register a file in file_assets. Returns the asset_id.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
asset_id = f"f_{uuid.uuid4().hex[:20]}"
|
|
|
|
|
|
|
| 140 |
con = _db()
|
|
|
|
| 141 |
cur = con.cursor()
|
| 142 |
cur.execute(
|
| 143 |
"""
|
| 144 |
INSERT INTO file_assets(
|
| 145 |
id, user_id, kind, rel_path, mime, size_bytes,
|
| 146 |
-
original_name, project_id, conversation_id
|
| 147 |
)
|
| 148 |
-
VALUES (?,?,?,?,?,?,?,?,?)
|
| 149 |
""",
|
| 150 |
(asset_id, user_id, kind, rel_path, mime or "",
|
| 151 |
int(size_bytes or 0), original_name or "",
|
| 152 |
-
project_id or "", conversation_id or ""),
|
| 153 |
)
|
| 154 |
con.commit()
|
| 155 |
con.close()
|
| 156 |
return asset_id
|
| 157 |
|
| 158 |
|
| 159 |
-
def get_asset(asset_id: str) -> Optional[Dict[str, Any]]:
|
| 160 |
-
"""Look up a file asset by id."""
|
| 161 |
con = _db()
|
| 162 |
cur = con.cursor()
|
| 163 |
cur.execute("SELECT * FROM file_assets WHERE id = ?", (asset_id,))
|
| 164 |
row = cur.fetchone()
|
| 165 |
con.close()
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
"""
|
| 171 |
-
Delete a
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
"""
|
| 174 |
if not asset_id:
|
| 175 |
return False
|
| 176 |
con = _db()
|
|
|
|
| 177 |
cur = con.cursor()
|
| 178 |
-
cur.execute("SELECT id, rel_path FROM file_assets WHERE id = ?", (asset_id,))
|
| 179 |
row = cur.fetchone()
|
| 180 |
if not row:
|
| 181 |
con.close()
|
| 182 |
return False
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
if abs_path.is_file():
|
| 192 |
-
abs_path.unlink()
|
| 193 |
-
# Clean up empty parent dirs (but not the root)
|
| 194 |
-
parent = abs_path.parent
|
| 195 |
-
try:
|
| 196 |
-
if parent != root and not any(parent.iterdir()):
|
| 197 |
-
parent.rmdir()
|
| 198 |
-
except Exception:
|
| 199 |
-
pass
|
| 200 |
-
except Exception as e:
|
| 201 |
-
print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
|
| 202 |
-
|
| 203 |
-
# Delete the DB record
|
| 204 |
-
cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,))
|
| 205 |
removed = cur.rowcount > 0
|
| 206 |
con.commit()
|
| 207 |
con.close()
|
|
@@ -224,13 +282,9 @@ def save_generated_image_as_asset(
|
|
| 224 |
Returns dict with asset_id and url (/files/<asset_id>).
|
| 225 |
"""
|
| 226 |
ext = "png" if "png" in (mime or "") else "jpg"
|
| 227 |
-
folder = _ensure_user_dir(user_id, "image", project_id=project_id)
|
| 228 |
-
|
| 229 |
fname = f"{uuid.uuid4().hex}.{ext}"
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
rel_path = str(abs_path.relative_to(_upload_root()))
|
| 234 |
|
| 235 |
asset_id = insert_asset(
|
| 236 |
user_id=user_id,
|
|
@@ -241,9 +295,10 @@ def save_generated_image_as_asset(
|
|
| 241 |
original_name=fname,
|
| 242 |
project_id=project_id,
|
| 243 |
conversation_id=conversation_id,
|
|
|
|
| 244 |
)
|
| 245 |
|
| 246 |
-
return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png"}
|
| 247 |
|
| 248 |
|
| 249 |
def save_generated_media_as_asset(
|
|
@@ -259,21 +314,21 @@ def save_generated_media_as_asset(
|
|
| 259 |
extension = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip())
|
| 260 |
allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
|
| 261 |
extension = extension if extension in allowed_extensions else ".bin"
|
| 262 |
-
folder = _ensure_user_dir(user_id, safe_kind, project_id=project_id)
|
| 263 |
filename = f"{uuid.uuid4().hex}{extension}"
|
| 264 |
-
|
| 265 |
-
|
| 266 |
asset_id = insert_asset(
|
| 267 |
user_id=user_id,
|
| 268 |
kind=safe_kind,
|
| 269 |
-
rel_path=
|
| 270 |
mime=mime or "application/octet-stream",
|
| 271 |
size_bytes=len(media_bytes),
|
| 272 |
original_name=filename,
|
| 273 |
project_id=project_id,
|
| 274 |
conversation_id=conversation_id,
|
|
|
|
| 275 |
)
|
| 276 |
-
return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
|
| 277 |
|
| 278 |
|
| 279 |
# ---------------------------------------------------------------------------
|
|
@@ -442,45 +497,32 @@ def delete_conversation_media(conversation_id: str) -> int:
|
|
| 442 |
projects/<id>/persona/appearance/).
|
| 443 |
"""
|
| 444 |
con = _db()
|
|
|
|
| 445 |
cur = con.cursor()
|
| 446 |
cur.execute(
|
| 447 |
-
"SELECT id, rel_path FROM file_assets WHERE conversation_id = ?",
|
| 448 |
(conversation_id,),
|
| 449 |
)
|
| 450 |
rows = cur.fetchall()
|
| 451 |
-
root = _upload_root()
|
| 452 |
deleted = 0
|
| 453 |
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
try:
|
| 461 |
-
if abs_path.is_file():
|
| 462 |
-
abs_path.unlink()
|
| 463 |
-
deleted += 1
|
| 464 |
-
# Clean up empty parent dirs (but not the root)
|
| 465 |
-
parent = abs_path.parent
|
| 466 |
-
try:
|
| 467 |
-
if parent != root and not any(parent.iterdir()):
|
| 468 |
-
parent.rmdir()
|
| 469 |
-
except Exception:
|
| 470 |
-
pass
|
| 471 |
-
except Exception as e:
|
| 472 |
-
print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
|
| 473 |
-
|
| 474 |
-
# Delete all asset records for this conversation
|
| 475 |
-
if rows:
|
| 476 |
-
cur.execute(
|
| 477 |
-
"DELETE FROM file_assets WHERE conversation_id = ?",
|
| 478 |
-
(conversation_id,),
|
| 479 |
-
)
|
| 480 |
con.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
con.close()
|
| 483 |
-
print(f"[CLEANUP] Deleted {deleted} files for conversation {conversation_id}")
|
| 484 |
return deleted
|
| 485 |
|
| 486 |
|
|
@@ -562,24 +604,27 @@ async def upload_file(
|
|
| 562 |
if len(data) > max_bytes:
|
| 563 |
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_MB}MB)")
|
| 564 |
|
| 565 |
-
# Choose extension
|
| 566 |
original_name = file.filename or ""
|
| 567 |
content_type = (file.content_type or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
ext = ""
|
| 569 |
if original_name and "." in original_name:
|
| 570 |
ext = original_name.rsplit(".", 1)[1].lower()
|
| 571 |
if not ext:
|
| 572 |
ext = (mimetypes.guess_extension(content_type) or "").lstrip(".") or "bin"
|
| 573 |
|
| 574 |
-
# Write
|
| 575 |
-
folder = _ensure_user_dir(user["id"], kind, project_id=project_id)
|
| 576 |
fname = f"{uuid.uuid4().hex}.{ext}"
|
| 577 |
-
|
| 578 |
-
|
| 579 |
|
| 580 |
# Store metadata
|
| 581 |
-
|
| 582 |
-
mime = content_type or mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
| 583 |
asset_id = insert_asset(
|
| 584 |
user_id=user["id"],
|
| 585 |
kind=kind,
|
|
@@ -589,11 +634,43 @@ async def upload_file(
|
|
| 589 |
original_name=original_name,
|
| 590 |
project_id=project_id,
|
| 591 |
conversation_id=conversation_id,
|
|
|
|
| 592 |
)
|
| 593 |
|
| 594 |
return {"ok": True, "asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
|
| 595 |
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
# ---------------------------------------------------------------------------
|
| 598 |
# Secure Download / Serve Endpoint
|
| 599 |
# ---------------------------------------------------------------------------
|
|
@@ -610,6 +687,52 @@ def _is_public_file(name: str) -> bool:
|
|
| 610 |
return name.startswith(("avatar_", "outfit_", "thumb_"))
|
| 611 |
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
def _find_persona_file(filename: str) -> Optional[Path]:
|
| 614 |
"""
|
| 615 |
Search all project appearance directories for a file by name.
|
|
@@ -628,49 +751,128 @@ def _find_persona_file(filename: str) -> Optional[Path]:
|
|
| 628 |
return None
|
| 629 |
|
| 630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
@router.get("/files/{asset_id}")
|
| 632 |
def download_file(
|
| 633 |
asset_id: str,
|
| 634 |
authorization: str = Header(default=""),
|
| 635 |
homepilot_session: Optional[str] = Cookie(default=None),
|
| 636 |
token: Optional[str] = Query(default=None, alias="token"),
|
|
|
|
|
|
|
| 637 |
):
|
| 638 |
"""
|
| 639 |
-
Secure file serving.
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
|
|
|
| 644 |
"""
|
| 645 |
-
|
| 646 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
safe = Path(asset_id)
|
| 648 |
abs_path = _upload_root() / safe
|
| 649 |
if abs_path.exists() and abs_path.is_file():
|
| 650 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
| 651 |
return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 652 |
-
|
| 653 |
|
| 654 |
user = _resolve_user(authorization, homepilot_session, token_param=token)
|
| 655 |
if not user:
|
| 656 |
raise HTTPException(401, "Authentication required")
|
| 657 |
|
| 658 |
-
asset = get_asset(asset_id)
|
| 659 |
if asset:
|
| 660 |
if asset.get("user_id") != user["id"]:
|
| 661 |
raise HTTPException(404, "Not found")
|
| 662 |
-
|
| 663 |
-
abs_path = _upload_root() / asset["rel_path"]
|
| 664 |
-
if not abs_path.exists():
|
| 665 |
-
raise HTTPException(404, "Not found")
|
| 666 |
-
|
| 667 |
-
filename = asset.get("original_name") or os.path.basename(str(abs_path))
|
| 668 |
-
return FileResponse(
|
| 669 |
-
path=str(abs_path),
|
| 670 |
-
media_type=asset.get("mime") or "application/octet-stream",
|
| 671 |
-
filename=filename,
|
| 672 |
-
headers=_FILE_CACHE_HEADERS,
|
| 673 |
-
)
|
| 674 |
|
| 675 |
# Fallback: serve from flat uploads dir (avatars, legacy files)
|
| 676 |
safe = Path(asset_id)
|
|
@@ -681,7 +883,7 @@ def download_file(
|
|
| 681 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
| 682 |
return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 683 |
|
| 684 |
-
|
| 685 |
|
| 686 |
|
| 687 |
# ---------------------------------------------------------------------------
|
|
@@ -696,19 +898,18 @@ def download_file_legacy(
|
|
| 696 |
token: Optional[str] = Query(default=None, alias="token"),
|
| 697 |
):
|
| 698 |
"""
|
| 699 |
-
Fallback for legacy /files/<
|
| 700 |
-
|
| 701 |
-
|
| 702 |
Supports ?token=<session_token> for <img> tags that can't set headers.
|
| 703 |
-
Exception: persona appearance images (avatar_*, outfit_*, thumb_*) are public.
|
| 704 |
"""
|
| 705 |
# Prevent path traversal
|
| 706 |
-
safe = Path(subpath)
|
| 707 |
if ".." in safe.parts:
|
| 708 |
raise HTTPException(400, "Invalid path")
|
| 709 |
|
| 710 |
-
# Public persona
|
| 711 |
-
if
|
| 712 |
abs_path = _upload_root() / safe
|
| 713 |
if abs_path.exists() and abs_path.is_file():
|
| 714 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
|
@@ -719,18 +920,20 @@ def download_file_legacy(
|
|
| 719 |
if found:
|
| 720 |
mime = mimetypes.guess_type(str(found))[0] or "application/octet-stream"
|
| 721 |
return FileResponse(path=str(found), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 722 |
-
|
| 723 |
|
| 724 |
user = _resolve_user(authorization, homepilot_session, token_param=token)
|
| 725 |
if not user:
|
| 726 |
raise HTTPException(401, "Authentication required")
|
|
|
|
|
|
|
| 727 |
|
| 728 |
abs_path = _upload_root() / safe
|
| 729 |
if not abs_path.exists() or not abs_path.is_file():
|
| 730 |
# Fallback: search project appearance dirs for the file by name
|
| 731 |
found = _find_persona_file(safe.name) if "persona" in safe.parts else None
|
| 732 |
if not found:
|
| 733 |
-
|
| 734 |
abs_path = found
|
| 735 |
|
| 736 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
|
|
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import hashlib
|
| 19 |
import mimetypes
|
| 20 |
import os
|
| 21 |
import sqlite3
|
| 22 |
+
import time
|
| 23 |
import uuid
|
| 24 |
from pathlib import Path
|
| 25 |
from typing import Any, Dict, Optional
|
| 26 |
|
| 27 |
from fastapi import APIRouter, Cookie, Header, HTTPException, Query, UploadFile, File
|
| 28 |
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 29 |
|
| 30 |
+
from .config import UPLOAD_DIR, MAX_UPLOAD_MB # noqa: F401 (UPLOAD_DIR: kept for importers)
|
| 31 |
+
from . import media_links, media_ops, media_quota, media_validate
|
| 32 |
+
from .media_storage import (StorageKeyError, backend_name, get_storage, local_storage, normalize_key,
|
| 33 |
+
storage_for_read, storage_named)
|
| 34 |
+
from .media_storage import upload_root as _storage_root
|
| 35 |
+
from .storage import _get_db_path, ensure_file_assets_columns
|
| 36 |
|
| 37 |
# Cache headers for served files β prevents image flickering on React re-renders.
|
| 38 |
# Uploaded images are immutable (content-addressed), so long max-age is safe.
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def _upload_root() -> Path:
|
| 55 |
+
"""The local upload root (media_storage.upload_root), created. The legacy path-based
|
| 56 |
+
routes below read this disk directly; everything registered goes through get_storage()."""
|
| 57 |
+
p = _storage_root()
|
|
|
|
| 58 |
p.mkdir(parents=True, exist_ok=True)
|
| 59 |
return p
|
| 60 |
|
| 61 |
|
| 62 |
+
def _user_key(user_id: str, kind: str, project_id: str = "") -> str:
|
| 63 |
+
"""Where a user's files go, as a storage-key prefix:
|
| 64 |
+
users/<user_id>/{uploads|images|projects/<project_id>|misc}."""
|
|
|
|
|
|
|
|
|
|
| 65 |
if kind == "upload":
|
| 66 |
+
sub = "uploads"
|
| 67 |
elif kind == "image":
|
| 68 |
+
sub = "images"
|
| 69 |
elif kind == "project_asset":
|
| 70 |
+
sub = f"projects/{project_id or 'unknown'}"
|
|
|
|
|
|
|
| 71 |
else:
|
| 72 |
+
sub = "misc"
|
| 73 |
+
return f"users/{user_id}/{sub}"
|
| 74 |
+
|
| 75 |
|
| 76 |
+
def _ensure_user_dir(user_id: str, kind: str, project_id: str = "") -> Path:
|
| 77 |
+
"""The per-user directory on the local disk (kept for callers that still write paths)."""
|
| 78 |
+
p = _upload_root() / _user_key(user_id, kind, project_id)
|
| 79 |
p.mkdir(parents=True, exist_ok=True)
|
| 80 |
return p
|
| 81 |
|
|
|
|
| 130 |
# Asset CRUD
|
| 131 |
# ---------------------------------------------------------------------------
|
| 132 |
|
| 133 |
+
_backend_column_checked = False
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _ensure_backend_column(con: sqlite3.Connection) -> None:
|
| 137 |
+
"""The storage_backend column is added at startup (storage.init_db); a database opened
|
| 138 |
+
without that β a test, a script β gets it here, once per process."""
|
| 139 |
+
global _backend_column_checked
|
| 140 |
+
if not _backend_column_checked:
|
| 141 |
+
ensure_file_assets_columns(con)
|
| 142 |
+
_backend_column_checked = True
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _now() -> str:
|
| 146 |
+
"""UTC, the way SQLite's CURRENT_TIMESTAMP writes it."""
|
| 147 |
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _store_for(backend: Optional[str], key: str):
|
| 151 |
+
"""The storage holding ``key``: the one a registry row names, or β for a key with no row β
|
| 152 |
+
whichever has it (media_storage.storage_for_read). None when the row names a backend this
|
| 153 |
+
HomePilot is not configured for."""
|
| 154 |
+
return storage_named(backend) if backend else storage_for_read(key)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
def insert_asset(
|
| 158 |
user_id: str,
|
| 159 |
kind: str,
|
|
|
|
| 163 |
original_name: str = "",
|
| 164 |
project_id: str = "",
|
| 165 |
conversation_id: str = "",
|
| 166 |
+
storage_backend: Optional[str] = None,
|
| 167 |
+
sha256: str = "",
|
| 168 |
+
visibility: str = "private",
|
| 169 |
) -> str:
|
| 170 |
+
"""Register a file in file_assets. Returns the asset_id.
|
| 171 |
+
|
| 172 |
+
``storage_backend`` is the media backend that holds the bytes β the configured one when
|
| 173 |
+
not given, which is where every writer in this module just put them. ``sha256`` is of
|
| 174 |
+
the bytes as written ('' when the caller did not hash them); ``visibility`` is
|
| 175 |
+
'private' (owner, signed link or API key) or 'public' (anyone)."""
|
| 176 |
asset_id = f"f_{uuid.uuid4().hex[:20]}"
|
| 177 |
+
backend = (storage_backend or get_storage().name).strip().lower()
|
| 178 |
+
visibility = "public" if visibility == "public" else "private"
|
| 179 |
con = _db()
|
| 180 |
+
_ensure_backend_column(con)
|
| 181 |
cur = con.cursor()
|
| 182 |
cur.execute(
|
| 183 |
"""
|
| 184 |
INSERT INTO file_assets(
|
| 185 |
id, user_id, kind, rel_path, mime, size_bytes,
|
| 186 |
+
original_name, project_id, conversation_id, storage_backend, sha256, visibility
|
| 187 |
)
|
| 188 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
| 189 |
""",
|
| 190 |
(asset_id, user_id, kind, rel_path, mime or "",
|
| 191 |
int(size_bytes or 0), original_name or "",
|
| 192 |
+
project_id or "", conversation_id or "", backend, sha256 or "", visibility),
|
| 193 |
)
|
| 194 |
con.commit()
|
| 195 |
con.close()
|
| 196 |
return asset_id
|
| 197 |
|
| 198 |
|
| 199 |
+
def get_asset(asset_id: str, include_deleted: bool = False) -> Optional[Dict[str, Any]]:
|
| 200 |
+
"""Look up a file asset by id. One in the trash (deleted_at set) is None unless asked for."""
|
| 201 |
con = _db()
|
| 202 |
cur = con.cursor()
|
| 203 |
cur.execute("SELECT * FROM file_assets WHERE id = ?", (asset_id,))
|
| 204 |
row = cur.fetchone()
|
| 205 |
con.close()
|
| 206 |
+
if not row:
|
| 207 |
+
return None
|
| 208 |
+
asset = dict(row)
|
| 209 |
+
if asset.get("deleted_at") and not include_deleted:
|
| 210 |
+
return None
|
| 211 |
+
return asset
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _remove_object(rel_path: str, backend: Optional[str] = None) -> bool:
|
| 215 |
+
"""Remove the bytes behind a file_assets row, from the backend the row names. Immediate,
|
| 216 |
+
as before: soft deletion with deferred removal is the multi-user phase
|
| 217 |
+
(docs/design/MEDIA_STORAGE.md)."""
|
| 218 |
+
if not rel_path:
|
| 219 |
+
return False
|
| 220 |
+
try:
|
| 221 |
+
storage = _store_for(backend, rel_path)
|
| 222 |
+
if storage is None:
|
| 223 |
+
print(f"[CLEANUP] {rel_path!r} is in media backend {backend!r}, which this HomePilot is not "
|
| 224 |
+
f"configured for (MEDIA_STORAGE_BACKEND={backend_name()}); the object was left in place.")
|
| 225 |
+
return False
|
| 226 |
+
return storage.delete(rel_path)
|
| 227 |
+
except Exception as e:
|
| 228 |
+
print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
|
| 229 |
+
return False
|
| 230 |
|
| 231 |
|
| 232 |
+
def _trash_on() -> bool:
|
| 233 |
+
"""Deleted assets go to the trash first (MEDIA_TRASH_RETENTION_DAYS, 7 by default); 0 means
|
| 234 |
+
bytes and row go at once, as before Phase 4."""
|
| 235 |
+
return media_ops.trash_retention_days() > 0
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def delete_asset_and_file(asset_id: str, *, purge: bool = False) -> bool:
|
| 239 |
"""
|
| 240 |
+
Delete a file asset. The row is marked deleted and disappears from every read; its bytes
|
| 241 |
+
stay until the trash is purged (media_ops.purge_trash, run by the backend), so a mistaken
|
| 242 |
+
delete is recoverable for the retention window. ``purge=True`` β or a retention of 0 β
|
| 243 |
+
removes the bytes and the row now. Returns True if the record was deleted either way.
|
| 244 |
"""
|
| 245 |
if not asset_id:
|
| 246 |
return False
|
| 247 |
con = _db()
|
| 248 |
+
_ensure_backend_column(con)
|
| 249 |
cur = con.cursor()
|
| 250 |
+
cur.execute("SELECT id, rel_path, storage_backend, deleted_at FROM file_assets WHERE id = ?", (asset_id,))
|
| 251 |
row = cur.fetchone()
|
| 252 |
if not row:
|
| 253 |
con.close()
|
| 254 |
return False
|
| 255 |
|
| 256 |
+
if purge or not _trash_on():
|
| 257 |
+
_remove_object(row["rel_path"], row["storage_backend"] or "local")
|
| 258 |
+
cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,))
|
| 259 |
+
else:
|
| 260 |
+
stamp = _now()
|
| 261 |
+
cur.execute("UPDATE file_assets SET deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?",
|
| 262 |
+
(stamp, stamp, asset_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
removed = cur.rowcount > 0
|
| 264 |
con.commit()
|
| 265 |
con.close()
|
|
|
|
| 282 |
Returns dict with asset_id and url (/files/<asset_id>).
|
| 283 |
"""
|
| 284 |
ext = "png" if "png" in (mime or "") else "jpg"
|
|
|
|
|
|
|
| 285 |
fname = f"{uuid.uuid4().hex}.{ext}"
|
| 286 |
+
rel_path = f"{_user_key(user_id, 'image', project_id=project_id)}/{fname}"
|
| 287 |
+
get_storage().put(rel_path, image_bytes)
|
|
|
|
|
|
|
| 288 |
|
| 289 |
asset_id = insert_asset(
|
| 290 |
user_id=user_id,
|
|
|
|
| 295 |
original_name=fname,
|
| 296 |
project_id=project_id,
|
| 297 |
conversation_id=conversation_id,
|
| 298 |
+
sha256=hashlib.sha256(image_bytes).hexdigest(),
|
| 299 |
)
|
| 300 |
|
| 301 |
+
return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png", "rel_path": rel_path}
|
| 302 |
|
| 303 |
|
| 304 |
def save_generated_media_as_asset(
|
|
|
|
| 314 |
extension = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip())
|
| 315 |
allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
|
| 316 |
extension = extension if extension in allowed_extensions else ".bin"
|
|
|
|
| 317 |
filename = f"{uuid.uuid4().hex}{extension}"
|
| 318 |
+
rel_path = f"{_user_key(user_id, safe_kind, project_id=project_id)}/{filename}"
|
| 319 |
+
get_storage().put(rel_path, media_bytes)
|
| 320 |
asset_id = insert_asset(
|
| 321 |
user_id=user_id,
|
| 322 |
kind=safe_kind,
|
| 323 |
+
rel_path=rel_path,
|
| 324 |
mime=mime or "application/octet-stream",
|
| 325 |
size_bytes=len(media_bytes),
|
| 326 |
original_name=filename,
|
| 327 |
project_id=project_id,
|
| 328 |
conversation_id=conversation_id,
|
| 329 |
+
sha256=hashlib.sha256(media_bytes).hexdigest(),
|
| 330 |
)
|
| 331 |
+
return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime, "rel_path": rel_path}
|
| 332 |
|
| 333 |
|
| 334 |
# ---------------------------------------------------------------------------
|
|
|
|
| 497 |
projects/<id>/persona/appearance/).
|
| 498 |
"""
|
| 499 |
con = _db()
|
| 500 |
+
_ensure_backend_column(con)
|
| 501 |
cur = con.cursor()
|
| 502 |
cur.execute(
|
| 503 |
+
"SELECT id, rel_path, storage_backend FROM file_assets WHERE conversation_id = ? AND deleted_at IS NULL",
|
| 504 |
(conversation_id,),
|
| 505 |
)
|
| 506 |
rows = cur.fetchall()
|
|
|
|
| 507 |
deleted = 0
|
| 508 |
|
| 509 |
+
if _trash_on():
|
| 510 |
+
# To the trash: gone from every read now, bytes removed when the retention has passed.
|
| 511 |
+
stamp = _now()
|
| 512 |
+
for row in rows:
|
| 513 |
+
cur.execute("UPDATE file_assets SET deleted_at = ?, updated_at = ? WHERE id = ?", (stamp, stamp, row["id"]))
|
| 514 |
+
deleted += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
con.commit()
|
| 516 |
+
else:
|
| 517 |
+
for row in rows:
|
| 518 |
+
if _remove_object(row["rel_path"], row["storage_backend"] or "local"):
|
| 519 |
+
deleted += 1
|
| 520 |
+
if rows:
|
| 521 |
+
cur.execute("DELETE FROM file_assets WHERE conversation_id = ?", (conversation_id,))
|
| 522 |
+
con.commit()
|
| 523 |
|
| 524 |
con.close()
|
| 525 |
+
print(f"[CLEANUP] {'Trashed' if _trash_on() else 'Deleted'} {deleted} files for conversation {conversation_id}")
|
| 526 |
return deleted
|
| 527 |
|
| 528 |
|
|
|
|
| 604 |
if len(data) > max_bytes:
|
| 605 |
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_MB}MB)")
|
| 606 |
|
|
|
|
| 607 |
original_name = file.filename or ""
|
| 608 |
content_type = (file.content_type or "").lower()
|
| 609 |
+
# Before any byte is stored: what it claims to be, the owner's quota, the scanner if one is set.
|
| 610 |
+
media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type, original_name)
|
| 611 |
+
media_quota.check(user["id"], len(data))
|
| 612 |
+
media_validate.scan_bytes(data, original_name)
|
| 613 |
+
|
| 614 |
+
# Choose extension
|
| 615 |
ext = ""
|
| 616 |
if original_name and "." in original_name:
|
| 617 |
ext = original_name.rsplit(".", 1)[1].lower()
|
| 618 |
if not ext:
|
| 619 |
ext = (mimetypes.guess_extension(content_type) or "").lstrip(".") or "bin"
|
| 620 |
|
| 621 |
+
# Write to the user's area in media storage
|
|
|
|
| 622 |
fname = f"{uuid.uuid4().hex}.{ext}"
|
| 623 |
+
rel_path = f"{_user_key(user['id'], kind, project_id=project_id)}/{fname}"
|
| 624 |
+
get_storage().put(rel_path, data)
|
| 625 |
|
| 626 |
# Store metadata
|
| 627 |
+
mime = content_type or mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
|
|
|
| 628 |
asset_id = insert_asset(
|
| 629 |
user_id=user["id"],
|
| 630 |
kind=kind,
|
|
|
|
| 634 |
original_name=original_name,
|
| 635 |
project_id=project_id,
|
| 636 |
conversation_id=conversation_id,
|
| 637 |
+
sha256=hashlib.sha256(data).hexdigest(),
|
| 638 |
)
|
| 639 |
|
| 640 |
return {"ok": True, "asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
|
| 641 |
|
| 642 |
|
| 643 |
+
@router.get("/v1/files/usage")
|
| 644 |
+
def files_usage(
|
| 645 |
+
authorization: str = Header(default=""),
|
| 646 |
+
homepilot_session: Optional[str] = Cookie(default=None),
|
| 647 |
+
):
|
| 648 |
+
"""How much of their media quota the caller uses (quota_bytes 0: no limit)."""
|
| 649 |
+
user = _resolve_user(authorization, homepilot_session)
|
| 650 |
+
if not user:
|
| 651 |
+
raise HTTPException(401, "Authentication required")
|
| 652 |
+
return {"ok": True, **media_quota.usage(user["id"])}
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
@router.get("/v1/files/{asset_id}/link")
|
| 656 |
+
def file_link(
|
| 657 |
+
asset_id: str,
|
| 658 |
+
ttl: int = Query(default=media_links.DEFAULT_TTL_S, ge=1, le=media_links.MAX_TTL_S),
|
| 659 |
+
authorization: str = Header(default=""),
|
| 660 |
+
homepilot_session: Optional[str] = Cookie(default=None),
|
| 661 |
+
):
|
| 662 |
+
"""A short-lived link to one of the caller's files β for an <img> on another origin or a
|
| 663 |
+
share β that carries no account credential: /files/{id}?exp=β¦&sig=β¦"""
|
| 664 |
+
user = _resolve_user(authorization, homepilot_session)
|
| 665 |
+
if not user:
|
| 666 |
+
raise HTTPException(401, "Authentication required")
|
| 667 |
+
asset = get_asset(asset_id)
|
| 668 |
+
if not asset or asset.get("user_id") != user["id"]:
|
| 669 |
+
raise HTTPException(404, "Not found")
|
| 670 |
+
signed = media_links.sign(asset_id, ttl)
|
| 671 |
+
return {"ok": True, "asset_id": asset_id, "url": signed["url"], "expires_at": signed["exp"]}
|
| 672 |
+
|
| 673 |
+
|
| 674 |
# ---------------------------------------------------------------------------
|
| 675 |
# Secure Download / Serve Endpoint
|
| 676 |
# ---------------------------------------------------------------------------
|
|
|
|
| 687 |
return name.startswith(("avatar_", "outfit_", "thumb_"))
|
| 688 |
|
| 689 |
|
| 690 |
+
def _is_user_avatar(name: str) -> bool:
|
| 691 |
+
"""Some account's current profile picture: the login screen shows those before anyone is
|
| 692 |
+
signed in, so they are public by design."""
|
| 693 |
+
try:
|
| 694 |
+
con = _db()
|
| 695 |
+
row = con.execute("SELECT 1 FROM users WHERE avatar_url = ? LIMIT 1", (f"/files/{name}",)).fetchone()
|
| 696 |
+
con.close()
|
| 697 |
+
return row is not None
|
| 698 |
+
except Exception:
|
| 699 |
+
return False
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _is_public_flat_file(name: str) -> bool:
|
| 703 |
+
"""A flat file served without an account: a user's current avatar, or a persona visual that
|
| 704 |
+
also lives under a project's appearance folder. Any other avatar_*/outfit_*/thumb_* file
|
| 705 |
+
now needs an account, a signed link or the API key (audit item A1)."""
|
| 706 |
+
if not _is_public_file(name):
|
| 707 |
+
return False
|
| 708 |
+
return _is_user_avatar(name) or _find_persona_file(name) is not None
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def _is_persona_visual_path(parts: tuple) -> bool:
|
| 712 |
+
"""projects/<id>/persona/appearance/<avatar_|outfit_|thumb_β¦>: the persona's own pictures,
|
| 713 |
+
public by design (the gallery, OllaBridge's VR chatbot)."""
|
| 714 |
+
return (len(parts) >= 4 and parts[0] == "projects" and "persona" in parts and "appearance" in parts
|
| 715 |
+
and _is_public_file(parts[-1]))
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def _owns_path(user: Dict[str, Any], parts: tuple) -> bool:
|
| 719 |
+
"""A path under users/<id>/ belongs to that account alone (audit item A2); anything else on
|
| 720 |
+
the legacy routes is as before β flat root files, project folders."""
|
| 721 |
+
if len(parts) >= 2 and parts[0] == "users":
|
| 722 |
+
return parts[1] == str(user.get("id"))
|
| 723 |
+
return True
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _serve_asset(asset: Dict[str, Any]):
|
| 727 |
+
key = str(asset.get("rel_path") or "")
|
| 728 |
+
return _serve_object(
|
| 729 |
+
key,
|
| 730 |
+
asset.get("mime") or "application/octet-stream",
|
| 731 |
+
asset.get("original_name") or os.path.basename(key.replace("\\", "/")),
|
| 732 |
+
backend=asset.get("storage_backend") or "local",
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
|
| 736 |
def _find_persona_file(filename: str) -> Optional[Path]:
|
| 737 |
"""
|
| 738 |
Search all project appearance directories for a file by name.
|
|
|
|
| 751 |
return None
|
| 752 |
|
| 753 |
|
| 754 |
+
def _serve_object(key: str, mime: str, filename: str, backend: Optional[str] = None):
|
| 755 |
+
"""Serve an object from media storage β the backend its registry row names, or for a key
|
| 756 |
+
with no row whichever has it: straight off the disk when that is local (a FileResponse,
|
| 757 |
+
as before), streamed in 1 MB chunks otherwise."""
|
| 758 |
+
storage = _store_for(backend, key)
|
| 759 |
+
if storage is None:
|
| 760 |
+
print(f"[FILES] {key!r} is in media backend {backend!r}, which this HomePilot is not configured "
|
| 761 |
+
f"for (MEDIA_STORAGE_BACKEND={backend_name()}); configure it again to serve the file.")
|
| 762 |
+
raise HTTPException(404, "Not found")
|
| 763 |
+
try:
|
| 764 |
+
if not storage.exists(key):
|
| 765 |
+
raise HTTPException(404, "Not found")
|
| 766 |
+
local = storage.local_path(key)
|
| 767 |
+
if local is not None:
|
| 768 |
+
return FileResponse(path=str(local), media_type=mime, filename=filename, headers=_FILE_CACHE_HEADERS)
|
| 769 |
+
stream = storage.open(key)
|
| 770 |
+
except StorageKeyError:
|
| 771 |
+
raise HTTPException(404, "Not found")
|
| 772 |
+
|
| 773 |
+
def _chunks():
|
| 774 |
+
try:
|
| 775 |
+
while True:
|
| 776 |
+
chunk = stream.read(1024 * 1024)
|
| 777 |
+
if not chunk:
|
| 778 |
+
break
|
| 779 |
+
yield chunk
|
| 780 |
+
finally:
|
| 781 |
+
stream.close()
|
| 782 |
+
|
| 783 |
+
safe_name = filename.replace('"', "")
|
| 784 |
+
headers = {**_FILE_CACHE_HEADERS, "Content-Disposition": f'inline; filename="{safe_name}"'}
|
| 785 |
+
return StreamingResponse(_chunks(), media_type=mime, headers=headers)
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _object_or_404(key: str, filename: str):
|
| 789 |
+
"""A path-based route's last resort: the object under ``key`` in the configured storage,
|
| 790 |
+
when that is not this disk (an avatar or background written after a switch to S3)."""
|
| 791 |
+
try:
|
| 792 |
+
storage = get_storage()
|
| 793 |
+
if storage.name != "local" and storage.exists(key):
|
| 794 |
+
return _serve_object(key, mimetypes.guess_type(filename)[0] or "application/octet-stream", filename)
|
| 795 |
+
except Exception:
|
| 796 |
+
pass
|
| 797 |
+
raise HTTPException(404, "Not found")
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def resolve_local_file(file_ref: str) -> Optional[Path]:
|
| 801 |
+
"""A readable path on this disk for a ``/files/<ref>`` reference β a flat key or a
|
| 802 |
+
file_assets id β for code that hands files to ComfyUI or copies them: the object itself
|
| 803 |
+
when it is on the disk, else a copy fetched from the configured storage into
|
| 804 |
+
``<upload root>/.cache/media/<key>`` (kept; the same key resolves to the same path, and
|
| 805 |
+
re-fetched when the size differs). None when nothing is found."""
|
| 806 |
+
ref = (file_ref or "").split("?")[0].strip().strip("/")
|
| 807 |
+
if not ref or ".." in Path(ref.replace("\\", "/")).parts:
|
| 808 |
+
return None
|
| 809 |
+
candidates: list = [(ref, None)] # a flat key: whichever store has it
|
| 810 |
+
try:
|
| 811 |
+
asset = get_asset(ref)
|
| 812 |
+
except Exception:
|
| 813 |
+
asset = None
|
| 814 |
+
if asset and asset.get("rel_path"): # a registered asset: its own backend
|
| 815 |
+
candidates.insert(0, (str(asset["rel_path"]), asset.get("storage_backend") or "local"))
|
| 816 |
+
for key, backend in candidates:
|
| 817 |
+
try:
|
| 818 |
+
storage = _store_for(backend, key)
|
| 819 |
+
if storage is None or not storage.exists(key):
|
| 820 |
+
continue
|
| 821 |
+
local = storage.local_path(key)
|
| 822 |
+
if local is not None:
|
| 823 |
+
return local
|
| 824 |
+
cache_key = f".cache/media/{normalize_key(key)}"
|
| 825 |
+
disk = local_storage()
|
| 826 |
+
if not disk.exists(cache_key) or disk.size(cache_key) != storage.size(key):
|
| 827 |
+
with storage.open(key) as src:
|
| 828 |
+
disk.put(cache_key, src)
|
| 829 |
+
return disk.local_path(cache_key)
|
| 830 |
+
except StorageKeyError:
|
| 831 |
+
continue
|
| 832 |
+
except Exception as e:
|
| 833 |
+
print(f"[FILES] Could not resolve {key!r} to a local file: {e}")
|
| 834 |
+
continue
|
| 835 |
+
return None
|
| 836 |
+
|
| 837 |
+
|
| 838 |
@router.get("/files/{asset_id}")
|
| 839 |
def download_file(
|
| 840 |
asset_id: str,
|
| 841 |
authorization: str = Header(default=""),
|
| 842 |
homepilot_session: Optional[str] = Cookie(default=None),
|
| 843 |
token: Optional[str] = Query(default=None, alias="token"),
|
| 844 |
+
exp: Optional[str] = Query(default=None),
|
| 845 |
+
sig: Optional[str] = Query(default=None),
|
| 846 |
):
|
| 847 |
"""
|
| 848 |
+
Secure file serving β the endpoint behind <img src="/files/...">.
|
| 849 |
+
|
| 850 |
+
A registered asset is served to its owner (session, cookie, or ?token= for an <img> on
|
| 851 |
+
another origin), to anyone with a signed link for it (?exp=&sig=, GET /v1/files/{id}/link),
|
| 852 |
+
and to everyone when its visibility is public. A flat file is public only when it is a
|
| 853 |
+
user's current avatar or a persona visual; other flat files need an account.
|
| 854 |
"""
|
| 855 |
+
asset = get_asset(asset_id)
|
| 856 |
+
if asset and (asset.get("visibility") == "public" or media_links.verify(asset_id, exp, sig)):
|
| 857 |
+
return _serve_asset(asset)
|
| 858 |
+
|
| 859 |
+
# A public flat file: a login-screen avatar or a persona visual.
|
| 860 |
+
if not asset and _is_public_flat_file(asset_id):
|
| 861 |
safe = Path(asset_id)
|
| 862 |
abs_path = _upload_root() / safe
|
| 863 |
if abs_path.exists() and abs_path.is_file():
|
| 864 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
| 865 |
return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 866 |
+
return _object_or_404(asset_id, safe.name)
|
| 867 |
|
| 868 |
user = _resolve_user(authorization, homepilot_session, token_param=token)
|
| 869 |
if not user:
|
| 870 |
raise HTTPException(401, "Authentication required")
|
| 871 |
|
|
|
|
| 872 |
if asset:
|
| 873 |
if asset.get("user_id") != user["id"]:
|
| 874 |
raise HTTPException(404, "Not found")
|
| 875 |
+
return _serve_asset(asset)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
|
| 877 |
# Fallback: serve from flat uploads dir (avatars, legacy files)
|
| 878 |
safe = Path(asset_id)
|
|
|
|
| 883 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
| 884 |
return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 885 |
|
| 886 |
+
return _object_or_404(asset_id, safe.name)
|
| 887 |
|
| 888 |
|
| 889 |
# ---------------------------------------------------------------------------
|
|
|
|
| 898 |
token: Optional[str] = Query(default=None, alias="token"),
|
| 899 |
):
|
| 900 |
"""
|
| 901 |
+
Fallback for legacy /files/<path> URLs (pre-asset era) and persona visuals.
|
| 902 |
+
A persona's own pictures (projects/<id>/persona/appearance/β¦) are public; a path under
|
| 903 |
+
users/<id>/ is served to that account only; other paths need an account, as before.
|
| 904 |
Supports ?token=<session_token> for <img> tags that can't set headers.
|
|
|
|
| 905 |
"""
|
| 906 |
# Prevent path traversal
|
| 907 |
+
safe = Path(subpath.replace("\\", "/"))
|
| 908 |
if ".." in safe.parts:
|
| 909 |
raise HTTPException(400, "Invalid path")
|
| 910 |
|
| 911 |
+
# Public without an account: a persona's own pictures, or a public flat file.
|
| 912 |
+
if _is_persona_visual_path(safe.parts) or (len(safe.parts) == 1 and _is_public_flat_file(safe.name)):
|
| 913 |
abs_path = _upload_root() / safe
|
| 914 |
if abs_path.exists() and abs_path.is_file():
|
| 915 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
|
|
|
| 920 |
if found:
|
| 921 |
mime = mimetypes.guess_type(str(found))[0] or "application/octet-stream"
|
| 922 |
return FileResponse(path=str(found), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
|
| 923 |
+
return _object_or_404(subpath, safe.name)
|
| 924 |
|
| 925 |
user = _resolve_user(authorization, homepilot_session, token_param=token)
|
| 926 |
if not user:
|
| 927 |
raise HTTPException(401, "Authentication required")
|
| 928 |
+
if not _owns_path(user, safe.parts):
|
| 929 |
+
raise HTTPException(404, "Not found")
|
| 930 |
|
| 931 |
abs_path = _upload_root() / safe
|
| 932 |
if not abs_path.exists() or not abs_path.is_file():
|
| 933 |
# Fallback: search project appearance dirs for the file by name
|
| 934 |
found = _find_persona_file(safe.name) if "persona" in safe.parts else None
|
| 935 |
if not found:
|
| 936 |
+
return _object_or_404(subpath, safe.name)
|
| 937 |
abs_path = found
|
| 938 |
|
| 939 |
mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
|
backend/app/intent_guard.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""When a keyword shortcut may answer instead of the model.
|
| 2 |
+
|
| 3 |
+
Persona chats have deterministic fast paths ("show me your back" β the back view, "photo 2" β
|
| 4 |
+
the second photo) and prompt hints built from the same keywords. They scan for words such as
|
| 5 |
+
"back", "first", "front", "more" β which any long text contains. A user who pasted a work log
|
| 6 |
+
and asked "give a summary" got "I only have the front in my look for now. Here it is!": the
|
| 7 |
+
paste said "moved HomePilot back to 8002" and "the reply first showsβ¦".
|
| 8 |
+
|
| 9 |
+
A display command is short. Anything longer β a question with context, a pasted document, a
|
| 10 |
+
composed message β goes to the model, which reads all of it.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
MAX_COMMAND_CHARS = 160
|
| 15 |
+
MAX_COMMAND_WORDS = 24
|
| 16 |
+
MAX_COMMAND_LINES = 2
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def is_short_command(message: str) -> bool:
|
| 20 |
+
"""Whether ``message`` is short enough to be a display command a keyword shortcut may
|
| 21 |
+
answer: at most two lines, 160 characters and 24 words."""
|
| 22 |
+
text = (message or "").strip()
|
| 23 |
+
if not text:
|
| 24 |
+
return False
|
| 25 |
+
lines = [line for line in text.splitlines() if line.strip()]
|
| 26 |
+
if len(lines) > MAX_COMMAND_LINES:
|
| 27 |
+
return False
|
| 28 |
+
return len(text) <= MAX_COMMAND_CHARS and len(text.split()) <= MAX_COMMAND_WORDS
|
backend/app/inventory.py
CHANGED
|
@@ -214,16 +214,23 @@ def _db_list_project_files(project_id: str, *, user_id: str = "") -> List[Dict[s
|
|
| 214 |
if not conn:
|
| 215 |
return []
|
| 216 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
if user_id:
|
| 218 |
cur = conn.execute(
|
| 219 |
"SELECT id, kind, rel_path, mime, size_bytes, original_name "
|
| 220 |
-
"FROM file_assets WHERE project_id = ? AND user_id = ?",
|
| 221 |
(project_id, user_id),
|
| 222 |
)
|
| 223 |
else:
|
| 224 |
cur = conn.execute(
|
| 225 |
"SELECT id, kind, rel_path, mime, size_bytes, original_name "
|
| 226 |
-
"FROM file_assets WHERE project_id = ?",
|
| 227 |
(project_id,),
|
| 228 |
)
|
| 229 |
out: List[Dict[str, Any]] = []
|
|
|
|
| 214 |
if not conn:
|
| 215 |
return []
|
| 216 |
try:
|
| 217 |
+
# Files in the trash (deleted_at set) are not inventory; a database from before that
|
| 218 |
+
# column is read as it was.
|
| 219 |
+
try:
|
| 220 |
+
conn.execute("SELECT deleted_at FROM file_assets LIMIT 0")
|
| 221 |
+
trash = " AND deleted_at IS NULL"
|
| 222 |
+
except sqlite3.OperationalError:
|
| 223 |
+
trash = ""
|
| 224 |
if user_id:
|
| 225 |
cur = conn.execute(
|
| 226 |
"SELECT id, kind, rel_path, mime, size_bytes, original_name "
|
| 227 |
+
f"FROM file_assets WHERE project_id = ? AND user_id = ?{trash}",
|
| 228 |
(project_id, user_id),
|
| 229 |
)
|
| 230 |
else:
|
| 231 |
cur = conn.execute(
|
| 232 |
"SELECT id, kind, rel_path, mime, size_bytes, original_name "
|
| 233 |
+
f"FROM file_assets WHERE project_id = ?{trash}",
|
| 234 |
(project_id,),
|
| 235 |
)
|
| 236 |
out: List[Dict[str, Any]] = []
|
backend/app/main.py
CHANGED
|
@@ -126,6 +126,9 @@ from .multimodal import analyze_image, is_vision_intent, VISION_MODEL_PATTERNS
|
|
| 126 |
# OpenAI-compatible endpoint (additive β exposes personas as /v1/chat/completions)
|
| 127 |
from .openai_compat_endpoint import router as openai_compat_router
|
| 128 |
|
|
|
|
|
|
|
|
|
|
| 129 |
# ββ Access-log noise filter ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
# The Interactive runtime polls GET /v1/interactive/play/sessions/{id}/pending
|
| 131 |
# once per 1.5β5 s to pick up scene-render updates. Uvicorn logs every hit at
|
|
@@ -403,10 +406,18 @@ try:
|
|
| 403 |
import sys as _expert_sys
|
| 404 |
from pathlib import Path as _ExpertPath
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
/ "expert" / "backend" / "app" / "expert"
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
_expert_init = _expert_pkg_dir / "__init__.py"
|
| 411 |
if not _expert_init.is_file():
|
| 412 |
raise FileNotFoundError(f"Expert package not found at {_expert_init}")
|
|
@@ -1074,9 +1085,10 @@ def _extract_photo_number(msg: str) -> int | None:
|
|
| 1074 |
if num_match:
|
| 1075 |
return int(num_match.group(1))
|
| 1076 |
|
| 1077 |
-
# Match patterns like "the second one", "second photo", "the 2nd"
|
|
|
|
| 1078 |
for word, idx in _ORDINAL_MAP.items():
|
| 1079 |
-
if word
|
| 1080 |
return idx
|
| 1081 |
|
| 1082 |
# Match patterns like "the 2nd", "the 3rd", "the 1st"
|
|
@@ -1109,6 +1121,12 @@ def _is_photo_intent(msg: str) -> bool:
|
|
| 1109 |
if not m:
|
| 1110 |
return False
|
| 1111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
# ---- PASS-THROUGH: let the LLM handle questions and conversation ----
|
| 1113 |
# Questions starting with question words β LLM reasons
|
| 1114 |
if _re.search(r'^(?:what|explain|describe|tell|list|how many|which|why|who|where|when|do you|can you|have you|are you|could you|would you)\b', m):
|
|
@@ -1269,6 +1287,19 @@ def _startup() -> None:
|
|
| 1269 |
startup_reconcile()
|
| 1270 |
except Exception as e:
|
| 1271 |
print(f"Warning: Asset registry reconcile failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
# Files mount
|
| 1273 |
_ensure_static_mount()
|
| 1274 |
|
|
@@ -2465,9 +2496,16 @@ async def list_models(
|
|
| 2465 |
remedy_extra: Dict[str, Any] = {}
|
| 2466 |
if provider == "ollama":
|
| 2467 |
from .model_resolver import active_runtime_url as _active_runtime_url
|
|
|
|
| 2468 |
from .model_resolver import resolve as _resolve_model
|
| 2469 |
-
|
| 2470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2471 |
|
| 2472 |
# OpenAI-compatible endpoints (an OllaBridge gateway) also say *what* each model is β
|
| 2473 |
# a base model or an assistant built on one β which a picker needs to choose a
|
|
@@ -2864,17 +2902,9 @@ async def install_model(req: ModelInstallRequest) -> JSONResponse:
|
|
| 2864 |
logger.addHandler(handler)
|
| 2865 |
|
| 2866 |
try:
|
| 2867 |
-
|
| 2868 |
-
|
| 2869 |
-
|
| 2870 |
-
logger.error(f"Download script not found at {script_path}")
|
| 2871 |
-
return JSONResponse(
|
| 2872 |
-
status_code=500,
|
| 2873 |
-
content=_safe_err(
|
| 2874 |
-
"Download script not found. Please ensure scripts/download.py exists.",
|
| 2875 |
-
code="script_not_found",
|
| 2876 |
-
),
|
| 2877 |
-
)
|
| 2878 |
|
| 2879 |
logger.info(f"Starting model installation: provider={req.provider}, model={req.model_id}")
|
| 2880 |
|
|
@@ -2951,6 +2981,17 @@ async def install_model(req: ModelInstallRequest) -> JSONResponse:
|
|
| 2951 |
)
|
| 2952 |
|
| 2953 |
# Run download script for comfyui and civitai
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2954 |
# Pass Civitai API key via environment variable (secure - not visible in process list)
|
| 2955 |
env = os.environ.copy()
|
| 2956 |
if req.civitai_api_key and req.provider == "civitai":
|
|
@@ -3545,17 +3586,12 @@ def _resolve_local_file_to_upload_root(url: str, upload_root: Path) -> Optional[
|
|
| 3545 |
if ".." in file_ref:
|
| 3546 |
return None
|
| 3547 |
|
|
|
|
|
|
|
| 3548 |
local_path = upload_root / file_ref
|
| 3549 |
if not local_path.exists():
|
| 3550 |
-
|
| 3551 |
-
|
| 3552 |
-
from .files import get_asset, _upload_root
|
| 3553 |
-
asset = get_asset(file_ref)
|
| 3554 |
-
if asset and asset.get("rel_path"):
|
| 3555 |
-
local_path = _upload_root() / asset["rel_path"]
|
| 3556 |
-
except Exception:
|
| 3557 |
-
pass
|
| 3558 |
-
|
| 3559 |
if not local_path.exists():
|
| 3560 |
return None
|
| 3561 |
|
|
@@ -3638,12 +3674,20 @@ async def _download_comfy_image(url: str, upload_root: Path) -> str:
|
|
| 3638 |
) as client:
|
| 3639 |
resp = await client.get(url)
|
| 3640 |
resp.raise_for_status()
|
| 3641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3642 |
|
| 3643 |
# Register in asset registry for durability
|
| 3644 |
try:
|
| 3645 |
from .asset_registry import register_comfy_output
|
| 3646 |
-
register_comfy_output(filename=filename, dest_path=str(dest)
|
|
|
|
| 3647 |
except Exception as e:
|
| 3648 |
print(f"[ASSET_REGISTRY] Warning: failed to register {filename}: {e}")
|
| 3649 |
|
|
@@ -4299,36 +4343,45 @@ async def upload_project_item(
|
|
| 4299 |
except Exception:
|
| 4300 |
pass # Non-fatal: proceed with upload if dedup check fails
|
| 4301 |
|
| 4302 |
-
# Save
|
|
|
|
|
|
|
|
|
|
| 4303 |
import mimetypes as _mt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4304 |
save_ext = f".{ext}" if ext else ".bin"
|
| 4305 |
save_name = f"{uuidlib.uuid4().hex}{save_ext}"
|
| 4306 |
-
|
| 4307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4308 |
|
| 4309 |
-
mime = content_type or _mt.guess_type(
|
| 4310 |
base = _base_url_from_request(request)
|
| 4311 |
|
| 4312 |
# Register as secure asset if user is authenticated
|
| 4313 |
asset_id = ""
|
| 4314 |
-
file_url = f"{base}/files/{
|
| 4315 |
if user_id:
|
| 4316 |
try:
|
| 4317 |
-
from .files import _ensure_user_dir, _upload_root, insert_asset
|
| 4318 |
-
folder = _ensure_user_dir(user_id, "project_asset", project_id=project_id)
|
| 4319 |
-
import shutil
|
| 4320 |
-
dest = folder / save_name
|
| 4321 |
-
shutil.move(str(save_path), str(dest))
|
| 4322 |
-
save_path = dest
|
| 4323 |
-
rel_path = str(dest.relative_to(_upload_root()))
|
| 4324 |
asset_id = insert_asset(
|
| 4325 |
user_id=user_id,
|
| 4326 |
kind="project_asset",
|
| 4327 |
-
rel_path=
|
| 4328 |
mime=mime,
|
| 4329 |
size_bytes=len(data),
|
| 4330 |
original_name=filename,
|
| 4331 |
project_id=project_id,
|
|
|
|
| 4332 |
)
|
| 4333 |
file_url = f"{base}/files/{asset_id}"
|
| 4334 |
except Exception as asset_err:
|
|
@@ -5584,65 +5637,71 @@ async def upload(request: Request, file: UploadFile = File(...), authorization:
|
|
| 5584 |
except Exception:
|
| 5585 |
pass
|
| 5586 |
|
|
|
|
|
|
|
| 5587 |
if _uid:
|
| 5588 |
-
# Secure path:
|
| 5589 |
-
from .files import
|
| 5590 |
-
|
| 5591 |
-
folder = _ensure_user_dir(_uid, "upload")
|
| 5592 |
-
name = f"{uuidlib.uuid4().hex}{ext}"
|
| 5593 |
-
path = folder / name
|
| 5594 |
else:
|
| 5595 |
-
# Legacy path: flat
|
| 5596 |
-
|
| 5597 |
-
|
| 5598 |
|
| 5599 |
written = 0
|
| 5600 |
chunk_size = 1024 * 1024 # 1MB
|
|
|
|
|
|
|
| 5601 |
|
| 5602 |
-
#
|
| 5603 |
-
|
|
|
|
|
|
|
| 5604 |
try:
|
| 5605 |
-
|
| 5606 |
-
|
| 5607 |
-
|
| 5608 |
-
|
| 5609 |
-
|
| 5610 |
-
|
| 5611 |
-
|
| 5612 |
-
|
| 5613 |
-
|
| 5614 |
-
|
| 5615 |
-
|
| 5616 |
-
|
| 5617 |
-
|
| 5618 |
-
|
| 5619 |
-
|
| 5620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5621 |
except OSError as e:
|
| 5622 |
-
|
| 5623 |
-
tmp_path.unlink(missing_ok=True)
|
| 5624 |
raise HTTPException(
|
| 5625 |
status_code=500,
|
| 5626 |
-
detail=f"Upload storage error: {e}. Upload dir: {
|
| 5627 |
)
|
| 5628 |
-
finally:
|
| 5629 |
-
await file.close()
|
| 5630 |
|
| 5631 |
base = _base_url_from_request(request)
|
| 5632 |
|
| 5633 |
if _uid:
|
| 5634 |
# Register as a secure asset
|
| 5635 |
-
from .files import
|
| 5636 |
import mimetypes as _mt
|
| 5637 |
-
|
| 5638 |
-
mime = _mt.guess_type(str(path))[0] or "application/octet-stream"
|
| 5639 |
asset_id = insert_asset(
|
| 5640 |
user_id=_uid,
|
| 5641 |
kind="upload",
|
| 5642 |
-
rel_path=
|
| 5643 |
mime=mime,
|
| 5644 |
size_bytes=written,
|
| 5645 |
original_name=filename,
|
|
|
|
| 5646 |
)
|
| 5647 |
return JSONResponse(status_code=201, content={"url": f"{base}/files/{asset_id}"})
|
| 5648 |
else:
|
|
|
|
| 126 |
# OpenAI-compatible endpoint (additive β exposes personas as /v1/chat/completions)
|
| 127 |
from .openai_compat_endpoint import router as openai_compat_router
|
| 128 |
|
| 129 |
+
# Where scripts/download.py is β the repo's, or the copy a packaged image ships.
|
| 130 |
+
from .download_script import download_script
|
| 131 |
+
|
| 132 |
# ββ Access-log noise filter ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 133 |
# The Interactive runtime polls GET /v1/interactive/play/sessions/{id}/pending
|
| 134 |
# once per 1.5β5 s to pick up scene-render updates. Uvicorn logs every hit at
|
|
|
|
| 406 |
import sys as _expert_sys
|
| 407 |
from pathlib import Path as _ExpertPath
|
| 408 |
|
| 409 |
+
_expert_here = _ExpertPath(__file__).resolve()
|
| 410 |
+
_expert_candidates = [
|
| 411 |
+
_expert_here.parents[2] / "expert" / "backend" / "app" / "expert", # the repo
|
| 412 |
+
# A packaged image has no repo around it: the Hugging Face Space ships the
|
| 413 |
+
# package next to this one (/app/expert). Without it the chat's default path,
|
| 414 |
+
# POST /v1/expert/chat, did not exist there and every chat came back HTTP 405.
|
| 415 |
+
_expert_here.parents[1] / "expert",
|
| 416 |
+
]
|
| 417 |
+
if os.getenv("EXPERT_PACKAGE_DIR"):
|
| 418 |
+
_expert_candidates.insert(0, _ExpertPath(os.environ["EXPERT_PACKAGE_DIR"]))
|
| 419 |
+
_expert_pkg_dir = next((p for p in _expert_candidates if (p / "__init__.py").is_file()),
|
| 420 |
+
_expert_candidates[0])
|
| 421 |
_expert_init = _expert_pkg_dir / "__init__.py"
|
| 422 |
if not _expert_init.is_file():
|
| 423 |
raise FileNotFoundError(f"Expert package not found at {_expert_init}")
|
|
|
|
| 1085 |
if num_match:
|
| 1086 |
return int(num_match.group(1))
|
| 1087 |
|
| 1088 |
+
# Match patterns like "the second one", "second photo", "the 2nd" β as whole words, so
|
| 1089 |
+
# "firstly" or "a thirdparty tool" is not a photo number.
|
| 1090 |
for word, idx in _ORDINAL_MAP.items():
|
| 1091 |
+
if _re.search(rf"\b{word}\b", m):
|
| 1092 |
return idx
|
| 1093 |
|
| 1094 |
# Match patterns like "the 2nd", "the 3rd", "the 1st"
|
|
|
|
| 1121 |
if not m:
|
| 1122 |
return False
|
| 1123 |
|
| 1124 |
+
# ---- PASS-THROUGH: anything longer than a command (a pasted document, a question with
|
| 1125 |
+
# context) goes to the LLM β its words ("back", "first", "more") are not a request.
|
| 1126 |
+
from .intent_guard import is_short_command
|
| 1127 |
+
if not is_short_command(m):
|
| 1128 |
+
return False
|
| 1129 |
+
|
| 1130 |
# ---- PASS-THROUGH: let the LLM handle questions and conversation ----
|
| 1131 |
# Questions starting with question words β LLM reasons
|
| 1132 |
if _re.search(r'^(?:what|explain|describe|tell|list|how many|which|why|who|where|when|do you|can you|have you|are you|could you|would you)\b', m):
|
|
|
|
| 1287 |
startup_reconcile()
|
| 1288 |
except Exception as e:
|
| 1289 |
print(f"Warning: Asset registry reconcile failed: {e}")
|
| 1290 |
+
# Media storage: say which backend holds the bytes; reach a remote one now, off the loop,
|
| 1291 |
+
# so the first upload does not pay for loading its client (docs/design/MEDIA_STORAGE.md).
|
| 1292 |
+
try:
|
| 1293 |
+
from .media_storage import warm_up as _media_storage_warm_up
|
| 1294 |
+
_media_storage_warm_up()
|
| 1295 |
+
except Exception as e:
|
| 1296 |
+
print(f"Warning: media storage warm-up failed: {e}")
|
| 1297 |
+
# Deleted media waits in the trash for MEDIA_TRASH_RETENTION_DAYS; this thread purges it.
|
| 1298 |
+
try:
|
| 1299 |
+
from .media_ops import start_trash_gc as _start_trash_gc
|
| 1300 |
+
_start_trash_gc()
|
| 1301 |
+
except Exception as e:
|
| 1302 |
+
print(f"Warning: media trash purge not started: {e}")
|
| 1303 |
# Files mount
|
| 1304 |
_ensure_static_mount()
|
| 1305 |
|
|
|
|
| 2496 |
remedy_extra: Dict[str, Any] = {}
|
| 2497 |
if provider == "ollama":
|
| 2498 |
from .model_resolver import active_runtime_url as _active_runtime_url
|
| 2499 |
+
from .model_resolver import ollama_root as _ollama_root
|
| 2500 |
from .model_resolver import resolve as _resolve_model
|
| 2501 |
+
# An explicit Base URL that is not an Ollama (OllaBridge's /v1, say) falls back to
|
| 2502 |
+
# the Ollama that answers, and the response says where the models came from.
|
| 2503 |
+
asked = base_url
|
| 2504 |
+
base_url = await _active_runtime_url(base_url)
|
| 2505 |
+
if asked and base_url not in (_ollama_root(asked), asked.rstrip("/")):
|
| 2506 |
+
remedy_extra["note"] = (
|
| 2507 |
+
f"{asked} is not an Ollama runtime; listing the models of Ollama at {base_url}."
|
| 2508 |
+
)
|
| 2509 |
|
| 2510 |
# OpenAI-compatible endpoints (an OllaBridge gateway) also say *what* each model is β
|
| 2511 |
# a base model or an assistant built on one β which a picker needs to choose a
|
|
|
|
| 2902 |
logger.addHandler(handler)
|
| 2903 |
|
| 2904 |
try:
|
| 2905 |
+
# Only ComfyUI and Civitai installs run the script (checked below, once an Ollama pull
|
| 2906 |
+
# has returned): requiring it up front failed every install on the Space, pulls included.
|
| 2907 |
+
script_path = download_script()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2908 |
|
| 2909 |
logger.info(f"Starting model installation: provider={req.provider}, model={req.model_id}")
|
| 2910 |
|
|
|
|
| 2981 |
)
|
| 2982 |
|
| 2983 |
# Run download script for comfyui and civitai
|
| 2984 |
+
if not script_path.is_file():
|
| 2985 |
+
logger.error(f"Download script not found at {script_path}")
|
| 2986 |
+
return JSONResponse(
|
| 2987 |
+
status_code=500,
|
| 2988 |
+
content=_safe_err(
|
| 2989 |
+
f"Download script not found at {script_path}. Set HOMEPILOT_DOWNLOAD_SCRIPT "
|
| 2990 |
+
"to the repo's scripts/download.py.",
|
| 2991 |
+
code="script_not_found",
|
| 2992 |
+
),
|
| 2993 |
+
)
|
| 2994 |
+
|
| 2995 |
# Pass Civitai API key via environment variable (secure - not visible in process list)
|
| 2996 |
env = os.environ.copy()
|
| 2997 |
if req.civitai_api_key and req.provider == "civitai":
|
|
|
|
| 3586 |
if ".." in file_ref:
|
| 3587 |
return None
|
| 3588 |
|
| 3589 |
+
# The folder we were handed first (as before), then the same reference as a flat key or
|
| 3590 |
+
# a file_assets id β on this disk, or fetched from the configured storage (files.py).
|
| 3591 |
local_path = upload_root / file_ref
|
| 3592 |
if not local_path.exists():
|
| 3593 |
+
from .files import resolve_local_file
|
| 3594 |
+
local_path = resolve_local_file(file_ref) or local_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3595 |
if not local_path.exists():
|
| 3596 |
return None
|
| 3597 |
|
|
|
|
| 3674 |
) as client:
|
| 3675 |
resp = await client.get(url)
|
| 3676 |
resp.raise_for_status()
|
| 3677 |
+
# The flat key `filename`, written atomically through the storage interface. Every
|
| 3678 |
+
# caller passes UPLOAD_PATH, the media storage root; another folder (a test's temp dir)
|
| 3679 |
+
# is written as a local store of its own.
|
| 3680 |
+
from .media_storage import LocalStorage, get_storage, local_storage
|
| 3681 |
+
storage = get_storage()
|
| 3682 |
+
if Path(upload_root).resolve() != local_storage().root:
|
| 3683 |
+
storage = LocalStorage(upload_root)
|
| 3684 |
+
storage.put(filename, resp.content)
|
| 3685 |
|
| 3686 |
# Register in asset registry for durability
|
| 3687 |
try:
|
| 3688 |
from .asset_registry import register_comfy_output
|
| 3689 |
+
register_comfy_output(filename=filename, dest_path=str(dest),
|
| 3690 |
+
storage_backend=storage.name, size_bytes=len(resp.content))
|
| 3691 |
except Exception as e:
|
| 3692 |
print(f"[ASSET_REGISTRY] Warning: failed to register {filename}: {e}")
|
| 3693 |
|
|
|
|
| 4343 |
except Exception:
|
| 4344 |
pass # Non-fatal: proceed with upload if dedup check fails
|
| 4345 |
|
| 4346 |
+
# Save through media storage: in the user's project area when there is a user (the
|
| 4347 |
+
# secure-asset layout), else the flat legacy key β one atomic write, not a flat write
|
| 4348 |
+
# and a move.
|
| 4349 |
+
import hashlib as _hashlib
|
| 4350 |
import mimetypes as _mt
|
| 4351 |
+
from . import media_quota, media_validate
|
| 4352 |
+
from .files import _user_key, insert_asset
|
| 4353 |
+
from .media_storage import get_storage
|
| 4354 |
+
# Before any byte is stored: what it claims to be, the owner's quota, the scanner if set.
|
| 4355 |
+
media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type or "", filename)
|
| 4356 |
+
if user_id:
|
| 4357 |
+
media_quota.check(user_id, len(data))
|
| 4358 |
+
media_validate.scan_bytes(data, filename)
|
| 4359 |
save_ext = f".{ext}" if ext else ".bin"
|
| 4360 |
save_name = f"{uuidlib.uuid4().hex}{save_ext}"
|
| 4361 |
+
storage_key = (
|
| 4362 |
+
f"{_user_key(user_id, 'project_asset', project_id=project_id)}/{save_name}" if user_id else save_name
|
| 4363 |
+
)
|
| 4364 |
+
storage = get_storage()
|
| 4365 |
+
storage.put(storage_key, data)
|
| 4366 |
+
save_path = storage.local_path(storage_key) or (UPLOAD_PATH / storage_key)
|
| 4367 |
|
| 4368 |
+
mime = content_type or _mt.guess_type(save_name)[0] or "application/octet-stream"
|
| 4369 |
base = _base_url_from_request(request)
|
| 4370 |
|
| 4371 |
# Register as secure asset if user is authenticated
|
| 4372 |
asset_id = ""
|
| 4373 |
+
file_url = f"{base}/files/{storage_key}"
|
| 4374 |
if user_id:
|
| 4375 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4376 |
asset_id = insert_asset(
|
| 4377 |
user_id=user_id,
|
| 4378 |
kind="project_asset",
|
| 4379 |
+
rel_path=storage_key,
|
| 4380 |
mime=mime,
|
| 4381 |
size_bytes=len(data),
|
| 4382 |
original_name=filename,
|
| 4383 |
project_id=project_id,
|
| 4384 |
+
sha256=_hashlib.sha256(data).hexdigest(),
|
| 4385 |
)
|
| 4386 |
file_url = f"{base}/files/{asset_id}"
|
| 4387 |
except Exception as asset_err:
|
|
|
|
| 5637 |
except Exception:
|
| 5638 |
pass
|
| 5639 |
|
| 5640 |
+
from .media_storage import get_storage
|
| 5641 |
+
name = f"{uuidlib.uuid4().hex}{ext}"
|
| 5642 |
if _uid:
|
| 5643 |
+
# Secure path: the user's area, registered as an asset below
|
| 5644 |
+
from .files import _user_key
|
| 5645 |
+
key = f"{_user_key(_uid, 'upload')}/{name}"
|
|
|
|
|
|
|
|
|
|
| 5646 |
else:
|
| 5647 |
+
# Legacy path: the flat root
|
| 5648 |
+
key = name
|
| 5649 |
+
storage = get_storage()
|
| 5650 |
|
| 5651 |
written = 0
|
| 5652 |
chunk_size = 1024 * 1024 # 1MB
|
| 5653 |
+
from . import media_quota, media_validate
|
| 5654 |
+
import hashlib as _hashlib
|
| 5655 |
|
| 5656 |
+
# Read it whole (bounded by MAX_UPLOAD_MB) and check it before any byte is stored: what it
|
| 5657 |
+
# claims to be, the owner's quota, the scanner if one is set. Then one atomic put.
|
| 5658 |
+
chunks: list = []
|
| 5659 |
+
digest = _hashlib.sha256()
|
| 5660 |
try:
|
| 5661 |
+
while True:
|
| 5662 |
+
chunk = await file.read(chunk_size)
|
| 5663 |
+
if not chunk:
|
| 5664 |
+
break
|
| 5665 |
+
if not chunks:
|
| 5666 |
+
media_validate.check_image_claim(chunk[:media_validate.HEAD_BYTES], file.content_type or "", filename)
|
| 5667 |
+
written += len(chunk)
|
| 5668 |
+
if written > max_bytes:
|
| 5669 |
+
raise HTTPException(
|
| 5670 |
+
status_code=413,
|
| 5671 |
+
detail=f"File too large. Max {MAX_UPLOAD_MB}MB.",
|
| 5672 |
+
)
|
| 5673 |
+
digest.update(chunk)
|
| 5674 |
+
chunks.append(chunk)
|
| 5675 |
+
finally:
|
| 5676 |
+
await file.close()
|
| 5677 |
+
data = b"".join(chunks)
|
| 5678 |
+
if _uid:
|
| 5679 |
+
media_quota.check(_uid, written)
|
| 5680 |
+
media_validate.scan_bytes(data, filename)
|
| 5681 |
+
try:
|
| 5682 |
+
storage.put(key, data)
|
| 5683 |
except OSError as e:
|
| 5684 |
+
local = storage.local_path(key)
|
|
|
|
| 5685 |
raise HTTPException(
|
| 5686 |
status_code=500,
|
| 5687 |
+
detail=f"Upload storage error: {e}. Upload dir: {local.parent if local else storage.name}",
|
| 5688 |
)
|
|
|
|
|
|
|
| 5689 |
|
| 5690 |
base = _base_url_from_request(request)
|
| 5691 |
|
| 5692 |
if _uid:
|
| 5693 |
# Register as a secure asset
|
| 5694 |
+
from .files import insert_asset
|
| 5695 |
import mimetypes as _mt
|
| 5696 |
+
mime = _mt.guess_type(name)[0] or "application/octet-stream"
|
|
|
|
| 5697 |
asset_id = insert_asset(
|
| 5698 |
user_id=_uid,
|
| 5699 |
kind="upload",
|
| 5700 |
+
rel_path=key,
|
| 5701 |
mime=mime,
|
| 5702 |
size_bytes=written,
|
| 5703 |
original_name=filename,
|
| 5704 |
+
sha256=digest.hexdigest(),
|
| 5705 |
)
|
| 5706 |
return JSONResponse(status_code=201, content={"url": f"{base}/files/{asset_id}"})
|
| 5707 |
else:
|
backend/app/media_links.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Signed links to one file β the grant for an <img> tag or a share that is not an account
|
| 3 |
+
credential (docs/design/MEDIA_STORAGE.md, audit item A3).
|
| 4 |
+
|
| 5 |
+
``/files/<asset_id>?exp=<unix seconds>&sig=<hmac>``: valid for that asset until ``exp``,
|
| 6 |
+
nothing else. The key is ``MEDIA_SIGNING_KEY`` when set, else one generated once into
|
| 7 |
+
``<data dir>/.media_signing_key`` (mode 600) next to the database.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import hmac
|
| 13 |
+
import os
|
| 14 |
+
import secrets
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Dict, Optional
|
| 18 |
+
|
| 19 |
+
from .storage import _get_db_path
|
| 20 |
+
|
| 21 |
+
DEFAULT_TTL_S = 600
|
| 22 |
+
MAX_TTL_S = 7 * 24 * 3600
|
| 23 |
+
_key_cache: Optional[bytes] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _key_file() -> Path:
|
| 27 |
+
return Path(_get_db_path()).parent / ".media_signing_key"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def signing_key() -> bytes:
|
| 31 |
+
global _key_cache
|
| 32 |
+
if _key_cache:
|
| 33 |
+
return _key_cache
|
| 34 |
+
env = (os.getenv("MEDIA_SIGNING_KEY") or "").strip()
|
| 35 |
+
if env:
|
| 36 |
+
_key_cache = env.encode("utf-8")
|
| 37 |
+
return _key_cache
|
| 38 |
+
path = _key_file()
|
| 39 |
+
try:
|
| 40 |
+
key = path.read_bytes().strip()
|
| 41 |
+
if key:
|
| 42 |
+
_key_cache = key
|
| 43 |
+
return key
|
| 44 |
+
except FileNotFoundError:
|
| 45 |
+
pass
|
| 46 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
key = secrets.token_hex(32).encode("ascii")
|
| 48 |
+
try:
|
| 49 |
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
| 50 |
+
with os.fdopen(fd, "wb") as f:
|
| 51 |
+
f.write(key + b"\n")
|
| 52 |
+
except FileExistsError:
|
| 53 |
+
key = path.read_bytes().strip()
|
| 54 |
+
_key_cache = key
|
| 55 |
+
return key
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def reset_key_cache() -> None:
|
| 59 |
+
global _key_cache
|
| 60 |
+
_key_cache = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _signature(asset_id: str, exp: int) -> str:
|
| 64 |
+
message = f"{asset_id}|{int(exp)}".encode("utf-8")
|
| 65 |
+
return hmac.new(signing_key(), message, hashlib.sha256).hexdigest()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def sign(asset_id: str, ttl_s: int = DEFAULT_TTL_S, now: Optional[float] = None) -> Dict[str, object]:
|
| 69 |
+
"""``{"exp": β¦, "sig": β¦, "url": "/files/<id>?exp=β¦&sig=β¦"}`` for ``asset_id``."""
|
| 70 |
+
ttl = max(1, min(int(ttl_s or DEFAULT_TTL_S), MAX_TTL_S))
|
| 71 |
+
exp = int((now if now is not None else time.time()) + ttl)
|
| 72 |
+
sig = _signature(asset_id, exp)
|
| 73 |
+
return {"exp": exp, "sig": sig, "url": f"/files/{asset_id}?exp={exp}&sig={sig}"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def verify(asset_id: str, exp: Optional[str], sig: Optional[str], now: Optional[float] = None) -> bool:
|
| 77 |
+
"""True when ``sig`` is this asset's signature for ``exp`` and ``exp`` is still ahead."""
|
| 78 |
+
if not asset_id or not exp or not sig:
|
| 79 |
+
return False
|
| 80 |
+
try:
|
| 81 |
+
exp_i = int(exp)
|
| 82 |
+
except (TypeError, ValueError):
|
| 83 |
+
return False
|
| 84 |
+
if exp_i < (now if now is not None else time.time()):
|
| 85 |
+
return False
|
| 86 |
+
return hmac.compare_digest(_signature(asset_id, exp_i), str(sig).strip())
|
backend/app/media_ops.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Operations on HomePilot's media (docs/design/MEDIA_STORAGE.md, Phases 3 and 4):
|
| 3 |
+
|
| 4 |
+
python -m app.media_ops verify every asset is where its row says, hashes match
|
| 5 |
+
python -m app.media_ops migrate [--to s3] copy assets into the configured backend, verify, flip the row
|
| 6 |
+
python -m app.media_ops backup <dir> database copy + every asset's bytes + a manifest
|
| 7 |
+
python -m app.media_ops restore <dir> put a backup's assets into the configured backend
|
| 8 |
+
python -m app.media_ops gc remove what has been in the trash longer than the retention
|
| 9 |
+
|
| 10 |
+
Nothing here deletes a source during a migration unless ``--prune-source`` says so, and a
|
| 11 |
+
copy is only recorded after it has been read back and its SHA-256 matched.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
import sys
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Callable, Dict, Iterator, List, Optional
|
| 26 |
+
|
| 27 |
+
from . import media_storage as _ms
|
| 28 |
+
from .media_storage import LocalStorage, MediaStorage
|
| 29 |
+
from .storage import _get_db_path, ensure_file_assets_columns
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_storage() -> MediaStorage:
|
| 33 |
+
"""Looked up on the module each time, so a configured (or test-substituted) backend is seen."""
|
| 34 |
+
return _ms.get_storage()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def storage_named(name: str) -> Optional[MediaStorage]:
|
| 38 |
+
return _ms.storage_named(name)
|
| 39 |
+
|
| 40 |
+
_CHUNK = 1024 * 1024
|
| 41 |
+
Log = Callable[[str], None]
|
| 42 |
+
|
| 43 |
+
ROW_COLUMNS = ("id", "user_id", "kind", "rel_path", "mime", "size_bytes", "original_name", "project_id",
|
| 44 |
+
"conversation_id", "storage_backend", "sha256", "visibility", "created_at")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _db() -> sqlite3.Connection:
|
| 48 |
+
con = sqlite3.connect(_get_db_path())
|
| 49 |
+
con.row_factory = sqlite3.Row
|
| 50 |
+
ensure_file_assets_columns(con)
|
| 51 |
+
return con
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _rows(con: sqlite3.Connection, where: str = "deleted_at IS NULL", params: tuple = ()) -> List[sqlite3.Row]:
|
| 55 |
+
return con.execute(f"SELECT {', '.join(ROW_COLUMNS)}, deleted_at FROM file_assets WHERE {where} ORDER BY created_at",
|
| 56 |
+
params).fetchall()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def sha256_of(stream) -> str:
|
| 60 |
+
digest = hashlib.sha256()
|
| 61 |
+
while True:
|
| 62 |
+
chunk = stream.read(_CHUNK)
|
| 63 |
+
if not chunk:
|
| 64 |
+
break
|
| 65 |
+
digest.update(chunk)
|
| 66 |
+
return digest.hexdigest()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def copy_hashed(src: MediaStorage, dst: MediaStorage, key: str) -> str:
|
| 70 |
+
"""Copy one object, hashing it on the way; the SHA-256 of what was read."""
|
| 71 |
+
digest = hashlib.sha256()
|
| 72 |
+
with src.open(key) as reader, dst.open_write(key) as writer:
|
| 73 |
+
while True:
|
| 74 |
+
chunk = reader.read(_CHUNK)
|
| 75 |
+
if not chunk:
|
| 76 |
+
break
|
| 77 |
+
digest.update(chunk)
|
| 78 |
+
writer.write(chunk)
|
| 79 |
+
return digest.hexdigest()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def read_back_hash(storage: MediaStorage, key: str) -> str:
|
| 83 |
+
with storage.open(key) as reader:
|
| 84 |
+
return sha256_of(reader)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ββ verify βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def verify(log: Log = print) -> Dict[str, int]:
|
| 91 |
+
"""Every live asset: its backend configured, the object there, the hash as recorded."""
|
| 92 |
+
counts = {"ok": 0, "missing": 0, "mismatch": 0, "unconfigured": 0, "unhashed": 0}
|
| 93 |
+
con = _db()
|
| 94 |
+
try:
|
| 95 |
+
for row in _rows(con):
|
| 96 |
+
store = storage_named(row["storage_backend"] or "local")
|
| 97 |
+
if store is None:
|
| 98 |
+
counts["unconfigured"] += 1
|
| 99 |
+
log(f" ? {row['id']} {row['rel_path']}: backend {row['storage_backend']!r} is not configured")
|
| 100 |
+
continue
|
| 101 |
+
if not store.exists(row["rel_path"]):
|
| 102 |
+
counts["missing"] += 1
|
| 103 |
+
log(f" β {row['id']} {row['rel_path']}: missing from {store.name}")
|
| 104 |
+
continue
|
| 105 |
+
if not row["sha256"]:
|
| 106 |
+
counts["unhashed"] += 1
|
| 107 |
+
counts["ok"] += 1
|
| 108 |
+
continue
|
| 109 |
+
if read_back_hash(store, row["rel_path"]) != row["sha256"]:
|
| 110 |
+
counts["mismatch"] += 1
|
| 111 |
+
log(f" β {row['id']} {row['rel_path']}: SHA-256 differs from the recorded one")
|
| 112 |
+
continue
|
| 113 |
+
counts["ok"] += 1
|
| 114 |
+
finally:
|
| 115 |
+
con.close()
|
| 116 |
+
log(f"verify: {counts}")
|
| 117 |
+
return counts
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ββ migrate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def migrate(target_name: Optional[str] = None, *, dry_run: bool = False, prune_source: bool = False,
|
| 124 |
+
log: Log = print) -> Dict[str, int]:
|
| 125 |
+
"""Copy every live asset that is not in the target backend into it β read back, hash
|
| 126 |
+
checked β then flip its row. Sources stay unless ``prune_source``."""
|
| 127 |
+
target = get_storage() if not target_name else storage_named(target_name)
|
| 128 |
+
if target is None:
|
| 129 |
+
raise RuntimeError(f"target backend {target_name!r} is not configured")
|
| 130 |
+
counts = {"moved": 0, "already": 0, "missing_source": 0, "unconfigured_source": 0, "failed": 0}
|
| 131 |
+
con = _db()
|
| 132 |
+
try:
|
| 133 |
+
for row in _rows(con, "deleted_at IS NULL AND storage_backend != ?", (target.name,)):
|
| 134 |
+
key, src_name = row["rel_path"], row["storage_backend"] or "local"
|
| 135 |
+
src = storage_named(src_name)
|
| 136 |
+
if src is None:
|
| 137 |
+
counts["unconfigured_source"] += 1
|
| 138 |
+
log(f" ? {row['id']} {key}: source backend {src_name!r} is not configured")
|
| 139 |
+
continue
|
| 140 |
+
if not src.exists(key):
|
| 141 |
+
counts["missing_source"] += 1
|
| 142 |
+
log(f" β {row['id']} {key}: not in {src_name}")
|
| 143 |
+
continue
|
| 144 |
+
if dry_run:
|
| 145 |
+
counts["moved"] += 1
|
| 146 |
+
log(f" β {row['id']} {key}: would copy {src_name} β {target.name}")
|
| 147 |
+
continue
|
| 148 |
+
try:
|
| 149 |
+
if target.exists(key) and read_back_hash(target, key) == (row["sha256"] or read_back_hash(src, key)):
|
| 150 |
+
digest = row["sha256"] or read_back_hash(src, key)
|
| 151 |
+
counts["already"] += 1
|
| 152 |
+
else:
|
| 153 |
+
digest = copy_hashed(src, target, key)
|
| 154 |
+
if read_back_hash(target, key) != digest:
|
| 155 |
+
raise RuntimeError("read-back hash differs from what was written")
|
| 156 |
+
if row["sha256"] and row["sha256"] != digest:
|
| 157 |
+
raise RuntimeError("the source no longer matches its recorded hash")
|
| 158 |
+
counts["moved"] += 1
|
| 159 |
+
con.execute("UPDATE file_assets SET storage_backend = ?, sha256 = ? WHERE id = ?",
|
| 160 |
+
(target.name, digest, row["id"]))
|
| 161 |
+
try:
|
| 162 |
+
con.execute("UPDATE assets SET storage_backend = ? WHERE storage_backend = ? AND storage_key = ?",
|
| 163 |
+
(target.name, src_name, key))
|
| 164 |
+
except sqlite3.Error:
|
| 165 |
+
pass # the registry row for this key already exists in the target backend
|
| 166 |
+
con.commit()
|
| 167 |
+
if prune_source and src is not target:
|
| 168 |
+
src.delete(key)
|
| 169 |
+
log(f" β {row['id']} {key}: {src_name} β {target.name}")
|
| 170 |
+
except Exception as e:
|
| 171 |
+
counts["failed"] += 1
|
| 172 |
+
log(f" β {row['id']} {key}: {e} β row left on {src_name}")
|
| 173 |
+
finally:
|
| 174 |
+
con.close()
|
| 175 |
+
log(f"migrate β {target.name}{' (dry run)' if dry_run else ''}: {counts}")
|
| 176 |
+
return counts
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ββ backup / restore βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def backup(dest: Path, log: Log = print) -> Dict[str, Any]:
|
| 183 |
+
"""``dest/homepilot.db`` (a consistent copy), ``dest/media/<key>`` for every live asset,
|
| 184 |
+
``dest/manifest.json``. Portable: it does not depend on any backend's on-disk format."""
|
| 185 |
+
dest = Path(dest)
|
| 186 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
media = LocalStorage(dest / "media")
|
| 188 |
+
con = _db()
|
| 189 |
+
try:
|
| 190 |
+
db_copy = sqlite3.connect(dest / "homepilot.db")
|
| 191 |
+
with db_copy:
|
| 192 |
+
con.backup(db_copy)
|
| 193 |
+
db_copy.close()
|
| 194 |
+
manifest: Dict[str, Any] = {"created_at": datetime.now(timezone.utc).isoformat(), "assets": [], "skipped": []}
|
| 195 |
+
for row in _rows(con):
|
| 196 |
+
store = storage_named(row["storage_backend"] or "local")
|
| 197 |
+
key = row["rel_path"]
|
| 198 |
+
if store is None or not store.exists(key):
|
| 199 |
+
manifest["skipped"].append({"id": row["id"], "rel_path": key, "storage_backend": row["storage_backend"]})
|
| 200 |
+
log(f" β {row['id']} {key}: not available from {row['storage_backend']}")
|
| 201 |
+
continue
|
| 202 |
+
digest = copy_hashed(store, media, key)
|
| 203 |
+
entry = {c: row[c] for c in ROW_COLUMNS}
|
| 204 |
+
entry["sha256"] = digest
|
| 205 |
+
manifest["assets"].append(entry)
|
| 206 |
+
(dest / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
| 207 |
+
finally:
|
| 208 |
+
con.close()
|
| 209 |
+
log(f"backup β {dest}: {len(manifest['assets'])} assets, {len(manifest['skipped'])} skipped")
|
| 210 |
+
return manifest
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def restore(src: Path, target_name: Optional[str] = None, log: Log = print) -> Dict[str, int]:
|
| 214 |
+
"""Put a backup's assets into the configured backend (read back, hash checked) and make
|
| 215 |
+
sure each has its row, naming that backend. The database file itself is restored by
|
| 216 |
+
copying ``homepilot.db`` back while HomePilot is stopped β see the design doc."""
|
| 217 |
+
src = Path(src)
|
| 218 |
+
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
|
| 219 |
+
media = LocalStorage(src / "media")
|
| 220 |
+
target = get_storage() if not target_name else storage_named(target_name)
|
| 221 |
+
if target is None:
|
| 222 |
+
raise RuntimeError(f"target backend {target_name!r} is not configured")
|
| 223 |
+
counts = {"restored": 0, "failed": 0}
|
| 224 |
+
con = _db()
|
| 225 |
+
try:
|
| 226 |
+
for entry in manifest["assets"]:
|
| 227 |
+
key = entry["rel_path"]
|
| 228 |
+
try:
|
| 229 |
+
digest = copy_hashed(media, target, key)
|
| 230 |
+
if digest != entry.get("sha256"):
|
| 231 |
+
raise RuntimeError("the backup copy does not match its manifest hash")
|
| 232 |
+
if read_back_hash(target, key) != digest:
|
| 233 |
+
raise RuntimeError("read-back hash differs from what was written")
|
| 234 |
+
con.execute(
|
| 235 |
+
"""INSERT INTO file_assets(id, user_id, kind, rel_path, mime, size_bytes, original_name,
|
| 236 |
+
project_id, conversation_id, storage_backend, sha256, visibility, created_at)
|
| 237 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
| 238 |
+
ON CONFLICT(id) DO UPDATE SET storage_backend = excluded.storage_backend,
|
| 239 |
+
sha256 = excluded.sha256, deleted_at = NULL""",
|
| 240 |
+
(entry["id"], entry["user_id"], entry["kind"], key, entry.get("mime") or "",
|
| 241 |
+
int(entry.get("size_bytes") or 0), entry.get("original_name") or "", entry.get("project_id") or "",
|
| 242 |
+
entry.get("conversation_id") or "", target.name, digest, entry.get("visibility") or "private",
|
| 243 |
+
entry.get("created_at") or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
|
| 244 |
+
)
|
| 245 |
+
con.commit()
|
| 246 |
+
counts["restored"] += 1
|
| 247 |
+
except Exception as e:
|
| 248 |
+
counts["failed"] += 1
|
| 249 |
+
log(f" β {entry['id']} {key}: {e}")
|
| 250 |
+
finally:
|
| 251 |
+
con.close()
|
| 252 |
+
log(f"restore β {src} into {target.name}: {counts}")
|
| 253 |
+
return counts
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ββ trash ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def trash_retention_days() -> float:
|
| 260 |
+
raw = (os.getenv("MEDIA_TRASH_RETENTION_DAYS") or "7").strip()
|
| 261 |
+
try:
|
| 262 |
+
return max(0.0, float(raw))
|
| 263 |
+
except ValueError:
|
| 264 |
+
return 7.0
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def purge_trash(retention_days: Optional[float] = None, *, now: Optional[float] = None, log: Log = print) -> Dict[str, int]:
|
| 268 |
+
"""Remove the bytes and rows of assets deleted longer ago than the retention. A row whose
|
| 269 |
+
backend is not configured is left β the object cannot be reached, so neither goes."""
|
| 270 |
+
days = trash_retention_days() if retention_days is None else float(retention_days)
|
| 271 |
+
cutoff = datetime.fromtimestamp((now if now is not None else time.time()) - days * 86400, tz=timezone.utc)
|
| 272 |
+
cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
| 273 |
+
counts = {"purged": 0, "kept": 0, "failed": 0}
|
| 274 |
+
con = _db()
|
| 275 |
+
try:
|
| 276 |
+
rows = con.execute("SELECT id, rel_path, storage_backend FROM file_assets WHERE deleted_at IS NOT NULL AND deleted_at <= ?",
|
| 277 |
+
(cutoff_s,)).fetchall()
|
| 278 |
+
for row in rows:
|
| 279 |
+
store = storage_named(row["storage_backend"] or "local")
|
| 280 |
+
if store is None:
|
| 281 |
+
counts["kept"] += 1
|
| 282 |
+
continue
|
| 283 |
+
try:
|
| 284 |
+
store.delete(row["rel_path"])
|
| 285 |
+
con.execute("DELETE FROM file_assets WHERE id = ?", (row["id"],))
|
| 286 |
+
con.commit()
|
| 287 |
+
counts["purged"] += 1
|
| 288 |
+
except Exception as e:
|
| 289 |
+
counts["failed"] += 1
|
| 290 |
+
log(f" β {row['id']} {row['rel_path']}: {e}")
|
| 291 |
+
finally:
|
| 292 |
+
con.close()
|
| 293 |
+
if counts["purged"] or counts["failed"]:
|
| 294 |
+
log(f"trash: {counts} (retention {days:g} days)")
|
| 295 |
+
return counts
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
_gc_thread: Optional[threading.Thread] = None
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def start_trash_gc(interval_s: float = 6 * 3600, first_delay_s: float = 60.0) -> None:
|
| 302 |
+
"""A daemon thread that purges the trash shortly after startup and every ``interval_s``."""
|
| 303 |
+
global _gc_thread
|
| 304 |
+
if _gc_thread and _gc_thread.is_alive():
|
| 305 |
+
return
|
| 306 |
+
|
| 307 |
+
def _loop() -> None:
|
| 308 |
+
time.sleep(first_delay_s)
|
| 309 |
+
while True:
|
| 310 |
+
try:
|
| 311 |
+
purge_trash()
|
| 312 |
+
except Exception as e: # never let housekeeping take the thread down
|
| 313 |
+
print(f"[MEDIA] trash purge failed: {e}")
|
| 314 |
+
time.sleep(interval_s)
|
| 315 |
+
|
| 316 |
+
_gc_thread = threading.Thread(target=_loop, name="media-trash-gc", daemon=True)
|
| 317 |
+
_gc_thread.start()
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ββ CLI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def main(argv: Optional[List[str]] = None) -> int:
|
| 324 |
+
parser = argparse.ArgumentParser(prog="python -m app.media_ops", description=__doc__,
|
| 325 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 326 |
+
sub = parser.add_subparsers(dest="command", required=True)
|
| 327 |
+
sub.add_parser("verify")
|
| 328 |
+
p = sub.add_parser("migrate")
|
| 329 |
+
p.add_argument("--to", dest="target", default=None, help="backend name (default: the configured one)")
|
| 330 |
+
p.add_argument("--dry-run", action="store_true")
|
| 331 |
+
p.add_argument("--prune-source", action="store_true", help="delete each source object after its copy verified")
|
| 332 |
+
p = sub.add_parser("backup")
|
| 333 |
+
p.add_argument("dest")
|
| 334 |
+
p = sub.add_parser("restore")
|
| 335 |
+
p.add_argument("src")
|
| 336 |
+
p.add_argument("--to", dest="target", default=None)
|
| 337 |
+
p = sub.add_parser("gc")
|
| 338 |
+
p.add_argument("--retention-days", type=float, default=None)
|
| 339 |
+
args = parser.parse_args(argv)
|
| 340 |
+
|
| 341 |
+
if args.command == "verify":
|
| 342 |
+
counts = verify()
|
| 343 |
+
return 1 if counts["missing"] or counts["mismatch"] else 0
|
| 344 |
+
if args.command == "migrate":
|
| 345 |
+
counts = migrate(args.target, dry_run=args.dry_run, prune_source=args.prune_source)
|
| 346 |
+
return 1 if counts["failed"] else 0
|
| 347 |
+
if args.command == "backup":
|
| 348 |
+
manifest = backup(Path(args.dest))
|
| 349 |
+
return 1 if manifest["skipped"] else 0
|
| 350 |
+
if args.command == "restore":
|
| 351 |
+
counts = restore(Path(args.src), args.target)
|
| 352 |
+
return 1 if counts["failed"] else 0
|
| 353 |
+
if args.command == "gc":
|
| 354 |
+
purge_trash(args.retention_days)
|
| 355 |
+
return 0
|
| 356 |
+
return 2
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
if __name__ == "__main__":
|
| 360 |
+
sys.exit(main())
|
backend/app/media_quota.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-user media quota (docs/design/MEDIA_STORAGE.md, Phase 4).
|
| 3 |
+
|
| 4 |
+
Off by default: ``MEDIA_QUOTA_MB_PER_USER=0`` (unlimited) keeps a local HomePilot exactly as
|
| 5 |
+
it was. With a limit, an upload that would take a user past it is refused with 413 before any
|
| 6 |
+
bytes are stored; files in the trash no longer count.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sqlite3
|
| 12 |
+
from typing import Dict
|
| 13 |
+
|
| 14 |
+
from fastapi import HTTPException
|
| 15 |
+
|
| 16 |
+
from .storage import _get_db_path, ensure_file_assets_columns
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def quota_bytes() -> int:
|
| 20 |
+
"""The per-user limit in bytes, 0 for none. Read at call time so a restart is not needed."""
|
| 21 |
+
raw = (os.getenv("MEDIA_QUOTA_MB_PER_USER") or "0").strip()
|
| 22 |
+
try:
|
| 23 |
+
mb = float(raw)
|
| 24 |
+
except ValueError:
|
| 25 |
+
mb = 0.0
|
| 26 |
+
return int(mb * 1024 * 1024) if mb > 0 else 0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def usage_bytes(user_id: str) -> int:
|
| 30 |
+
if not user_id:
|
| 31 |
+
return 0
|
| 32 |
+
con = sqlite3.connect(_get_db_path())
|
| 33 |
+
try:
|
| 34 |
+
ensure_file_assets_columns(con)
|
| 35 |
+
row = con.execute(
|
| 36 |
+
"SELECT COALESCE(SUM(size_bytes), 0) FROM file_assets WHERE user_id = ? AND deleted_at IS NULL",
|
| 37 |
+
(user_id,),
|
| 38 |
+
).fetchone()
|
| 39 |
+
return int(row[0] or 0)
|
| 40 |
+
finally:
|
| 41 |
+
con.close()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def usage(user_id: str) -> Dict[str, int]:
|
| 45 |
+
return {"used_bytes": usage_bytes(user_id), "quota_bytes": quota_bytes()}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def check(user_id: str, add_bytes: int) -> None:
|
| 49 |
+
"""Raise 413 when storing ``add_bytes`` more would take ``user_id`` past the quota."""
|
| 50 |
+
limit = quota_bytes()
|
| 51 |
+
if not limit or not user_id:
|
| 52 |
+
return
|
| 53 |
+
used = usage_bytes(user_id)
|
| 54 |
+
if used + max(0, int(add_bytes or 0)) > limit:
|
| 55 |
+
mb = 1024 * 1024
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=413,
|
| 58 |
+
detail=f"Storage quota reached: {used / mb:.0f} of {limit / mb:.0f} MB used. "
|
| 59 |
+
"Delete some files, or ask the operator to raise MEDIA_QUOTA_MB_PER_USER.",
|
| 60 |
+
)
|
backend/app/media_storage/__init__.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Where HomePilot's media bytes live.
|
| 3 |
+
|
| 4 |
+
One interface (``MediaStorage``) for uploads, generated images and videos, avatars and the
|
| 5 |
+
other persistent media, keyed by the relative path the registries already store β so every
|
| 6 |
+
existing asset resolves unchanged and nothing is moved. ``local`` (the default) is today's
|
| 7 |
+
layout under UPLOAD_DIR and needs no service; ``s3`` is any S3-compatible object store
|
| 8 |
+
(MEDIA_STORAGE_BACKEND=s3 plus S3_ENDPOINT_URL / S3_BUCKET / credentials). The backend is a
|
| 9 |
+
deployment setting, never a caller's decision.
|
| 10 |
+
|
| 11 |
+
from .media_storage import get_storage
|
| 12 |
+
get_storage().put("users/<id>/images/<file>.png", data)
|
| 13 |
+
|
| 14 |
+
Reads go through ``storage_for_read(key)``: the configured backend, else β when that is not
|
| 15 |
+
local β this machine's disk, where media from before a switch still is. Design and the phase
|
| 16 |
+
plan: docs/design/MEDIA_STORAGE.md.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import threading
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
from .. import config as _config
|
| 26 |
+
from .base import MediaStorage, StorageKeyError, StoredObject, normalize_key
|
| 27 |
+
from .local import LocalStorage
|
| 28 |
+
from .s3 import S3Settings, S3Storage
|
| 29 |
+
|
| 30 |
+
__all__ = [
|
| 31 |
+
"MediaStorage", "LocalStorage", "S3Settings", "S3Storage", "StorageKeyError", "StoredObject",
|
| 32 |
+
"AVAILABLE_BACKENDS", "backend_name", "get_storage", "local_storage", "normalize_key",
|
| 33 |
+
"reset_storage", "storage_for_read", "storage_named", "upload_root", "warm_up",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
DEFAULT_BACKEND = "local"
|
| 37 |
+
AVAILABLE_BACKENDS = ("local", "s3")
|
| 38 |
+
|
| 39 |
+
_lock = threading.Lock()
|
| 40 |
+
_current: Optional[MediaStorage] = None
|
| 41 |
+
_current_signature: Optional[tuple] = None
|
| 42 |
+
_local: Optional[LocalStorage] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def upload_root() -> Path:
|
| 46 |
+
"""UPLOAD_DIR, or ``backend/data/uploads`` when it is relative β the rule files.py and
|
| 47 |
+
asset_registry.py each carried a copy of. Read at call time, so a repointed
|
| 48 |
+
``app.config.UPLOAD_DIR`` (tests, DATA_DIR) is honoured."""
|
| 49 |
+
p = Path(_config.UPLOAD_DIR)
|
| 50 |
+
if not p.is_absolute():
|
| 51 |
+
p = Path(__file__).resolve().parents[2] / "data" / "uploads"
|
| 52 |
+
return p
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def backend_name() -> str:
|
| 56 |
+
return (os.getenv("MEDIA_STORAGE_BACKEND") or DEFAULT_BACKEND).strip().lower() or DEFAULT_BACKEND
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def local_storage() -> LocalStorage:
|
| 60 |
+
"""This machine's disk under the upload root β the default backend, and where media
|
| 61 |
+
from before a switch to another backend still is."""
|
| 62 |
+
global _local
|
| 63 |
+
root = upload_root()
|
| 64 |
+
with _lock:
|
| 65 |
+
if _local is None or _local.root != root.resolve():
|
| 66 |
+
_local = LocalStorage(root)
|
| 67 |
+
return _local
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_storage() -> MediaStorage:
|
| 71 |
+
"""The configured storage β built once, rebuilt when the backend or its settings change.
|
| 72 |
+
Building it touches no network; the first operation does."""
|
| 73 |
+
global _current, _current_signature
|
| 74 |
+
name = backend_name()
|
| 75 |
+
s3_settings = S3Settings.from_env() if name == "s3" else None
|
| 76 |
+
signature = s3_settings.signature() if s3_settings else (name, str(upload_root()))
|
| 77 |
+
with _lock:
|
| 78 |
+
if _current is None or _current_signature != signature:
|
| 79 |
+
if name == "local":
|
| 80 |
+
_current = LocalStorage(upload_root())
|
| 81 |
+
elif s3_settings is not None:
|
| 82 |
+
_current = S3Storage(s3_settings)
|
| 83 |
+
else:
|
| 84 |
+
raise RuntimeError(
|
| 85 |
+
f"MEDIA_STORAGE_BACKEND={name!r} is not a backend this HomePilot has: "
|
| 86 |
+
f"one of {', '.join(AVAILABLE_BACKENDS)}."
|
| 87 |
+
)
|
| 88 |
+
_current_signature = signature
|
| 89 |
+
return _current
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def storage_named(name: str) -> Optional[MediaStorage]:
|
| 93 |
+
"""The storage called ``name`` as this HomePilot is configured: this disk for 'local', the
|
| 94 |
+
configured backend when that is its name β else None: the bytes are in a store this
|
| 95 |
+
HomePilot is not set up to reach (switched back to local after writing to S3)."""
|
| 96 |
+
name = (name or "local").strip().lower()
|
| 97 |
+
if name == "local":
|
| 98 |
+
return local_storage()
|
| 99 |
+
storage = get_storage()
|
| 100 |
+
return storage if storage.name == name else None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def storage_for_read(key: str) -> MediaStorage:
|
| 104 |
+
"""For a key with no registry row (path-based legacy URLs): the configured backend when it
|
| 105 |
+
has the object (or is local), else this machine's disk β so switching to S3 never hides
|
| 106 |
+
the media written before it, and a remote store that is down still leaves the local ones
|
| 107 |
+
readable. Registered assets do not need this: their row names the backend."""
|
| 108 |
+
storage = get_storage()
|
| 109 |
+
if storage.name == "local":
|
| 110 |
+
return storage
|
| 111 |
+
try:
|
| 112 |
+
if storage.exists(key):
|
| 113 |
+
return storage
|
| 114 |
+
except StorageKeyError:
|
| 115 |
+
raise
|
| 116 |
+
except Exception:
|
| 117 |
+
pass # unreachable or misconfigured: fall through to the disk for this read
|
| 118 |
+
local = local_storage()
|
| 119 |
+
return local if local.exists(key) else storage
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def warm_up() -> None:
|
| 123 |
+
"""At startup: say which backend is in use and, when it is remote, load its client library
|
| 124 |
+
and reach the bucket on a background thread β so the first upload does not pay for it
|
| 125 |
+
(boto3 took 14 s to load from a venv on a Windows share; the second upload took 0.2 s)."""
|
| 126 |
+
try:
|
| 127 |
+
storage = get_storage()
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"[media-storage] not available: {e}")
|
| 130 |
+
return
|
| 131 |
+
if isinstance(storage, S3Storage):
|
| 132 |
+
s = storage.settings
|
| 133 |
+
print(f"[media-storage] backend=s3 endpoint={s.endpoint_url or 'aws'} bucket={s.bucket}"
|
| 134 |
+
+ (f" prefix={s.prefix}" if s.prefix else "") + " (media from before the switch is read from the disk)")
|
| 135 |
+
else:
|
| 136 |
+
print(f"[media-storage] backend={storage.name} root={upload_root()}")
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
def _warm() -> None:
|
| 140 |
+
try:
|
| 141 |
+
storage.exists(".warm-up") # builds the client and makes sure the bucket is there
|
| 142 |
+
print("[media-storage] s3 ready")
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f"[media-storage] s3 not reachable yet: {e}")
|
| 145 |
+
|
| 146 |
+
threading.Thread(target=_warm, name="media-storage-warm-up", daemon=True).start()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def reset_storage() -> None:
|
| 150 |
+
"""Forget the built storages (tests that change UPLOAD_DIR or the backend)."""
|
| 151 |
+
global _current, _current_signature, _local
|
| 152 |
+
with _lock:
|
| 153 |
+
_current = None
|
| 154 |
+
_current_signature = None
|
| 155 |
+
_local = None
|
backend/app/media_storage/base.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The MediaStorage interface: where HomePilot keeps media bytes.
|
| 3 |
+
|
| 4 |
+
A key is a relative POSIX path under the storage root β the same string that
|
| 5 |
+
``file_assets.rel_path`` and ``assets.storage_key`` already hold ("users/<id>/images/<f>.png",
|
| 6 |
+
"avatar_<id>_1a2b.png"), so every existing asset resolves unchanged. Which backend serves a
|
| 7 |
+
key is a deployment setting (``MEDIA_STORAGE_BACKEND``), never a caller's decision. Phase 1
|
| 8 |
+
ships ``local``; another backend is another subclass of this, run through the same contract
|
| 9 |
+
tests (backend/tests/test_media_storage.py). Design: docs/design/MEDIA_STORAGE.md.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from abc import ABC, abstractmethod
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import BinaryIO, ContextManager, Optional, Union
|
| 17 |
+
|
| 18 |
+
Bytes = Union[bytes, bytearray, memoryview]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StorageKeyError(ValueError):
|
| 22 |
+
"""A key that would leave the storage root, or names nothing."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class StoredObject:
|
| 27 |
+
backend: str
|
| 28 |
+
key: str
|
| 29 |
+
size_bytes: int
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalize_key(key: str) -> str:
|
| 33 |
+
"""The canonical form of a key, or StorageKeyError.
|
| 34 |
+
|
| 35 |
+
Backslashes are separators (keys written on Windows), repeated and trailing slashes
|
| 36 |
+
collapse, and nothing may climb out: no absolute paths, drive letters, "..", NUL.
|
| 37 |
+
"""
|
| 38 |
+
raw = str(key or "").replace("\\", "/")
|
| 39 |
+
if not raw.strip() or "\x00" in raw:
|
| 40 |
+
raise StorageKeyError("empty key")
|
| 41 |
+
if raw.startswith("/") or (len(raw) > 1 and raw[1] == ":"):
|
| 42 |
+
raise StorageKeyError(f"an absolute path is not a key: {key!r}")
|
| 43 |
+
parts = [p for p in raw.split("/") if p not in ("", ".")]
|
| 44 |
+
if not parts or any(p == ".." for p in parts):
|
| 45 |
+
raise StorageKeyError(f"key leaves the storage root: {key!r}")
|
| 46 |
+
return "/".join(parts)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class MediaStorage(ABC):
|
| 50 |
+
"""Bytes by key. Writes are atomic: a reader never sees a half-written object."""
|
| 51 |
+
|
| 52 |
+
name: str = "abstract"
|
| 53 |
+
|
| 54 |
+
@abstractmethod
|
| 55 |
+
def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
|
| 56 |
+
"""Store bytes (or a readable stream) at ``key``. FileExistsError when it is
|
| 57 |
+
there and ``overwrite`` is False."""
|
| 58 |
+
|
| 59 |
+
@abstractmethod
|
| 60 |
+
def open_write(self, key: str, *, overwrite: bool = True) -> ContextManager[BinaryIO]:
|
| 61 |
+
"""A writable stream for ``key``, committed when the block ends normally and
|
| 62 |
+
discarded entirely β nothing at ``key``, no leftovers β when it raises."""
|
| 63 |
+
|
| 64 |
+
@abstractmethod
|
| 65 |
+
def open(self, key: str) -> BinaryIO:
|
| 66 |
+
"""A readable stream; FileNotFoundError when there is no such object."""
|
| 67 |
+
|
| 68 |
+
def read(self, key: str) -> bytes:
|
| 69 |
+
with self.open(key) as f:
|
| 70 |
+
return f.read()
|
| 71 |
+
|
| 72 |
+
@abstractmethod
|
| 73 |
+
def exists(self, key: str) -> bool: ...
|
| 74 |
+
|
| 75 |
+
@abstractmethod
|
| 76 |
+
def size(self, key: str) -> int:
|
| 77 |
+
"""Size in bytes; FileNotFoundError when there is no such object."""
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def delete(self, key: str) -> bool:
|
| 81 |
+
"""Remove the object. True when something was removed, False when it was not there."""
|
| 82 |
+
|
| 83 |
+
@abstractmethod
|
| 84 |
+
def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject: ...
|
| 85 |
+
|
| 86 |
+
def local_path(self, key: str) -> Optional[Path]:
|
| 87 |
+
"""The object's path on this machine's filesystem, when the backend has one (a
|
| 88 |
+
FileResponse can serve it directly); None for remote storage."""
|
| 89 |
+
return None
|
backend/app/media_storage/local.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LocalStorage β today's layout under UPLOAD_DIR, behind the MediaStorage interface.
|
| 3 |
+
|
| 4 |
+
Nothing moves: a key is the path relative to the root that the registries already store.
|
| 5 |
+
What is new is the discipline β every write lands atomically (temp file in the same
|
| 6 |
+
directory, then ``os.replace``), and no key can reach outside the root.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import shutil
|
| 12 |
+
import tempfile
|
| 13 |
+
from contextlib import contextmanager
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import BinaryIO, Iterator, Union
|
| 16 |
+
|
| 17 |
+
from .base import Bytes, MediaStorage, StorageKeyError, StoredObject, normalize_key
|
| 18 |
+
|
| 19 |
+
_COPY_CHUNK = 1024 * 1024
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class LocalStorage(MediaStorage):
|
| 23 |
+
name = "local"
|
| 24 |
+
|
| 25 |
+
def __init__(self, root: Union[str, Path]) -> None:
|
| 26 |
+
self.root = Path(root).resolve()
|
| 27 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
|
| 29 |
+
def _path(self, key: str) -> Path:
|
| 30 |
+
path = self.root / normalize_key(key)
|
| 31 |
+
resolved = path.resolve()
|
| 32 |
+
if resolved != self.root and self.root not in resolved.parents:
|
| 33 |
+
raise StorageKeyError(f"key leaves the storage root: {key!r}")
|
| 34 |
+
return path
|
| 35 |
+
|
| 36 |
+
# ββ writing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
@contextmanager
|
| 39 |
+
def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]:
|
| 40 |
+
path = self._path(key)
|
| 41 |
+
if not overwrite and path.exists():
|
| 42 |
+
raise FileExistsError(normalize_key(key))
|
| 43 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
# Same directory as the target, so the final rename is atomic on every filesystem.
|
| 45 |
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".part", dir=str(path.parent))
|
| 46 |
+
tmp = Path(tmp_name)
|
| 47 |
+
handle = os.fdopen(fd, "wb")
|
| 48 |
+
try:
|
| 49 |
+
yield handle
|
| 50 |
+
handle.flush()
|
| 51 |
+
os.fsync(handle.fileno())
|
| 52 |
+
handle.close()
|
| 53 |
+
os.replace(tmp, path)
|
| 54 |
+
except BaseException:
|
| 55 |
+
try:
|
| 56 |
+
handle.close()
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
tmp.unlink(missing_ok=True)
|
| 60 |
+
raise
|
| 61 |
+
|
| 62 |
+
def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
|
| 63 |
+
with self.open_write(key, overwrite=overwrite) as out:
|
| 64 |
+
if hasattr(data, "read"):
|
| 65 |
+
shutil.copyfileobj(data, out, _COPY_CHUNK) # type: ignore[arg-type]
|
| 66 |
+
else:
|
| 67 |
+
out.write(data) # type: ignore[arg-type]
|
| 68 |
+
return StoredObject(self.name, normalize_key(key), self._path(key).stat().st_size)
|
| 69 |
+
|
| 70 |
+
def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject:
|
| 71 |
+
with self.open(src_key) as src:
|
| 72 |
+
return self.put(dst_key, src, overwrite=overwrite)
|
| 73 |
+
|
| 74 |
+
# ββ reading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
def open(self, key: str) -> BinaryIO:
|
| 77 |
+
path = self._path(key)
|
| 78 |
+
if not path.is_file():
|
| 79 |
+
raise FileNotFoundError(normalize_key(key))
|
| 80 |
+
return path.open("rb")
|
| 81 |
+
|
| 82 |
+
def exists(self, key: str) -> bool:
|
| 83 |
+
return self._path(key).is_file()
|
| 84 |
+
|
| 85 |
+
def size(self, key: str) -> int:
|
| 86 |
+
path = self._path(key)
|
| 87 |
+
if not path.is_file():
|
| 88 |
+
raise FileNotFoundError(normalize_key(key))
|
| 89 |
+
return path.stat().st_size
|
| 90 |
+
|
| 91 |
+
def local_path(self, key: str) -> Path:
|
| 92 |
+
return self._path(key)
|
| 93 |
+
|
| 94 |
+
# ββ removing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
def delete(self, key: str) -> bool:
|
| 97 |
+
path = self._path(key)
|
| 98 |
+
if not path.is_file():
|
| 99 |
+
return False
|
| 100 |
+
path.unlink()
|
| 101 |
+
# Empty folders the object leaves behind go too β never the root itself.
|
| 102 |
+
parent = path.parent
|
| 103 |
+
while parent != self.root and self.root in parent.parents:
|
| 104 |
+
try:
|
| 105 |
+
if any(parent.iterdir()):
|
| 106 |
+
break
|
| 107 |
+
parent.rmdir()
|
| 108 |
+
except OSError:
|
| 109 |
+
break
|
| 110 |
+
parent = parent.parent
|
| 111 |
+
return True
|
backend/app/media_storage/s3.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
S3Storage β media bytes in any S3-compatible object store, behind MediaStorage.
|
| 3 |
+
|
| 4 |
+
S3 is the protocol, not a vendor: MinIO or AIStor Free, Garage, SeaweedFS, RustFS, AWS, a
|
| 5 |
+
Hugging Face bucket's S3 layer β anything that answers SigV4 PUT/GET/HEAD/DELETE/COPY.
|
| 6 |
+
boto3 is imported only here, when this backend is selected, so a local HomePilot needs
|
| 7 |
+
neither the library nor a server.
|
| 8 |
+
|
| 9 |
+
Keys are the same relative paths as the local backend (an optional S3_PREFIX in front), so
|
| 10 |
+
the registries do not change when the backend does. A PUT is atomic on every S3 server β an
|
| 11 |
+
object appears only once its upload completed β which is the ``open_write`` contract: a
|
| 12 |
+
stream is spooled locally and uploaded when its block ends, dropped when it raises.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import mimetypes
|
| 17 |
+
import os
|
| 18 |
+
import tempfile
|
| 19 |
+
from contextlib import contextmanager
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import Any, BinaryIO, Iterator, Mapping, Optional, Union
|
| 22 |
+
|
| 23 |
+
from .base import Bytes, MediaStorage, StoredObject, normalize_key
|
| 24 |
+
|
| 25 |
+
_MISSING_CODES = {"404", "NoSuchKey", "NotFound"}
|
| 26 |
+
_SPOOL_MAX = 8 * 1024 * 1024
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def normalize_prefix(prefix: str) -> str:
|
| 30 |
+
""""media/" for "media", "/media//" and so on; "" for none."""
|
| 31 |
+
clean = "/".join(p for p in (prefix or "").replace("\\", "/").split("/") if p and p != ".")
|
| 32 |
+
return f"{clean}/" if clean else ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class S3Settings:
|
| 37 |
+
bucket: str
|
| 38 |
+
endpoint_url: Optional[str] = None # None: AWS; "http://127.0.0.1:9400": a self-hosted server
|
| 39 |
+
region: str = "us-east-1"
|
| 40 |
+
access_key: Optional[str] = None # None: boto3's own credential chain (env, profile, role)
|
| 41 |
+
secret_key: Optional[str] = None
|
| 42 |
+
addressing_style: str = "path" # path for self-hosted servers; virtual for AWS-style hosts
|
| 43 |
+
prefix: str = "" # key prefix inside the bucket, "" or "name/"
|
| 44 |
+
create_bucket: bool = True # make the bucket on first use when it is missing
|
| 45 |
+
verify_tls: bool = True
|
| 46 |
+
|
| 47 |
+
def __post_init__(self) -> None:
|
| 48 |
+
# "media", "/media//" and "media/" are the same prefix; a frozen dataclass sets it so.
|
| 49 |
+
object.__setattr__(self, "prefix", normalize_prefix(self.prefix))
|
| 50 |
+
object.__setattr__(self, "bucket", (self.bucket or "").strip())
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_env(cls, env: Mapping[str, str] = os.environ) -> "S3Settings":
|
| 54 |
+
def get(name: str, default: str = "") -> str:
|
| 55 |
+
return (env.get(name) or default).strip()
|
| 56 |
+
|
| 57 |
+
def flag(name: str, default: str = "1") -> bool:
|
| 58 |
+
return get(name, default).lower() not in ("0", "false", "no", "off")
|
| 59 |
+
|
| 60 |
+
return cls(
|
| 61 |
+
bucket=get("S3_BUCKET", "homepilot-media"),
|
| 62 |
+
endpoint_url=get("S3_ENDPOINT_URL") or None,
|
| 63 |
+
region=get("S3_REGION", "us-east-1") or "us-east-1",
|
| 64 |
+
access_key=get("S3_ACCESS_KEY_ID") or get("AWS_ACCESS_KEY_ID") or None,
|
| 65 |
+
secret_key=get("S3_SECRET_ACCESS_KEY") or get("AWS_SECRET_ACCESS_KEY") or None,
|
| 66 |
+
addressing_style=(get("S3_ADDRESSING_STYLE", "path").lower() or "path"),
|
| 67 |
+
prefix=normalize_prefix(get("S3_PREFIX")),
|
| 68 |
+
create_bucket=flag("S3_CREATE_BUCKET"),
|
| 69 |
+
verify_tls=flag("S3_VERIFY_TLS"),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def signature(self) -> tuple:
|
| 73 |
+
"""What distinguishes one configured store from another (credentials excluded)."""
|
| 74 |
+
return ("s3", self.endpoint_url or "", self.bucket, self.prefix, self.region, self.addressing_style)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _is_missing(error: BaseException) -> bool:
|
| 78 |
+
response = getattr(error, "response", None) or {}
|
| 79 |
+
code = str((response.get("Error") or {}).get("Code") or "")
|
| 80 |
+
status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode")
|
| 81 |
+
return code in _MISSING_CODES or status == 404
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _content_type(key: str) -> str:
|
| 85 |
+
return mimetypes.guess_type(key)[0] or "application/octet-stream"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _build_client(settings: S3Settings) -> Any:
|
| 89 |
+
try:
|
| 90 |
+
import boto3
|
| 91 |
+
from botocore.config import Config
|
| 92 |
+
except ImportError as e: # pragma: no cover - depends on the install
|
| 93 |
+
raise RuntimeError(
|
| 94 |
+
"MEDIA_STORAGE_BACKEND=s3 needs boto3: in backend/, `uv pip install -e '.[s3]'` "
|
| 95 |
+
"(or `pip install 'boto3>=1.34'`)."
|
| 96 |
+
) from e
|
| 97 |
+
config = Config(
|
| 98 |
+
signature_version="s3v4",
|
| 99 |
+
s3={"addressing_style": settings.addressing_style},
|
| 100 |
+
retries={"max_attempts": 3, "mode": "standard"},
|
| 101 |
+
connect_timeout=5,
|
| 102 |
+
read_timeout=60,
|
| 103 |
+
)
|
| 104 |
+
return boto3.client(
|
| 105 |
+
"s3",
|
| 106 |
+
endpoint_url=settings.endpoint_url,
|
| 107 |
+
region_name=settings.region,
|
| 108 |
+
aws_access_key_id=settings.access_key,
|
| 109 |
+
aws_secret_access_key=settings.secret_key,
|
| 110 |
+
config=config,
|
| 111 |
+
verify=settings.verify_tls,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class _Body:
|
| 116 |
+
"""A GET body as a plain binary stream (boto3's StreamingBody is not a context manager)."""
|
| 117 |
+
|
| 118 |
+
def __init__(self, body: Any) -> None:
|
| 119 |
+
self._body = body
|
| 120 |
+
|
| 121 |
+
def read(self, size: int = -1) -> bytes:
|
| 122 |
+
return self._body.read() if size is None or size < 0 else self._body.read(size)
|
| 123 |
+
|
| 124 |
+
def close(self) -> None:
|
| 125 |
+
close = getattr(self._body, "close", None)
|
| 126 |
+
if close:
|
| 127 |
+
close()
|
| 128 |
+
|
| 129 |
+
def __enter__(self) -> "_Body":
|
| 130 |
+
return self
|
| 131 |
+
|
| 132 |
+
def __exit__(self, *exc: object) -> None:
|
| 133 |
+
self.close()
|
| 134 |
+
|
| 135 |
+
def __iter__(self) -> Iterator[bytes]:
|
| 136 |
+
while True:
|
| 137 |
+
chunk = self.read(1024 * 1024)
|
| 138 |
+
if not chunk:
|
| 139 |
+
return
|
| 140 |
+
yield chunk
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class S3Storage(MediaStorage):
|
| 144 |
+
name = "s3"
|
| 145 |
+
|
| 146 |
+
def __init__(self, settings: S3Settings, *, client: Any = None) -> None:
|
| 147 |
+
if not settings.bucket:
|
| 148 |
+
raise ValueError("S3_BUCKET is required for MEDIA_STORAGE_BACKEND=s3")
|
| 149 |
+
self.settings = settings
|
| 150 |
+
self._client = client # injected in tests; built lazily otherwise
|
| 151 |
+
self._bucket_ready = False
|
| 152 |
+
|
| 153 |
+
# ββ plumbing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
|
| 155 |
+
@property
|
| 156 |
+
def client(self) -> Any:
|
| 157 |
+
if self._client is None:
|
| 158 |
+
self._client = _build_client(self.settings)
|
| 159 |
+
return self._client
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def bucket(self) -> str:
|
| 163 |
+
return self.settings.bucket
|
| 164 |
+
|
| 165 |
+
def _ensure_bucket(self) -> None:
|
| 166 |
+
if self._bucket_ready:
|
| 167 |
+
return
|
| 168 |
+
try:
|
| 169 |
+
self.client.head_bucket(Bucket=self.bucket)
|
| 170 |
+
except Exception as e:
|
| 171 |
+
if not (_is_missing(e) and self.settings.create_bucket):
|
| 172 |
+
raise
|
| 173 |
+
kwargs: dict = {"Bucket": self.bucket}
|
| 174 |
+
if not self.settings.endpoint_url and self.settings.region != "us-east-1":
|
| 175 |
+
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": self.settings.region}
|
| 176 |
+
self.client.create_bucket(**kwargs)
|
| 177 |
+
self._bucket_ready = True
|
| 178 |
+
|
| 179 |
+
def _key(self, key: str) -> str:
|
| 180 |
+
return self.settings.prefix + normalize_key(key)
|
| 181 |
+
|
| 182 |
+
def _head(self, object_key: str) -> Optional[dict]:
|
| 183 |
+
try:
|
| 184 |
+
return self.client.head_object(Bucket=self.bucket, Key=object_key)
|
| 185 |
+
except Exception as e:
|
| 186 |
+
if _is_missing(e):
|
| 187 |
+
return None
|
| 188 |
+
raise
|
| 189 |
+
|
| 190 |
+
# ββ writing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 191 |
+
|
| 192 |
+
def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
|
| 193 |
+
self._ensure_bucket()
|
| 194 |
+
object_key = self._key(key)
|
| 195 |
+
if not overwrite and self._head(object_key) is not None:
|
| 196 |
+
raise FileExistsError(normalize_key(key))
|
| 197 |
+
content_type = _content_type(key)
|
| 198 |
+
if hasattr(data, "read"):
|
| 199 |
+
self.client.upload_fileobj(data, self.bucket, object_key, ExtraArgs={"ContentType": content_type})
|
| 200 |
+
size = int((self._head(object_key) or {}).get("ContentLength") or 0)
|
| 201 |
+
else:
|
| 202 |
+
payload = bytes(data) # type: ignore[arg-type]
|
| 203 |
+
self.client.put_object(Bucket=self.bucket, Key=object_key, Body=payload, ContentType=content_type)
|
| 204 |
+
size = len(payload)
|
| 205 |
+
return StoredObject(self.name, normalize_key(key), size)
|
| 206 |
+
|
| 207 |
+
@contextmanager
|
| 208 |
+
def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]:
|
| 209 |
+
self._ensure_bucket()
|
| 210 |
+
object_key = self._key(key)
|
| 211 |
+
if not overwrite and self._head(object_key) is not None:
|
| 212 |
+
raise FileExistsError(normalize_key(key))
|
| 213 |
+
spool = tempfile.SpooledTemporaryFile(max_size=_SPOOL_MAX)
|
| 214 |
+
try:
|
| 215 |
+
yield spool # type: ignore[misc]
|
| 216 |
+
spool.seek(0)
|
| 217 |
+
self.client.upload_fileobj(spool, self.bucket, object_key, ExtraArgs={"ContentType": _content_type(key)})
|
| 218 |
+
finally:
|
| 219 |
+
spool.close()
|
| 220 |
+
|
| 221 |
+
def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject:
|
| 222 |
+
self._ensure_bucket()
|
| 223 |
+
src, dst = self._key(src_key), self._key(dst_key)
|
| 224 |
+
if self._head(src) is None:
|
| 225 |
+
raise FileNotFoundError(normalize_key(src_key))
|
| 226 |
+
if not overwrite and self._head(dst) is not None:
|
| 227 |
+
raise FileExistsError(normalize_key(dst_key))
|
| 228 |
+
self.client.copy_object(
|
| 229 |
+
Bucket=self.bucket, Key=dst, CopySource={"Bucket": self.bucket, "Key": src},
|
| 230 |
+
ContentType=_content_type(dst_key), MetadataDirective="REPLACE",
|
| 231 |
+
)
|
| 232 |
+
return StoredObject(self.name, normalize_key(dst_key), int((self._head(dst) or {}).get("ContentLength") or 0))
|
| 233 |
+
|
| 234 |
+
# ββ reading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 235 |
+
|
| 236 |
+
def open(self, key: str) -> BinaryIO:
|
| 237 |
+
self._ensure_bucket()
|
| 238 |
+
try:
|
| 239 |
+
response = self.client.get_object(Bucket=self.bucket, Key=self._key(key))
|
| 240 |
+
except Exception as e:
|
| 241 |
+
if _is_missing(e):
|
| 242 |
+
raise FileNotFoundError(normalize_key(key)) from None
|
| 243 |
+
raise
|
| 244 |
+
return _Body(response["Body"]) # type: ignore[return-value]
|
| 245 |
+
|
| 246 |
+
def exists(self, key: str) -> bool:
|
| 247 |
+
self._ensure_bucket()
|
| 248 |
+
return self._head(self._key(key)) is not None
|
| 249 |
+
|
| 250 |
+
def size(self, key: str) -> int:
|
| 251 |
+
self._ensure_bucket()
|
| 252 |
+
head = self._head(self._key(key))
|
| 253 |
+
if head is None:
|
| 254 |
+
raise FileNotFoundError(normalize_key(key))
|
| 255 |
+
return int(head.get("ContentLength") or 0)
|
| 256 |
+
|
| 257 |
+
# ββ removing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 258 |
+
|
| 259 |
+
def delete(self, key: str) -> bool:
|
| 260 |
+
self._ensure_bucket()
|
| 261 |
+
object_key = self._key(key)
|
| 262 |
+
if self._head(object_key) is None:
|
| 263 |
+
return False
|
| 264 |
+
self.client.delete_object(Bucket=self.bucket, Key=object_key)
|
| 265 |
+
return True
|
backend/app/media_validate.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
What an upload must be before it is stored (docs/design/MEDIA_STORAGE.md, Phase 4).
|
| 3 |
+
|
| 4 |
+
- A file presented as an image (by MIME type or extension) must start like one: PNG, JPEG,
|
| 5 |
+
GIF, WebP or BMP. Anything else posing as an image is refused with 400 β before any bytes
|
| 6 |
+
reach storage.
|
| 7 |
+
- ``MEDIA_SCAN_COMMAND`` (unset by default) names a scanner, e.g. ``clamdscan --no-summary``:
|
| 8 |
+
it runs on a temporary copy with the path appended, and a non-zero exit refuses the file.
|
| 9 |
+
That is the quarantine: nothing is stored until the scanner has said yes.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import shlex
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from fastapi import HTTPException
|
| 21 |
+
|
| 22 |
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
| 23 |
+
_SIGNATURES = (
|
| 24 |
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
| 25 |
+
(b"\xff\xd8\xff", "image/jpeg"),
|
| 26 |
+
(b"GIF87a", "image/gif"),
|
| 27 |
+
(b"GIF89a", "image/gif"),
|
| 28 |
+
(b"BM", "image/bmp"),
|
| 29 |
+
)
|
| 30 |
+
HEAD_BYTES = 16
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def sniff_image(head: bytes) -> Optional[str]:
|
| 34 |
+
"""The image type the first bytes say, or None."""
|
| 35 |
+
head = bytes(head or b"")
|
| 36 |
+
for magic, mime in _SIGNATURES:
|
| 37 |
+
if head.startswith(magic):
|
| 38 |
+
return mime
|
| 39 |
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
| 40 |
+
return "image/webp"
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def claims_image(declared_mime: str, filename: str) -> bool:
|
| 45 |
+
mime = (declared_mime or "").split(";", 1)[0].strip().lower()
|
| 46 |
+
ext = Path(filename or "").suffix.lower()
|
| 47 |
+
return mime.startswith("image/") or ext in IMAGE_EXTS
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def check_image_claim(head: bytes, declared_mime: str, filename: str) -> None:
|
| 51 |
+
"""400 when the file says it is an image and its bytes say it is not."""
|
| 52 |
+
if claims_image(declared_mime, filename) and sniff_image(head) is None:
|
| 53 |
+
raise HTTPException(
|
| 54 |
+
status_code=400,
|
| 55 |
+
detail=f"{filename or 'The file'} is not the image it claims to be; PNG, JPEG, GIF, WebP and BMP are accepted.",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def scan_command() -> list:
|
| 60 |
+
raw = (os.getenv("MEDIA_SCAN_COMMAND") or "").strip()
|
| 61 |
+
return shlex.split(raw) if raw else []
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def scan_bytes(data: bytes, filename: str = "") -> None:
|
| 65 |
+
"""Run the configured scanner on a temporary copy; 400 when it objects. No-op when none is set."""
|
| 66 |
+
command = scan_command()
|
| 67 |
+
if not command:
|
| 68 |
+
return
|
| 69 |
+
suffix = Path(filename or "").suffix[:16]
|
| 70 |
+
with tempfile.NamedTemporaryFile(prefix="hp-scan-", suffix=suffix, delete=False) as tmp:
|
| 71 |
+
tmp.write(data)
|
| 72 |
+
path = tmp.name
|
| 73 |
+
try:
|
| 74 |
+
result = subprocess.run(command + [path], capture_output=True, text=True, timeout=120)
|
| 75 |
+
except (OSError, subprocess.TimeoutExpired) as e:
|
| 76 |
+
os.unlink(path)
|
| 77 |
+
raise HTTPException(status_code=503, detail=f"The upload scanner could not run: {e}") from e
|
| 78 |
+
os.unlink(path)
|
| 79 |
+
if result.returncode != 0:
|
| 80 |
+
print(f"[MEDIA] scanner refused {filename!r}: exit {result.returncode} {result.stdout.strip()[:200]}")
|
| 81 |
+
raise HTTPException(status_code=400, detail=f"{filename or 'The file'} was refused by the upload scanner.")
|
backend/app/model_advisor/hardware.py
CHANGED
|
@@ -81,7 +81,14 @@ def _nvidia() -> Optional[Dict[str, Any]]:
|
|
| 81 |
"note": "" if bw else "Unlisted GPU β speed estimates are unavailable; fit is still exact."}
|
| 82 |
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
try:
|
| 86 |
import psutil
|
| 87 |
return psutil.virtual_memory().total / 1e9
|
|
@@ -89,6 +96,30 @@ def _total_ram_gb() -> float:
|
|
| 89 |
return 0.0
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def _apple() -> Optional[Dict[str, Any]]:
|
| 93 |
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
| 94 |
return None
|
|
@@ -108,10 +139,17 @@ def detect() -> Dict[str, Any]:
|
|
| 108 |
if hw:
|
| 109 |
return {**hw, "detected": True}
|
| 110 |
ram = _total_ram_gb()
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
def with_override(hw: Dict[str, Any], vram_gb: Optional[float]) -> Dict[str, Any]:
|
|
|
|
| 81 |
"note": "" if bw else "Unlisted GPU β speed estimates are unavailable; fit is still exact."}
|
| 82 |
|
| 83 |
|
| 84 |
+
# The memory limit of the container HomePilot runs in (Docker, Kubernetes, a Hugging Face
|
| 85 |
+
# Space) β cgroup v2, then v1. psutil reports the host's RAM: a 16 GB Space read 132.9 GB,
|
| 86 |
+
# and FitLab called a 20B model a fit for it.
|
| 87 |
+
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
| 88 |
+
_CGROUP_LIMIT_FILES = ("memory.max", "memory/memory.limit_in_bytes")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _host_ram_gb() -> float:
|
| 92 |
try:
|
| 93 |
import psutil
|
| 94 |
return psutil.virtual_memory().total / 1e9
|
|
|
|
| 96 |
return 0.0
|
| 97 |
|
| 98 |
|
| 99 |
+
def _cgroup_limit_gb(root: Optional[Path] = None) -> Optional[float]:
|
| 100 |
+
"""The memory limit of this process's cgroup, in GB; None when there is none."""
|
| 101 |
+
for name in _CGROUP_LIMIT_FILES:
|
| 102 |
+
try:
|
| 103 |
+
raw = ((root or CGROUP_ROOT) / name).read_text(encoding="utf-8").strip()
|
| 104 |
+
except OSError:
|
| 105 |
+
continue
|
| 106 |
+
if not raw.isdigit(): # "max": no limit at this level
|
| 107 |
+
continue
|
| 108 |
+
limit = int(raw)
|
| 109 |
+
if 0 < limit < 1 << 60: # v1 writes a near-2^63 sentinel when unlimited
|
| 110 |
+
return limit / 1e9
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _total_ram_gb() -> float:
|
| 115 |
+
"""Memory this HomePilot can use: the host's RAM, or the container's limit when smaller."""
|
| 116 |
+
host = _host_ram_gb()
|
| 117 |
+
limit = _cgroup_limit_gb()
|
| 118 |
+
if limit is not None and (not host or limit < host):
|
| 119 |
+
return limit
|
| 120 |
+
return host
|
| 121 |
+
|
| 122 |
+
|
| 123 |
def _apple() -> Optional[Dict[str, Any]]:
|
| 124 |
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
| 125 |
return None
|
|
|
|
| 139 |
if hw:
|
| 140 |
return {**hw, "detected": True}
|
| 141 |
ram = _total_ram_gb()
|
| 142 |
+
host = _host_ram_gb()
|
| 143 |
+
cpu = {"kind": "cpu", "name": platform.processor() or platform.machine() or "CPU",
|
| 144 |
+
"vram_gb": 0.0, "ram_gb": round(ram, 1), "bandwidth_gbs": CPU_BANDWIDTH_GBS,
|
| 145 |
+
"profile_id": "cpu-only", "detected": True,
|
| 146 |
+
"note": "No GPU found β chat models are sized for system RAM; image and video need a GPU."}
|
| 147 |
+
if host and ram < host - 0.5:
|
| 148 |
+
# In a container: its limit is what counts, and the page says so.
|
| 149 |
+
cpu.update(host_ram_gb=round(host, 1), ram_source="container",
|
| 150 |
+
note=f"No GPU found β chat models are sized for this container's {ram:.0f} GB memory "
|
| 151 |
+
f"limit (the host has {host:.0f} GB); image and video need a GPU.")
|
| 152 |
+
return cpu
|
| 153 |
|
| 154 |
|
| 155 |
def with_override(hw: Dict[str, Any], vram_gb: Optional[float]) -> Dict[str, Any]:
|
backend/app/model_resolver.py
CHANGED
|
@@ -37,6 +37,7 @@ import dataclasses
|
|
| 37 |
import logging
|
| 38 |
import os
|
| 39 |
import re
|
|
|
|
| 40 |
import time
|
| 41 |
from dataclasses import dataclass, field
|
| 42 |
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
@@ -50,8 +51,14 @@ log = logging.getLogger("homepilot.models")
|
|
| 50 |
Role = Literal["chat", "fast", "vision", "embed"]
|
| 51 |
ROLES: Tuple[str, ...] = ("chat", "fast", "vision", "embed")
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
CACHE_TTL_S = float(os.getenv("MODEL_DISCOVERY_TTL_S", "30"))
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
# ββ Model classification (single source of truth; llm.py re-exports) ββββββββββ
|
| 57 |
VISION_ONLY_HINTS = (
|
|
@@ -224,6 +231,29 @@ def is_docker() -> bool:
|
|
| 224 |
return os.path.exists("/.dockerenv") or "docker" in _read_text("/proc/1/cgroup")
|
| 225 |
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
def _default_gateway_from_proc() -> str:
|
| 228 |
"""Default route gateway from ``/proc/net/route`` (little-endian hex)."""
|
| 229 |
text = _read_text("/proc/net/route")
|
|
@@ -279,7 +309,9 @@ def candidate_urls(explicit: Optional[str] = None) -> List[Tuple[str, str]]:
|
|
| 279 |
add("configured", OLLAMA_BASE_URL)
|
| 280 |
add("loopback", "http://127.0.0.1:11434")
|
| 281 |
add("loopback", "http://localhost:11434")
|
| 282 |
-
if is_wsl():
|
|
|
|
|
|
|
| 283 |
for ip in wsl_host_ips():
|
| 284 |
add("wsl-host", f"http://{ip}:11434")
|
| 285 |
if is_docker():
|
|
@@ -379,14 +411,21 @@ def invalidate(reason: str = "") -> None:
|
|
| 379 |
log.info("model discovery cache invalidated: %s", reason)
|
| 380 |
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
async def discover(force: bool = False) -> List[Runtime]:
|
| 383 |
-
"""All candidate runtimes with their probe results, cached ``
|
| 384 |
now = time.monotonic()
|
| 385 |
-
if not force and _cache["runtimes"] and
|
| 386 |
return list(_cache["runtimes"])
|
| 387 |
async with _get_lock():
|
| 388 |
now = time.monotonic()
|
| 389 |
-
if not force and _cache["runtimes"] and
|
| 390 |
return list(_cache["runtimes"])
|
| 391 |
cands = candidate_urls()
|
| 392 |
results = await asyncio.gather(*(probe(u, label) for label, u in cands))
|
|
@@ -407,7 +446,7 @@ async def discover_url(url: str, force: bool = False) -> Runtime:
|
|
| 407 |
key = url.rstrip("/")
|
| 408 |
now = time.monotonic()
|
| 409 |
hit = _url_cache.get(key)
|
| 410 |
-
if hit and not force and
|
| 411 |
return hit[1]
|
| 412 |
rt = await probe(key, "explicit")
|
| 413 |
_url_cache[key] = (time.monotonic(), rt)
|
|
@@ -429,10 +468,35 @@ def pick_runtime(runtimes: List[Runtime]) -> Optional[Runtime]:
|
|
| 429 |
return reachable[0]
|
| 430 |
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
async def active_runtime(base_url: Optional[str] = None, force: bool = False) -> Tuple[Optional[Runtime], List[Runtime]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
if base_url:
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
runtimes = await discover(force=force)
|
| 437 |
return pick_runtime(runtimes), runtimes
|
| 438 |
|
|
@@ -543,6 +607,21 @@ def build_remedy(role: str, runtime: Optional[Runtime], runtimes: List[Runtime],
|
|
| 543 |
}
|
| 544 |
probed = [r.url for r in runtimes]
|
| 545 |
wsl_hosts = [r for r in runtimes if r.label == "wsl-host"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
if is_wsl() and wsl_hosts:
|
| 547 |
return {
|
| 548 |
"kind": "wsl_bridge",
|
|
@@ -596,6 +675,9 @@ async def resolve(role: str, configured: str = "", *, base_url: Optional[str] =
|
|
| 596 |
model, source = "", "none"
|
| 597 |
urls = ", ".join(r.url for r in runtimes) or (base_url or OLLAMA_BASE_URL)
|
| 598 |
reason = f"No Ollama runtime is reachable (tried {urls})."
|
|
|
|
|
|
|
|
|
|
| 599 |
res = Resolution(
|
| 600 |
role=role,
|
| 601 |
configured=cfg,
|
|
|
|
| 37 |
import logging
|
| 38 |
import os
|
| 39 |
import re
|
| 40 |
+
import subprocess
|
| 41 |
import time
|
| 42 |
from dataclasses import dataclass, field
|
| 43 |
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
|
|
| 51 |
Role = Literal["chat", "fast", "vision", "embed"]
|
| 52 |
ROLES: Tuple[str, ...] = ("chat", "fast", "vision", "embed")
|
| 53 |
|
| 54 |
+
# 3 s: Ollama answers /api/tags in tens of milliseconds, but not while it loads a model or the
|
| 55 |
+
# machine is short of memory (ComfyUI loading a checkpoint) β 1.5 s reported it missing then.
|
| 56 |
+
# The candidates are probed in parallel, so a down Ollama still costs one timeout, not several.
|
| 57 |
+
PROBE_TIMEOUT_S = float(os.getenv("MODEL_DISCOVERY_TIMEOUT_S", "3"))
|
| 58 |
CACHE_TTL_S = float(os.getenv("MODEL_DISCOVERY_TTL_S", "30"))
|
| 59 |
+
# "Nothing answered" is remembered briefly, so a hiccup clears itself on the next look instead
|
| 60 |
+
# of holding the "Ollama unavailable" banner for the full cache lifetime.
|
| 61 |
+
NEGATIVE_TTL_S = float(os.getenv("MODEL_DISCOVERY_NEGATIVE_TTL_S", "5"))
|
| 62 |
|
| 63 |
# ββ Model classification (single source of truth; llm.py re-exports) ββββββββββ
|
| 64 |
VISION_ONLY_HINTS = (
|
|
|
|
| 231 |
return os.path.exists("/.dockerenv") or "docker" in _read_text("/proc/1/cgroup")
|
| 232 |
|
| 233 |
|
| 234 |
+
_WSL_MODE: Dict[str, str] = {}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def wsl_networking_mode() -> str:
|
| 238 |
+
"""``mirrored`` or ``nat`` (WSL 2), ``""`` outside WSL or when it cannot be told. In
|
| 239 |
+
mirrored mode WSL shares Windows' loopback, so Ollama on Windows' 127.0.0.1 is reachable as
|
| 240 |
+
127.0.0.1 here and the "enable mirrored networking" advice does not apply."""
|
| 241 |
+
if "mode" in _WSL_MODE:
|
| 242 |
+
return _WSL_MODE["mode"]
|
| 243 |
+
mode = ""
|
| 244 |
+
if is_wsl():
|
| 245 |
+
try:
|
| 246 |
+
out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=2)
|
| 247 |
+
mode = (out.stdout or "").strip().lower()
|
| 248 |
+
except (OSError, subprocess.SubprocessError):
|
| 249 |
+
mode = ""
|
| 250 |
+
if mode not in ("mirrored", "nat"):
|
| 251 |
+
# Mirrored mode puts the host's loopback alias 10.255.255.254 on lo.
|
| 252 |
+
mode = "mirrored" if "10.255.255.254" in _read_text("/proc/net/fib_trie") else "nat"
|
| 253 |
+
_WSL_MODE["mode"] = mode
|
| 254 |
+
return mode
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def _default_gateway_from_proc() -> str:
|
| 258 |
"""Default route gateway from ``/proc/net/route`` (little-endian hex)."""
|
| 259 |
text = _read_text("/proc/net/route")
|
|
|
|
| 309 |
add("configured", OLLAMA_BASE_URL)
|
| 310 |
add("loopback", "http://127.0.0.1:11434")
|
| 311 |
add("loopback", "http://localhost:11434")
|
| 312 |
+
if is_wsl() and wsl_networking_mode() != "mirrored":
|
| 313 |
+
# NAT mode only: with mirrored networking Windows' Ollama is 127.0.0.1 here, and the
|
| 314 |
+
# gateway / nameserver addresses are the LAN router, which never runs Ollama.
|
| 315 |
for ip in wsl_host_ips():
|
| 316 |
add("wsl-host", f"http://{ip}:11434")
|
| 317 |
if is_docker():
|
|
|
|
| 411 |
log.info("model discovery cache invalidated: %s", reason)
|
| 412 |
|
| 413 |
|
| 414 |
+
def _fresh(ts: float, runtimes: List[Runtime], now: float) -> bool:
|
| 415 |
+
"""A cached probe result still holds: ``CACHE_TTL_S`` when something answered,
|
| 416 |
+
``NEGATIVE_TTL_S`` when nothing did (a hiccup must not stick)."""
|
| 417 |
+
ttl = CACHE_TTL_S if any(r.reachable for r in runtimes) else NEGATIVE_TTL_S
|
| 418 |
+
return now - ts < ttl
|
| 419 |
+
|
| 420 |
+
|
| 421 |
async def discover(force: bool = False) -> List[Runtime]:
|
| 422 |
+
"""All candidate runtimes with their probe results, cached (see :func:`_fresh`)."""
|
| 423 |
now = time.monotonic()
|
| 424 |
+
if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now):
|
| 425 |
return list(_cache["runtimes"])
|
| 426 |
async with _get_lock():
|
| 427 |
now = time.monotonic()
|
| 428 |
+
if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now):
|
| 429 |
return list(_cache["runtimes"])
|
| 430 |
cands = candidate_urls()
|
| 431 |
results = await asyncio.gather(*(probe(u, label) for label, u in cands))
|
|
|
|
| 446 |
key = url.rstrip("/")
|
| 447 |
now = time.monotonic()
|
| 448 |
hit = _url_cache.get(key)
|
| 449 |
+
if hit and not force and _fresh(hit[0], [hit[1]], now):
|
| 450 |
return hit[1]
|
| 451 |
rt = await probe(key, "explicit")
|
| 452 |
_url_cache[key] = (time.monotonic(), rt)
|
|
|
|
| 468 |
return reachable[0]
|
| 469 |
|
| 470 |
|
| 471 |
+
def ollama_root(url: str) -> str:
|
| 472 |
+
"""The Ollama root a URL points at: Ollama serves its own API (``/api/tags``) and the
|
| 473 |
+
OpenAI-compatible one (``/v1``) from the same root, so ``http://host:11434/v1`` β what an
|
| 474 |
+
OpenAI-style "Base URL" field holds β means ``http://host:11434``."""
|
| 475 |
+
u = (url or "").strip().rstrip("/")
|
| 476 |
+
for suffix in ("/v1", "/api"):
|
| 477 |
+
if u.lower().endswith(suffix):
|
| 478 |
+
u = u[: -len(suffix)]
|
| 479 |
+
return u
|
| 480 |
+
|
| 481 |
+
|
| 482 |
async def active_runtime(base_url: Optional[str] = None, force: bool = False) -> Tuple[Optional[Runtime], List[Runtime]]:
|
| 483 |
+
"""The Ollama to use. An explicit ``base_url`` wins when it is an Ollama (or answers like one,
|
| 484 |
+
a proxy that lists no tags). When it does not answer as Ollama at all β an OpenAI-compatible
|
| 485 |
+
gateway such as OllaBridge (404 on ``/api/tags``), or nothing β the usual places are searched
|
| 486 |
+
instead of reporting Ollama missing while it runs on its own port."""
|
| 487 |
if base_url:
|
| 488 |
+
root = ollama_root(base_url)
|
| 489 |
+
rt = await discover_url(root, force=force)
|
| 490 |
+
if rt.reachable:
|
| 491 |
+
return rt, [rt]
|
| 492 |
+
if os.getenv("OLLAMA_DISCOVERY", "auto").strip().lower() in ("explicit", "strict"):
|
| 493 |
+
return None, [rt]
|
| 494 |
+
runtimes = [r for r in await discover(force=force) if r.url != rt.url]
|
| 495 |
+
found = pick_runtime(runtimes)
|
| 496 |
+
if found is not None:
|
| 497 |
+
log.info("model discovery: %s is not an Ollama runtime (%s); using Ollama at %s",
|
| 498 |
+
base_url, rt.error or "no /api/tags", found.url)
|
| 499 |
+
return found, [rt, *runtimes]
|
| 500 |
runtimes = await discover(force=force)
|
| 501 |
return pick_runtime(runtimes), runtimes
|
| 502 |
|
|
|
|
| 607 |
}
|
| 608 |
probed = [r.url for r in runtimes]
|
| 609 |
wsl_hosts = [r for r in runtimes if r.label == "wsl-host"]
|
| 610 |
+
if is_wsl() and wsl_networking_mode() == "mirrored":
|
| 611 |
+
# WSL shares Windows' loopback: a Windows Ollama would have answered on 127.0.0.1, so
|
| 612 |
+
# it is stopped, starting, or too busy to answer β not unreachable.
|
| 613 |
+
return {
|
| 614 |
+
"kind": "start",
|
| 615 |
+
"message": (
|
| 616 |
+
"Ollama did not answer. WSL uses mirrored networking, so an Ollama running on Windows "
|
| 617 |
+
"is reachable here: it is probably stopped, still starting, or busy loading a model. "
|
| 618 |
+
"Start the Ollama app on Windows (if `ollama serve` says the port is already in use, "
|
| 619 |
+
"it is running), wait a few seconds, then press Check again."
|
| 620 |
+
),
|
| 621 |
+
"command": "ollama serve",
|
| 622 |
+
"probed": probed,
|
| 623 |
+
"suggested": suggested,
|
| 624 |
+
}
|
| 625 |
if is_wsl() and wsl_hosts:
|
| 626 |
return {
|
| 627 |
"kind": "wsl_bridge",
|
|
|
|
| 675 |
model, source = "", "none"
|
| 676 |
urls = ", ".join(r.url for r in runtimes) or (base_url or OLLAMA_BASE_URL)
|
| 677 |
reason = f"No Ollama runtime is reachable (tried {urls})."
|
| 678 |
+
elif base_url and runtime.url != ollama_root(base_url):
|
| 679 |
+
reason = (f"{base_url} is not an Ollama runtime (an OpenAI-compatible gateway such as "
|
| 680 |
+
f"OllaBridge?); found Ollama at {runtime.url}. " + reason)
|
| 681 |
res = Resolution(
|
| 682 |
role=role,
|
| 683 |
configured=cfg,
|
backend/app/projects.py
CHANGED
|
@@ -1150,7 +1150,11 @@ You have access to the project's context. When relevant context from the knowled
|
|
| 1150 |
|
| 1151 |
# 5b. Hybrid intent detection β inject dynamic hint for photo-related messages
|
| 1152 |
# so the LLM gets precise guidance on what to do, without bypassing its reasoning.
|
| 1153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
import re as _hint_re
|
| 1155 |
_user_msg = (payload.get("message") or "").lower().strip()
|
| 1156 |
|
|
|
|
| 1150 |
|
| 1151 |
# 5b. Hybrid intent detection β inject dynamic hint for photo-related messages
|
| 1152 |
# so the LLM gets precise guidance on what to do, without bypassing its reasoning.
|
| 1153 |
+
# Short messages only: a pasted document's "back" or "first" is not a request to turn
|
| 1154 |
+
# around or show photo 1 (intent_guard.py).
|
| 1155 |
+
from .intent_guard import is_short_command as _is_short_command
|
| 1156 |
+
if (project_data and project_data.get("project_type") == "persona"
|
| 1157 |
+
and _is_short_command(payload.get("message") or "")):
|
| 1158 |
import re as _hint_re
|
| 1159 |
_user_msg = (payload.get("message") or "").lower().strip()
|
| 1160 |
|
backend/app/storage.py
CHANGED
|
@@ -277,11 +277,43 @@ def init_db():
|
|
| 277 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user ON file_assets(user_id)")
|
| 278 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user_kind ON file_assets(user_id, kind)")
|
| 279 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_project ON file_assets(project_id)")
|
|
|
|
| 280 |
|
| 281 |
con.commit()
|
| 282 |
con.close()
|
| 283 |
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
def ensure_conversation_owner(conversation_id: str, user_id: str) -> None:
|
| 286 |
"""Set owner on first use (idempotent)."""
|
| 287 |
if not conversation_id or not user_id:
|
|
|
|
| 277 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user ON file_assets(user_id)")
|
| 278 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user_kind ON file_assets(user_id, kind)")
|
| 279 |
cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_project ON file_assets(project_id)")
|
| 280 |
+
ensure_file_assets_columns(con)
|
| 281 |
|
| 282 |
con.commit()
|
| 283 |
con.close()
|
| 284 |
|
| 285 |
|
| 286 |
+
# Columns file_assets gained after its first shape (docs/design/MEDIA_STORAGE.md). Each is added
|
| 287 |
+
# idempotently with a default that is true for every row written before it existed.
|
| 288 |
+
_FILE_ASSET_COLUMNS = (
|
| 289 |
+
("storage_backend", "TEXT NOT NULL DEFAULT 'local'"), # which media backend holds the bytes (Phase 3a)
|
| 290 |
+
("sha256", "TEXT NOT NULL DEFAULT ''"), # of the bytes as written; '' when unknown (Phase 3)
|
| 291 |
+
("visibility", "TEXT NOT NULL DEFAULT 'private'"), # 'private' | 'public' (Phase 4)
|
| 292 |
+
("deleted_at", "TEXT"), # soft deletion; bytes go when the trash is purged (Phase 4)
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def ensure_file_assets_columns(con: sqlite3.Connection) -> list:
|
| 297 |
+
"""Add whichever of the later file_assets columns this database lacks. Returns their names."""
|
| 298 |
+
cur = con.cursor()
|
| 299 |
+
present = {row[1] for row in cur.execute("PRAGMA table_info(file_assets)").fetchall()}
|
| 300 |
+
if not present:
|
| 301 |
+
return []
|
| 302 |
+
added = []
|
| 303 |
+
for name, spec in _FILE_ASSET_COLUMNS:
|
| 304 |
+
if name not in present:
|
| 305 |
+
cur.execute(f"ALTER TABLE file_assets ADD COLUMN {name} {spec}")
|
| 306 |
+
added.append(name)
|
| 307 |
+
if added:
|
| 308 |
+
con.commit()
|
| 309 |
+
return added
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def ensure_file_assets_backend_column(con: sqlite3.Connection) -> bool:
|
| 313 |
+
"""Phase 3a's entry point, kept: True when storage_backend was just added."""
|
| 314 |
+
return "storage_backend" in ensure_file_assets_columns(con)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
def ensure_conversation_owner(conversation_id: str, user_id: str) -> None:
|
| 318 |
"""Set owner on first use (idempotent)."""
|
| 319 |
if not conversation_id or not user_id:
|
backend/app/studio/bundles.py
CHANGED
|
@@ -300,7 +300,8 @@ def bundle_install_status(bundle: EssayVideoBundle) -> Dict[str, Any]:
|
|
| 300 |
# ββ Installation (reinstall-safe: download.py skips files on disk) ββββββββββ
|
| 301 |
|
| 302 |
def _download_script() -> Path:
|
| 303 |
-
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
def install_plan(bundle: EssayVideoBundle) -> List[List[str]]:
|
|
|
|
| 300 |
# ββ Installation (reinstall-safe: download.py skips files on disk) ββββββββββ
|
| 301 |
|
| 302 |
def _download_script() -> Path:
|
| 303 |
+
from ..download_script import download_script # the repo's, or a packaged image's copy
|
| 304 |
+
return download_script()
|
| 305 |
|
| 306 |
|
| 307 |
def install_plan(bundle: EssayVideoBundle) -> List[List[str]]:
|
backend/app/users.py
CHANGED
|
@@ -1131,19 +1131,13 @@ async def upload_avatar(
|
|
| 1131 |
if len(contents) > 5 * 1024 * 1024:
|
| 1132 |
raise HTTPException(400, "Avatar image must be under 5MB")
|
| 1133 |
|
| 1134 |
-
#
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
# Save with unique name
|
| 1143 |
-
filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
| 1144 |
-
filepath = os.path.join(upload_dir, filename)
|
| 1145 |
-
with open(filepath, "wb") as f:
|
| 1146 |
-
f.write(contents)
|
| 1147 |
|
| 1148 |
# Build URL (relative to /files/ static mount)
|
| 1149 |
avatar_url = f"/files/{filename}"
|
|
@@ -1151,10 +1145,8 @@ async def upload_avatar(
|
|
| 1151 |
# Delete old avatar file if it exists (cleanup)
|
| 1152 |
old_url = user.get("avatar_url", "")
|
| 1153 |
if old_url and old_url.startswith("/files/"):
|
| 1154 |
-
old_path = os.path.join(upload_dir, old_url.replace("/files/", "", 1))
|
| 1155 |
try:
|
| 1156 |
-
|
| 1157 |
-
os.remove(old_path)
|
| 1158 |
except Exception:
|
| 1159 |
pass # Non-fatal
|
| 1160 |
|
|
|
|
| 1131 |
if len(contents) > 5 * 1024 * 1024:
|
| 1132 |
raise HTTPException(400, "Avatar image must be under 5MB")
|
| 1133 |
|
| 1134 |
+
# Save with a unique name. The flat key keeps today's /files/<name> URL β and its
|
| 1135 |
+
# public-by-prefix serving (docs/design/MEDIA_STORAGE.md, audit).
|
| 1136 |
+
from .media_storage import get_storage
|
| 1137 |
+
storage = get_storage()
|
| 1138 |
+
# A random name: the picture is public on the login screen, the account id is not its business.
|
| 1139 |
+
filename = f"avatar_{uuid.uuid4().hex[:16]}{ext}"
|
| 1140 |
+
storage.put(filename, contents)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1141 |
|
| 1142 |
# Build URL (relative to /files/ static mount)
|
| 1143 |
avatar_url = f"/files/{filename}"
|
|
|
|
| 1145 |
# Delete old avatar file if it exists (cleanup)
|
| 1146 |
old_url = user.get("avatar_url", "")
|
| 1147 |
if old_url and old_url.startswith("/files/"):
|
|
|
|
| 1148 |
try:
|
| 1149 |
+
storage.delete(old_url.replace("/files/", "", 1))
|
|
|
|
| 1150 |
except Exception:
|
| 1151 |
pass # Non-fatal
|
| 1152 |
|
backend/requirements.txt
CHANGED
|
@@ -24,3 +24,6 @@ faster-whisper==1.2.1
|
|
| 24 |
# PyAV 19 dropped an argument faster-whisper 1.2.1 passes; see requirements/speech-cpu.txt.
|
| 25 |
av==18.1.0
|
| 26 |
ctranslate2==4.8.2
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# PyAV 19 dropped an argument faster-whisper 1.2.1 passes; see requirements/speech-cpu.txt.
|
| 25 |
av==18.1.0
|
| 26 |
ctranslate2==4.8.2
|
| 27 |
+
# Media in an S3-compatible object store (MEDIA_STORAGE_BACKEND=s3; docs/design/MEDIA_STORAGE.md).
|
| 28 |
+
# Imported only when that backend is selected; CI and the packaged images install this file.
|
| 29 |
+
boto3>=1.34
|
deploy/huggingface-space/hf_wrapper.py
CHANGED
|
@@ -18,6 +18,7 @@ import uvicorn
|
|
| 18 |
from fastapi import Request
|
| 19 |
from fastapi.responses import FileResponse, JSONResponse
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 21 |
|
| 22 |
# Ensure the app module is importable.
|
| 23 |
sys.path.insert(0, "/app")
|
|
@@ -155,9 +156,48 @@ def frontend_catchall(full_path: str, request: Request):
|
|
| 155 |
return FileResponse(FRONTEND_INDEX)
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
# ββ Run ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
|
| 160 |
if __name__ == "__main__":
|
|
|
|
|
|
|
| 161 |
port = int(os.environ.get("PORT", "7860"))
|
| 162 |
uvicorn.run(
|
| 163 |
app,
|
|
|
|
| 18 |
from fastapi import Request
|
| 19 |
from fastapi.responses import FileResponse, JSONResponse
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
| 21 |
+
from starlette.routing import Match
|
| 22 |
|
| 23 |
# Ensure the app module is importable.
|
| 24 |
sys.path.insert(0, "/app")
|
|
|
|
| 156 |
return FileResponse(FRONTEND_INDEX)
|
| 157 |
|
| 158 |
|
| 159 |
+
@app.api_route("/{full_path:path}", methods=["POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
| 160 |
+
def api_catchall(full_path: str):
|
| 161 |
+
"""An API route that is not there is a 404. Without this, a POST to a path only the GET
|
| 162 |
+
catch-all above knew came back "405 Method Not Allowed" β which is how a missing Expert
|
| 163 |
+
package showed up in the chat as `HTTP 405` after "Hello"."""
|
| 164 |
+
return JSONResponse({"detail": "Not Found", "path": f"/{full_path}"}, status_code=404)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ββ Route check ββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
# What the UI and external clients call. A missing one is logged at boot, instead of being
|
| 169 |
+
# found by a user as an HTTP error (backend/tests/test_required_routes.py checks the same).
|
| 170 |
+
|
| 171 |
+
REQUIRED_ROUTES = (
|
| 172 |
+
("GET", "/health"),
|
| 173 |
+
("POST", "/chat"),
|
| 174 |
+
("POST", "/v1/chat/completions"),
|
| 175 |
+
("GET", "/v1/models"),
|
| 176 |
+
("POST", "/models/install"),
|
| 177 |
+
("GET", "/models/install/progress"),
|
| 178 |
+
("POST", "/v1/expert/chat"),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def missing_routes(application=app):
|
| 183 |
+
"""Required routes the app does not serve β asked of the router's own matching, so routers
|
| 184 |
+
FastAPI includes lazily count too, and the two catch-alls above do not."""
|
| 185 |
+
catchalls = {frontend_catchall, api_catchall}
|
| 186 |
+
missing = []
|
| 187 |
+
for method, path in REQUIRED_ROUTES:
|
| 188 |
+
scope = {"type": "http", "method": method, "path": path, "root_path": "",
|
| 189 |
+
"query_string": b"", "headers": [], "path_params": {}, "app": application}
|
| 190 |
+
if not any(getattr(r, "endpoint", None) not in catchalls and r.matches(scope)[0] == Match.FULL
|
| 191 |
+
for r in application.router.routes):
|
| 192 |
+
missing.append(f"{method} {path}")
|
| 193 |
+
return missing
|
| 194 |
+
|
| 195 |
+
|
| 196 |
# ββ Run ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
|
| 198 |
if __name__ == "__main__":
|
| 199 |
+
for _route in missing_routes():
|
| 200 |
+
print(f"[routes] β missing: {_route}", flush=True)
|
| 201 |
port = int(os.environ.get("PORT", "7860"))
|
| 202 |
uvicorn.run(
|
| 203 |
app,
|
expert/backend/app/expert/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert β multi-provider AI intelligence add-on for HomePilot
|
| 2 |
+
# Additive module: no existing files modified except 2-line patch in main.py
|
| 3 |
+
from .routes import router
|
| 4 |
+
|
| 5 |
+
__all__ = ["router"]
|
expert/backend/app/expert/capability.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What the connected models can carry β the gate for Deep research and Heavy's escalation.
|
| 2 |
+
|
| 3 |
+
A deep-agent run (plan, tool calls over many rounds, long synthesis) needs a strong,
|
| 4 |
+
tool-calling model; a 3B local model loops and hallucinates calls. So the strength of the
|
| 5 |
+
models on the user's connection is measured, not assumed:
|
| 6 |
+
|
| 7 |
+
* Ollama: ``/api/tags`` says each model's parameter count.
|
| 8 |
+
* OpenAI-compatible gateways (OllaBridge and friends): ``/models`` lists ids; the names carry
|
| 9 |
+
the hints β a size (``gemma4-31b``, ``gpt-oss-120b``) or a frontier family (``claude``,
|
| 10 |
+
``gemini``, ``gpt``, ``glm-5``, ``kimi``, ``deepseek-v4``).
|
| 11 |
+
|
| 12 |
+
Tiers: ``high`` (frontier family, or β₯ EXPERT_DEEP_MIN_PARAMS_B parameters), ``medium``
|
| 13 |
+
(7B and up), ``low``. Deep research is offered when the best model on the connection is
|
| 14 |
+
``high`` (``EXPERT_DEEP_RESEARCH=auto``); ``always`` / ``off`` override.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
import time
|
| 21 |
+
from dataclasses import asdict, dataclass
|
| 22 |
+
from typing import Any, Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
from .config import EXPERT_CAPABILITY_TTL_S, EXPERT_DEEP_MIN_PARAMS_B, EXPERT_DEEP_RESEARCH, EXPERT_LOCAL_MODEL
|
| 25 |
+
from . import providers
|
| 26 |
+
from . import state_store
|
| 27 |
+
from . import target as target_mod
|
| 28 |
+
from .target import ChatTarget
|
| 29 |
+
|
| 30 |
+
TIER_RANK = {"low": 0, "medium": 1, "high": 2}
|
| 31 |
+
|
| 32 |
+
# Frontier families: strong and tool-calling whatever the size suffix says.
|
| 33 |
+
_FRONTIER = re.compile(
|
| 34 |
+
r"(claude|openai|gpt-?[45o]|gpt-oss|\bo[134]\b|gemini|grok|kimi|glm-?[45]|deepseek-?v[3-9]|"
|
| 35 |
+
r"mistral-large|command-?a|llama-?4|qwen3?-?(?:235|max|coder-?480)|minimax|muse)",
|
| 36 |
+
re.I,
|
| 37 |
+
)
|
| 38 |
+
# Families that call tools reliably once they are big enough.
|
| 39 |
+
_TOOL_FAMILIES = re.compile(
|
| 40 |
+
r"(qwen2\.5|qwen3|llama-?3\.[1-9]|llama-?4|mistral|mixtral|gpt-oss|command|hermes|granite|"
|
| 41 |
+
r"phi-?4|deepseek-?v[3-9]|glm|gemma-?[34]|kimi|claude|openai|gpt|gemini|grok|muse)",
|
| 42 |
+
re.I,
|
| 43 |
+
)
|
| 44 |
+
# Reasoning distills that answer well but do not call tools (Ollama rejects the field).
|
| 45 |
+
_NO_TOOLS = re.compile(r"deepseek-r1", re.I)
|
| 46 |
+
_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*b\b", re.I)
|
| 47 |
+
|
| 48 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 49 |
+
_CACHE_TTL_S = 30.0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class ModelProfile:
|
| 54 |
+
name: str
|
| 55 |
+
params_b: Optional[float]
|
| 56 |
+
tier: str
|
| 57 |
+
tools_likely: bool
|
| 58 |
+
reason: str
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def rank(self) -> int:
|
| 62 |
+
return TIER_RANK.get(self.tier, 0)
|
| 63 |
+
|
| 64 |
+
def as_dict(self) -> Dict[str, Any]:
|
| 65 |
+
d = asdict(self)
|
| 66 |
+
d["rank"] = self.rank
|
| 67 |
+
return d
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def params_from_name(name: str) -> Optional[float]:
|
| 71 |
+
"""``internal-qwen3-8-27b`` β 27, ``gpt-oss-120b`` β 120, ``deepseek-r1:latest`` β None."""
|
| 72 |
+
hits = [float(m.group(1)) for m in _SIZE.finditer(name or "")]
|
| 73 |
+
return max(hits) if hits else None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def profile(name: str, params_b: Optional[float] = None) -> ModelProfile:
|
| 77 |
+
params = params_b if params_b is not None else params_from_name(name)
|
| 78 |
+
frontier = bool(_FRONTIER.search(name or ""))
|
| 79 |
+
no_tools = bool(_NO_TOOLS.search(name or ""))
|
| 80 |
+
tools_likely = (frontier or bool(_TOOL_FAMILIES.search(name or ""))) and not no_tools
|
| 81 |
+
if frontier and (params is None or params >= EXPERT_DEEP_MIN_PARAMS_B or not _SIZE.search(name or "")):
|
| 82 |
+
return ModelProfile(name, params, "high", tools_likely, "frontier family")
|
| 83 |
+
if params is not None and params >= EXPERT_DEEP_MIN_PARAMS_B:
|
| 84 |
+
return ModelProfile(name, params, "high", tools_likely, f"{params:g}B parameters")
|
| 85 |
+
if params is not None and params >= 7:
|
| 86 |
+
return ModelProfile(name, params, "medium", tools_likely, f"{params:g}B parameters")
|
| 87 |
+
if params is None:
|
| 88 |
+
tier = "medium" if tools_likely else "low"
|
| 89 |
+
return ModelProfile(name, None, tier, tools_likely, "size unknown")
|
| 90 |
+
return ModelProfile(name, params, "low", tools_likely, f"{params:g}B parameters")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def best_of(profiles: List[ModelProfile]) -> Optional[ModelProfile]:
|
| 94 |
+
if not profiles:
|
| 95 |
+
return None
|
| 96 |
+
return max(profiles, key=lambda p: (p.rank, 1 if p.tools_likely else 0, p.params_b or 0.0))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def deep_research_enabled(best: Optional[ModelProfile], verified: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 100 |
+
"""``verified``: the probe's findings for ``best`` (see :func:`probe`); a model known to
|
| 101 |
+
reject tools cannot research, whatever its size."""
|
| 102 |
+
mode = EXPERT_DEEP_RESEARCH
|
| 103 |
+
if mode == "always":
|
| 104 |
+
return {"enabled": True, "reason": "EXPERT_DEEP_RESEARCH=always", "model": best.name if best else None}
|
| 105 |
+
if mode == "off":
|
| 106 |
+
return {"enabled": False, "reason": "Deep research is turned off (EXPERT_DEEP_RESEARCH=off).", "model": None}
|
| 107 |
+
if best is None:
|
| 108 |
+
return {"enabled": False, "reason": "No model answered on this connection.", "model": None}
|
| 109 |
+
if best.tier == "high":
|
| 110 |
+
if verified and verified.get("tools") == "no":
|
| 111 |
+
# The harness runs the searches for a model that cannot call tools (deep.py).
|
| 112 |
+
return {"enabled": True, "model": best.name,
|
| 113 |
+
"reason": f"{best.name} is a high-end model ({best.reason}); it cannot call tools, so HomePilot runs the searches for it."}
|
| 114 |
+
return {"enabled": True, "reason": f"{best.name} is a high-end model ({best.reason}).", "model": best.name}
|
| 115 |
+
return {
|
| 116 |
+
"enabled": False,
|
| 117 |
+
"reason": (
|
| 118 |
+
f"Deep research needs a high-end model (a frontier family or β₯ {EXPERT_DEEP_MIN_PARAMS_B:g}B); "
|
| 119 |
+
f"the strongest here is {best.name} ({best.reason})."
|
| 120 |
+
),
|
| 121 |
+
"model": None,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _connection_key(target: Optional[ChatTarget]) -> str:
|
| 126 |
+
if target is None:
|
| 127 |
+
return "ollama|"
|
| 128 |
+
return f"{target.provider}|{target.base_url}"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def list_profiles(target: Optional[ChatTarget]) -> List[ModelProfile]:
|
| 132 |
+
"""Every model on the connection ``target`` names (the default Ollama when None)."""
|
| 133 |
+
if target is None or target.is_ollama:
|
| 134 |
+
rows = await providers.ollama_models_detail(target.base_url if target and target.base_url else None)
|
| 135 |
+
return [profile(r["name"], r.get("params_b")) for r in rows if r.get("name")]
|
| 136 |
+
names = await providers.openai_compat_models(target.base_url, target.api_key)
|
| 137 |
+
return [profile(n) for n in names]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ββ Probes: measured facts, cached in the state store ββββββββββββββββββββββββ
|
| 141 |
+
|
| 142 |
+
PING_TOOL = [{
|
| 143 |
+
"type": "function",
|
| 144 |
+
"function": {
|
| 145 |
+
"name": "ping",
|
| 146 |
+
"description": "Reports that tool calling works. Call it with the given word.",
|
| 147 |
+
"parameters": {"type": "object", "properties": {"word": {"type": "string"}}, "required": ["word"]},
|
| 148 |
+
},
|
| 149 |
+
}]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def probe_key(target: Optional[ChatTarget], model: Optional[str]) -> str:
|
| 153 |
+
from .tool_loop import capability_key # the loop's learned facts use the same key
|
| 154 |
+
return capability_key(target, "local", model)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def verified_for(target: Optional[ChatTarget], model: Optional[str]) -> Optional[Dict[str, Any]]:
|
| 158 |
+
"""The probe's findings for this connection and model, when they are on record. A fact the
|
| 159 |
+
tool loop learned by itself (a refusal, a successful call) counts as well."""
|
| 160 |
+
key = probe_key(target, model)
|
| 161 |
+
found = state_store.kv_get(f"probe:{key}")
|
| 162 |
+
if found:
|
| 163 |
+
return found
|
| 164 |
+
learned = state_store.kv_get(f"tools:{key}")
|
| 165 |
+
if learned is not None and isinstance(learned.get("ok"), bool):
|
| 166 |
+
return {"tools": "yes" if learned["ok"] else "no", "json": "unknown", "source": learned.get("source", "observed"),
|
| 167 |
+
"probed_at": learned.get("at")}
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def probe(target: Optional[ChatTarget], model: Optional[str], *, force: bool = False) -> Dict[str, Any]:
|
| 172 |
+
"""Two short requests, once per model: does it return a structured tool call, and does it
|
| 173 |
+
follow a tiny JSON instruction. Findings are kept for EXPERT_CAPABILITY_TTL_S."""
|
| 174 |
+
from .router import dispatch
|
| 175 |
+
from .providers import ToolsUnsupported
|
| 176 |
+
from .tool_loop import remember_tools
|
| 177 |
+
|
| 178 |
+
key = probe_key(target, model)
|
| 179 |
+
if not force:
|
| 180 |
+
cached = state_store.kv_get(f"probe:{key}")
|
| 181 |
+
if cached:
|
| 182 |
+
return cached
|
| 183 |
+
result: Dict[str, Any] = {"tools": "unclear", "json": "no", "source": "probe", "probed_at": time.time(), "errors": []}
|
| 184 |
+
with target_mod.using(target):
|
| 185 |
+
try:
|
| 186 |
+
raw = await dispatch(
|
| 187 |
+
[{"role": "system", "content": "You have one tool, ping. When asked, call it; do not answer in prose."},
|
| 188 |
+
{"role": "user", "content": "Call ping with the word \"pong\"."}],
|
| 189 |
+
"local", model=model, temperature=0.0, max_tokens=64, tools=PING_TOOL,
|
| 190 |
+
)
|
| 191 |
+
calls = (((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("tool_calls") or []
|
| 192 |
+
result["tools"] = "yes" if calls else "unclear"
|
| 193 |
+
except ToolsUnsupported as exc:
|
| 194 |
+
result["tools"] = "no"
|
| 195 |
+
result["errors"].append(str(exc)[:200])
|
| 196 |
+
except Exception as exc: # noqa: BLE001
|
| 197 |
+
result["tools"] = "error"
|
| 198 |
+
result["errors"].append(f"{type(exc).__name__}: {exc}"[:200])
|
| 199 |
+
try:
|
| 200 |
+
raw = await dispatch(
|
| 201 |
+
[{"role": "user", "content": 'Reply with exactly this JSON and nothing else: {"ok": true}'}],
|
| 202 |
+
"local", model=model, temperature=0.0, max_tokens=32,
|
| 203 |
+
)
|
| 204 |
+
text = str((((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
|
| 205 |
+
m = re.search(r"\{.*\}", text, flags=re.S)
|
| 206 |
+
result["json"] = "yes" if m and json.loads(m.group(0)).get("ok") is True else "no"
|
| 207 |
+
except Exception as exc: # noqa: BLE001
|
| 208 |
+
result["errors"].append(f"{type(exc).__name__}: {exc}"[:200])
|
| 209 |
+
if result["tools"] in ("yes", "no"):
|
| 210 |
+
remember_tools(key, result["tools"] == "yes", source="probe")
|
| 211 |
+
if result["tools"] != "error":
|
| 212 |
+
state_store.kv_set(f"probe:{key}", result, ttl_s=EXPERT_CAPABILITY_TTL_S)
|
| 213 |
+
return result
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def assess(target: Optional[ChatTarget], selected_model: Optional[str] = None, *, run_probe: bool = False) -> Dict[str, Any]:
|
| 217 |
+
"""The selected model's profile, the best one on the connection, and the Deep research gate.
|
| 218 |
+
``run_probe`` measures the selected model's tool calling once (cached) instead of guessing."""
|
| 219 |
+
key = _connection_key(target)
|
| 220 |
+
now = time.monotonic()
|
| 221 |
+
cached = _CACHE.get(key)
|
| 222 |
+
if cached and now - cached["ts"] < _CACHE_TTL_S:
|
| 223 |
+
profiles: List[ModelProfile] = cached["profiles"]
|
| 224 |
+
else:
|
| 225 |
+
profiles = await list_profiles(target)
|
| 226 |
+
_CACHE[key] = {"ts": now, "profiles": profiles}
|
| 227 |
+
|
| 228 |
+
selected_name = selected_model or (target.model if target else "") or EXPERT_LOCAL_MODEL
|
| 229 |
+
by_name = {p.name: p for p in profiles}
|
| 230 |
+
selected = by_name.get(selected_name)
|
| 231 |
+
if selected is None:
|
| 232 |
+
stem = selected_name.split(":")[0]
|
| 233 |
+
selected = next((p for p in profiles if p.name.split(":")[0] == stem), None) or profile(selected_name)
|
| 234 |
+
best = best_of(profiles) or selected
|
| 235 |
+
|
| 236 |
+
verified = verified_for(target, selected.name)
|
| 237 |
+
if run_probe and verified is None:
|
| 238 |
+
try:
|
| 239 |
+
verified = await probe(target, selected.name)
|
| 240 |
+
except Exception: # noqa: BLE001
|
| 241 |
+
verified = None
|
| 242 |
+
if verified and verified.get("tools") in ("yes", "no"):
|
| 243 |
+
selected = ModelProfile(selected.name, selected.params_b, selected.tier, verified["tools"] == "yes", selected.reason)
|
| 244 |
+
# The gate judges the model the user chose: it is the model for every mode, Deep research
|
| 245 |
+
# included. ``best`` stays in the answer as information.
|
| 246 |
+
gate = deep_research_enabled(selected, verified)
|
| 247 |
+
return {
|
| 248 |
+
"connection": "ollama" if (target is None or target.is_ollama) else (target.provider or "openai_compat"),
|
| 249 |
+
"selected": selected.as_dict(),
|
| 250 |
+
"best": best.as_dict() if best else None,
|
| 251 |
+
"tier": selected.tier,
|
| 252 |
+
"verified": verified,
|
| 253 |
+
"models": [p.as_dict() for p in sorted(profiles, key=lambda p: (-p.rank, -(p.params_b or 0)))][:40],
|
| 254 |
+
"deep_research": gate,
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def stronger(best: Optional[Dict[str, Any]], selected: Dict[str, Any]) -> bool:
|
| 259 |
+
"""Whether ``best`` is a meaningful step up from ``selected`` (Heavy's escalation, Deep
|
| 260 |
+
research's model choice): a higher tier, or β below the high tier β the same tier with at
|
| 261 |
+
least twice the known parameters. A high-end model the user picked is never swapped for
|
| 262 |
+
another high-end one."""
|
| 263 |
+
if not best or best.get("name") == selected.get("name"):
|
| 264 |
+
return False
|
| 265 |
+
best_rank, sel_rank = best.get("rank", 0), selected.get("rank", 0)
|
| 266 |
+
if best_rank > sel_rank:
|
| 267 |
+
return True
|
| 268 |
+
if best_rank < sel_rank or sel_rank >= TIER_RANK["high"]:
|
| 269 |
+
return False
|
| 270 |
+
bp, sp = best.get("params_b"), selected.get("params_b")
|
| 271 |
+
return bool(bp and sp and bp >= 2 * sp)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def reset_cache() -> None:
|
| 275 |
+
_CACHE.clear()
|
expert/backend/app/expert/compaction.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compaction: turns older than the window become a running note instead of vanishing.
|
| 2 |
+
|
| 3 |
+
What ChatGPT and Claude do when a thread outgrows the context: keep the recent turns
|
| 4 |
+
verbatim, and carry the rest as a short memory note β facts, decisions, preferences, open
|
| 5 |
+
questions β refreshed incrementally. One small model call per overflow, cached per session.
|
| 6 |
+
The note travels like grounding does: a context variable that ``build_messages`` appends to
|
| 7 |
+
the system prompt of every pipeline step.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import logging
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
from contextlib import contextmanager
|
| 16 |
+
from contextvars import ContextVar
|
| 17 |
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
| 18 |
+
|
| 19 |
+
from .config import (
|
| 20 |
+
EXPERT_COMPACTION_ENABLED,
|
| 21 |
+
EXPERT_COMPACTION_MAX_INPUT_CHARS,
|
| 22 |
+
EXPERT_COMPACTION_MAX_TOKENS,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("expert.compaction")
|
| 26 |
+
|
| 27 |
+
_CURRENT: ContextVar[str] = ContextVar("expert_compaction_note", default="")
|
| 28 |
+
_CACHE: Dict[str, Dict[str, Any]] = {}
|
| 29 |
+
_CACHE_MAX = 500
|
| 30 |
+
|
| 31 |
+
SUMMARY_SYSTEM = (
|
| 32 |
+
"You maintain a compact memory note of a conversation. Update the note with the new turns: "
|
| 33 |
+
"keep facts, decisions, preferences, names, numbers, open questions and the user's goals; "
|
| 34 |
+
"drop chit-chat. Write in the third person, at most 250 words, no preamble."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def apply(system_prompt: str) -> str:
|
| 39 |
+
"""``system_prompt`` with this request's note appended (unchanged when there is none)."""
|
| 40 |
+
note = _CURRENT.get()
|
| 41 |
+
if not note:
|
| 42 |
+
return system_prompt
|
| 43 |
+
return f"{system_prompt}\n\nSummary of the earlier conversation (turns not shown):\n{note}"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@contextmanager
|
| 47 |
+
def using(note: Optional[str]) -> Iterator[str]:
|
| 48 |
+
token = _CURRENT.set((note or "").strip())
|
| 49 |
+
try:
|
| 50 |
+
yield _CURRENT.get()
|
| 51 |
+
finally:
|
| 52 |
+
_CURRENT.reset(token)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _digest(turns: List[Dict[str, Any]]) -> str:
|
| 56 |
+
h = hashlib.sha1()
|
| 57 |
+
for t in turns:
|
| 58 |
+
h.update(f"{t.get('role')}\x1f{t.get('content')}\x1e".encode("utf-8", "replace"))
|
| 59 |
+
return h.hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def split(history: Optional[List[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
| 63 |
+
"""``(older, window)``: the window exactly as the pipelines will see it, and the clean turns
|
| 64 |
+
before it."""
|
| 65 |
+
from .router import clean_turns, history_window
|
| 66 |
+
|
| 67 |
+
clean = clean_turns(history)
|
| 68 |
+
window = history_window(clean)
|
| 69 |
+
older = clean[: len(clean) - len(window)] if window else clean
|
| 70 |
+
return older, window
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def render(turns: List[Dict[str, Any]], max_chars: int) -> str:
|
| 74 |
+
"""The turns as a transcript, newest kept when the budget cuts."""
|
| 75 |
+
lines = [f"{'User' if t.get('role') == 'user' else 'Assistant'}: {t.get('content', '')}" for t in turns]
|
| 76 |
+
text = "\n".join(lines)
|
| 77 |
+
if len(text) > max_chars:
|
| 78 |
+
text = "β¦" + text[-max_chars:]
|
| 79 |
+
return text
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _clean(summary: str) -> str:
|
| 83 |
+
summary = re.sub(r"<think>.*?</think>", "", summary or "", flags=re.S)
|
| 84 |
+
return summary.strip()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def summarize(previous: str, transcript: str, provider: str, model: Optional[str], temperature: float) -> str:
|
| 88 |
+
from .router import dispatch
|
| 89 |
+
|
| 90 |
+
messages = [
|
| 91 |
+
{"role": "system", "content": SUMMARY_SYSTEM},
|
| 92 |
+
{"role": "user", "content": f"Current note:\n{previous or '(none yet)'}\n\nNew turns:\n{transcript}\n\nUpdated note:"},
|
| 93 |
+
]
|
| 94 |
+
raw = await dispatch(messages, provider, model=model, temperature=temperature, max_tokens=EXPERT_COMPACTION_MAX_TOKENS)
|
| 95 |
+
try:
|
| 96 |
+
return _clean(raw["choices"][0]["message"]["content"] or "")
|
| 97 |
+
except (KeyError, IndexError, TypeError):
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _remember(session_id: str, key: str, count: int, note: str) -> None:
|
| 102 |
+
if len(_CACHE) >= _CACHE_MAX:
|
| 103 |
+
oldest = min(_CACHE.items(), key=lambda kv: kv[1]["ts"])[0]
|
| 104 |
+
_CACHE.pop(oldest, None)
|
| 105 |
+
_CACHE[session_id] = {"key": key, "count": count, "note": note, "ts": time.monotonic()}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def cached_note(session_id: Optional[str]) -> Optional[str]:
|
| 109 |
+
entry = _CACHE.get(session_id or "")
|
| 110 |
+
return entry["note"] if entry else None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def needs_summary(history: Optional[List[Dict[str, Any]]], session_id: Optional[str]) -> int:
|
| 114 |
+
"""How many older turns :func:`prepare` would summarize with a model call now (0: nothing
|
| 115 |
+
overflowed, or the session's note already covers them). No model call β the stream uses it
|
| 116 |
+
to show "Compacting the conversationβ¦" before the summary starts."""
|
| 117 |
+
if not EXPERT_COMPACTION_ENABLED:
|
| 118 |
+
return 0
|
| 119 |
+
older, _window = split(history)
|
| 120 |
+
if not older:
|
| 121 |
+
return 0
|
| 122 |
+
key = _digest(older)
|
| 123 |
+
cached = _CACHE.get(session_id or "")
|
| 124 |
+
if cached and cached["key"] == key:
|
| 125 |
+
return 0
|
| 126 |
+
if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]:
|
| 127 |
+
return len(older) - cached["count"]
|
| 128 |
+
return len(older)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def prepare(
|
| 132 |
+
history: Optional[List[Dict[str, Any]]],
|
| 133 |
+
*,
|
| 134 |
+
session_id: Optional[str],
|
| 135 |
+
provider: str,
|
| 136 |
+
model: Optional[str] = None,
|
| 137 |
+
temperature: float = 0.2,
|
| 138 |
+
) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str]]:
|
| 139 |
+
"""``(window, note, notice)``: the turns to send verbatim, the note for the ones before
|
| 140 |
+
them (None when nothing overflowed), and a user-facing notice when summarizing failed.
|
| 141 |
+
|
| 142 |
+
Incremental per session: a thread whose overflow grew by two turns summarizes those two
|
| 143 |
+
on top of the previous note, not the whole history again."""
|
| 144 |
+
older, window = split(history)
|
| 145 |
+
if not EXPERT_COMPACTION_ENABLED or not older:
|
| 146 |
+
return window, None, None
|
| 147 |
+
key = _digest(older)
|
| 148 |
+
cached = _CACHE.get(session_id or "")
|
| 149 |
+
if cached and cached["key"] == key:
|
| 150 |
+
return window, cached["note"], None
|
| 151 |
+
previous, new_turns = "", older
|
| 152 |
+
if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]:
|
| 153 |
+
previous, new_turns = cached["note"], older[cached["count"]:]
|
| 154 |
+
transcript = render(new_turns, EXPERT_COMPACTION_MAX_INPUT_CHARS)
|
| 155 |
+
try:
|
| 156 |
+
note = await summarize(previous, transcript, provider, model, temperature)
|
| 157 |
+
except Exception as exc: # noqa: BLE001
|
| 158 |
+
logger.warning("compaction: summarizing %d older turns failed: %s", len(new_turns), exc)
|
| 159 |
+
notice = (
|
| 160 |
+
f"The earlier part of this conversation could not be summarized ({type(exc).__name__}); "
|
| 161 |
+
f"only the last {len(window)} turns are in context."
|
| 162 |
+
)
|
| 163 |
+
return window, previous or None, notice
|
| 164 |
+
if not note:
|
| 165 |
+
return window, previous or None, None
|
| 166 |
+
if session_id:
|
| 167 |
+
_remember(session_id, key, len(older), note)
|
| 168 |
+
return window, note, None
|
expert/backend/app/expert/config.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/config.py
|
| 2 |
+
# All Expert module env vars β purely additive, no conflict with existing config.py
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# ββ xAI Grok ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
GROK_API_KEY: str = os.getenv("GROK_API_KEY", "").strip()
|
| 9 |
+
GROK_BASE_URL: str = os.getenv("GROK_BASE_URL", "https://api.x.ai/v1").rstrip("/")
|
| 10 |
+
GROK_MODEL: str = os.getenv("GROK_MODEL", "grok-3").strip()
|
| 11 |
+
|
| 12 |
+
# ββ Groq (ultra-fast open-model inference, free tier available) ββββββββββββββ
|
| 13 |
+
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "").strip()
|
| 14 |
+
GROQ_BASE_URL: str = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/")
|
| 15 |
+
GROQ_MODEL: str = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip()
|
| 16 |
+
|
| 17 |
+
# ββ Google Gemini ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "").strip()
|
| 19 |
+
GEMINI_BASE_URL: str = os.getenv(
|
| 20 |
+
"GEMINI_BASE_URL",
|
| 21 |
+
"https://generativelanguage.googleapis.com/v1beta/openai"
|
| 22 |
+
).rstrip("/")
|
| 23 |
+
GEMINI_MODEL: str = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip()
|
| 24 |
+
|
| 25 |
+
# ββ Local Ollama (free, on-premises) βββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
# Reuses existing OLLAMA_BASE_URL from main config if not overridden
|
| 27 |
+
EXPERT_OLLAMA_URL: str = os.getenv(
|
| 28 |
+
"EXPERT_OLLAMA_URL",
|
| 29 |
+
os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 30 |
+
).rstrip("/")
|
| 31 |
+
# Default chosen so out-of-the-box Expert runs on a 12 GB consumer GPU.
|
| 32 |
+
# Operators with larger VRAM (or a cloud deploy) can point this at
|
| 33 |
+
# ``deepseek-r1:32b`` or similar for top-tier reasoning quality.
|
| 34 |
+
# If the configured model isn't pulled, chat_local falls back to any
|
| 35 |
+
# installed model via EXPERT_LOCAL_AUTO_FALLBACK β Expert never hard-fails
|
| 36 |
+
# on "model not found" as long as Ollama has at least one model.
|
| 37 |
+
EXPERT_LOCAL_MODEL: str = os.getenv("EXPERT_LOCAL_MODEL", "deepseek-r1:latest").strip()
|
| 38 |
+
EXPERT_LOCAL_FAST_MODEL: str = os.getenv("EXPERT_LOCAL_FAST_MODEL", "llama3.2:3b").strip()
|
| 39 |
+
|
| 40 |
+
# ββ Routing thresholds ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
# Complexity scores (0-10) that determine which tier to use
|
| 42 |
+
EXPERT_LOCAL_THRESHOLD: int = int(os.getenv("EXPERT_LOCAL_THRESHOLD", "3")) # β€3 β local fast
|
| 43 |
+
EXPERT_GROQ_THRESHOLD: int = int(os.getenv("EXPERT_GROQ_THRESHOLD", "6")) # β€6 β Groq 70B
|
| 44 |
+
# >6 β Grok / Gemini / best available cloud
|
| 45 |
+
|
| 46 |
+
# ββ Expert system identity ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
# "Expert" is the name of this pipeline (Fast / Think / Heavy), not a persona. The old
|
| 48 |
+
# prompt opened "You are Expert, an advanced AI assistant integrated into HomePilot", and
|
| 49 |
+
# models took it literally: a plain "hello" came back as "Hello! I am Expert, your HomePilot
|
| 50 |
+
# assistant. How can I help you today?". The assistant should sound like a capable colleague,
|
| 51 |
+
# not announce a product name.
|
| 52 |
+
EXPERT_SYSTEM_PROMPT: str = os.getenv(
|
| 53 |
+
"EXPERT_SYSTEM_PROMPT",
|
| 54 |
+
"You are a knowledgeable, professional assistant. Talk the way a thoughtful colleague "
|
| 55 |
+
"would: natural, warm and direct.\n"
|
| 56 |
+
"\n"
|
| 57 |
+
"- Reply in the language the user writes in.\n"
|
| 58 |
+
"- Fit the length to the request. A greeting or a quick question gets a short, friendly "
|
| 59 |
+
"reply; a complex problem gets a clear, well-structured answer that reasons it through.\n"
|
| 60 |
+
"- Do not introduce yourself, give yourself a name or describe what you are unless the "
|
| 61 |
+
"user asks.\n"
|
| 62 |
+
"- Skip filler and formula: no \"Great question!\", no \"As an AIβ¦\", no stock sign-off "
|
| 63 |
+
"at the end of every answer.\n"
|
| 64 |
+
"- Be accurate. If something is uncertain or depends on information you do not have, say "
|
| 65 |
+
"so briefly instead of guessing. Never invent facts, figures or sources.\n"
|
| 66 |
+
"- Use Markdown (lists, headings, code blocks) only when it makes the answer easier to "
|
| 67 |
+
"read."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# ββ Streaming ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
EXPERT_STREAM_CHUNK_SIZE: int = int(os.getenv("EXPERT_STREAM_CHUNK_SIZE", "64"))
|
| 72 |
+
EXPERT_MAX_TOKENS: int = int(os.getenv("EXPERT_MAX_TOKENS", "2048"))
|
| 73 |
+
EXPERT_TEMPERATURE: float = float(os.getenv("EXPERT_TEMPERATURE", "0.7"))
|
| 74 |
+
# Conversation window handed to every pipeline call β fast, think and heavy alike: the newest
|
| 75 |
+
# turns that fit both budgets. The chat sends its last 8 turns; API callers may send any number.
|
| 76 |
+
EXPERT_HISTORY_MAX_TURNS: int = int(os.getenv("EXPERT_HISTORY_MAX_TURNS", "12"))
|
| 77 |
+
EXPERT_HISTORY_MAX_CHARS: int = int(os.getenv("EXPERT_HISTORY_MAX_CHARS", "16000"))
|
| 78 |
+
# Compaction: turns older than the window are summarized into a running note instead of being
|
| 79 |
+
# dropped (compaction.py). One short model call per overflow, cached per session.
|
| 80 |
+
EXPERT_COMPACTION_ENABLED: bool = os.getenv("EXPERT_COMPACTION_ENABLED", "1").lower() in ("1", "true", "yes")
|
| 81 |
+
EXPERT_COMPACTION_MAX_TOKENS: int = int(os.getenv("EXPERT_COMPACTION_MAX_TOKENS", "400"))
|
| 82 |
+
EXPERT_COMPACTION_MAX_INPUT_CHARS: int = int(os.getenv("EXPERT_COMPACTION_MAX_INPUT_CHARS", "24000"))
|
| 83 |
+
|
| 84 |
+
# ββ Tools (tools.py, tool_loop.py) βββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
# The per-turn tool loop: the model may call a curated set of HomePilot's MCP tools (web search,
|
| 86 |
+
# page fetch, workspace and notes search) while answering, ChatGPT-style. Servers speak JSON-RPC
|
| 87 |
+
# tools/list + tools/call on /rpc β HomePilot's own agentic servers by default; Context Forge's
|
| 88 |
+
# /rpc speaks the same protocol and can be added for federated tools.
|
| 89 |
+
EXPERT_TOOLS_ENABLED: bool = os.getenv("EXPERT_TOOLS_ENABLED", "1").lower() in ("1", "true", "yes")
|
| 90 |
+
EXPERT_TOOL_SERVERS: str = os.getenv(
|
| 91 |
+
"EXPERT_TOOL_SERVERS",
|
| 92 |
+
"http://127.0.0.1:9105/rpc,http://127.0.0.1:9102/rpc,http://127.0.0.1:9101/rpc",
|
| 93 |
+
)
|
| 94 |
+
# Native tool names offered to the model (comma-separated; '*' for every tool the servers list).
|
| 95 |
+
EXPERT_TOOL_ALLOWLIST: str = os.getenv(
|
| 96 |
+
"EXPERT_TOOL_ALLOWLIST",
|
| 97 |
+
"hp.web.search,hp.web.fetch,hp.search_workspace,hp.get_document,hp.personal.search",
|
| 98 |
+
)
|
| 99 |
+
EXPERT_TOOL_MAX_ROUNDS: int = int(os.getenv("EXPERT_TOOL_MAX_ROUNDS", "4"))
|
| 100 |
+
EXPERT_TOOL_MAX_CALLS_PER_ROUND: int = int(os.getenv("EXPERT_TOOL_MAX_CALLS_PER_ROUND", "4"))
|
| 101 |
+
EXPERT_TOOL_TIMEOUT_S: float = float(os.getenv("EXPERT_TOOL_TIMEOUT_S", "45"))
|
| 102 |
+
EXPERT_TOOL_RESULT_MAX_CHARS: int = int(os.getenv("EXPERT_TOOL_RESULT_MAX_CHARS", "6000"))
|
| 103 |
+
|
| 104 |
+
# ββ Heavy: escalate to the strongest model on the same connection (routes.py) β
|
| 105 |
+
# Off by default: the model chosen in the chat is the model for every mode. Set to 1 to let
|
| 106 |
+
# Heavy move to a clearly stronger model of the same connection, with a notice.
|
| 107 |
+
EXPERT_HEAVY_ESCALATE: bool = os.getenv("EXPERT_HEAVY_ESCALATE", "0").lower() in ("1", "true", "yes")
|
| 108 |
+
|
| 109 |
+
# ββ Effort profiles (policies.py): what each mode may spend, not what it must β
|
| 110 |
+
# A mode sets the ceiling; the request's complexity decides how much of it is used. A greeting
|
| 111 |
+
# in Heavy is one generation; a hard question gets the full pipeline.
|
| 112 |
+
EXPERT_FAST_TEMPERATURE: float = float(os.getenv("EXPERT_FAST_TEMPERATURE", "0.7"))
|
| 113 |
+
EXPERT_FAST_MAX_TOKENS: int = int(os.getenv("EXPERT_FAST_MAX_TOKENS", "1024"))
|
| 114 |
+
EXPERT_FAST_TOOL_ROUNDS: int = int(os.getenv("EXPERT_FAST_TOOL_ROUNDS", "2"))
|
| 115 |
+
EXPERT_THINK_TEMPERATURE: float = float(os.getenv("EXPERT_THINK_TEMPERATURE", "0.4"))
|
| 116 |
+
EXPERT_THINK_MAX_TOKENS: int = int(os.getenv("EXPERT_THINK_MAX_TOKENS", "2048"))
|
| 117 |
+
EXPERT_THINK_TOOL_ROUNDS: int = int(os.getenv("EXPERT_THINK_TOOL_ROUNDS", "4"))
|
| 118 |
+
EXPERT_THINK_PLAN_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_THINK_PLAN_MIN_COMPLEXITY", "4"))
|
| 119 |
+
EXPERT_HEAVY_TEMPERATURE: float = float(os.getenv("EXPERT_HEAVY_TEMPERATURE", "0.3"))
|
| 120 |
+
EXPERT_HEAVY_MAX_TOKENS: int = int(os.getenv("EXPERT_HEAVY_MAX_TOKENS", "3072"))
|
| 121 |
+
EXPERT_HEAVY_TOOL_ROUNDS: int = int(os.getenv("EXPERT_HEAVY_TOOL_ROUNDS", "4"))
|
| 122 |
+
EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY", "5"))
|
| 123 |
+
EXPERT_DEEP_TEMPERATURE: float = float(os.getenv("EXPERT_DEEP_TEMPERATURE", "0.3"))
|
| 124 |
+
|
| 125 |
+
# ββ Durable state (state_store.py): capability facts, Deep research jobs βββββ
|
| 126 |
+
# Default: <DATA_DIR or backend/data>/expert_state.sqlite.
|
| 127 |
+
EXPERT_STATE_DB: str = os.getenv("EXPERT_STATE_DB", "").strip()
|
| 128 |
+
EXPERT_CAPABILITY_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_TTL_S", str(7 * 24 * 3600)))
|
| 129 |
+
# A learned refusal ("this connection rejects the tools field") is re-checked sooner than a
|
| 130 |
+
# success: gateways get fixed, and re-learning costs one rejected request plus a retry.
|
| 131 |
+
EXPERT_CAPABILITY_NEGATIVE_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_NEGATIVE_TTL_S", "3600"))
|
| 132 |
+
# `: keep-alive` SSE comment after this many silent seconds on /stream (0 disables): a long model
|
| 133 |
+
# call or a slow tool no longer looks like a dead connection to proxies, browsers, or the reader.
|
| 134 |
+
EXPERT_SSE_HEARTBEAT_S: float = float(os.getenv("EXPERT_SSE_HEARTBEAT_S", "10"))
|
| 135 |
+
|
| 136 |
+
# ββ Adaptive pacing and retries for OpenAI-compatible endpoints (providers.py) β
|
| 137 |
+
# One gateway is never flooded: at most MAX_INFLIGHT requests at a time, and a gap between
|
| 138 |
+
# request starts, per base URL. The gap starts at MIN_INTERVAL_S, doubles on every rate limit
|
| 139 |
+
# (up to MAX_INTERVAL_S, and every caller waits out the cooldown) and shrinks back 15 % per
|
| 140 |
+
# success β Heavy's agents and Deep research's sub-agents adapt to the gateway's real limit.
|
| 141 |
+
# Requests beyond MAX_INFLIGHT wait in a queue, a person's ahead of background work (Deep
|
| 142 |
+
# research). A 429, a body that says "rate limit", or a 502/503/504 is retried with exponential
|
| 143 |
+
# backoff and jitter (about 2 s, 4 s, 8 s, 16 s, 20 sβ¦; Retry-After is a floor), always before
|
| 144 |
+
# the first token, for up to WAIT_S seconds in a chat turn and BACKGROUND_WAIT_S in a background
|
| 145 |
+
# job (RETRIES caps the tries). Meanwhile the chat shows "The model provider is busy; retryingβ¦".
|
| 146 |
+
EXPERT_PROVIDER_MAX_INFLIGHT: int = int(os.getenv("EXPERT_PROVIDER_MAX_INFLIGHT", "2"))
|
| 147 |
+
EXPERT_PROVIDER_MIN_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MIN_INTERVAL_S", "1.0"))
|
| 148 |
+
EXPERT_PROVIDER_MAX_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MAX_INTERVAL_S", "15"))
|
| 149 |
+
EXPERT_PROVIDER_RETRIES: int = int(os.getenv("EXPERT_PROVIDER_RETRIES", "8"))
|
| 150 |
+
EXPERT_PROVIDER_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_WAIT_S", "90"))
|
| 151 |
+
EXPERT_PROVIDER_BACKGROUND_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_BACKGROUND_WAIT_S", "300"))
|
| 152 |
+
# The usage meter's rolling window. On a rate limit the endpoint's cap is learned as the
|
| 153 |
+
# successes this window took; new requests then wait their turn to stay under it, and the cap
|
| 154 |
+
# grows by one after three quiet windows (GET /v1/expert/usage shows it all).
|
| 155 |
+
EXPERT_PROVIDER_WINDOW_S: float = float(os.getenv("EXPERT_PROVIDER_WINDOW_S", "60"))
|
| 156 |
+
|
| 157 |
+
# ββ Deep research (deep.py, capability.py) βββββββββββββββββββββββββββββββββββ
|
| 158 |
+
# auto: offered when the connection has a high-end model (capability.py); always / off override.
|
| 159 |
+
EXPERT_DEEP_RESEARCH: str = os.getenv("EXPERT_DEEP_RESEARCH", "auto").strip().lower() or "auto"
|
| 160 |
+
EXPERT_DEEP_MIN_PARAMS_B: float = float(os.getenv("EXPERT_DEEP_MIN_PARAMS_B", "20"))
|
| 161 |
+
EXPERT_DEEP_TIME_BUDGET_S: float = float(os.getenv("EXPERT_DEEP_TIME_BUDGET_S", "300"))
|
| 162 |
+
EXPERT_DEEP_MAX_ITEMS: int = int(os.getenv("EXPERT_DEEP_MAX_ITEMS", "5"))
|
| 163 |
+
EXPERT_DEEP_ROUNDS_PER_ITEM: int = int(os.getenv("EXPERT_DEEP_ROUNDS_PER_ITEM", "3"))
|
| 164 |
+
EXPERT_DEEP_JOB_TTL_S: float = float(os.getenv("EXPERT_DEEP_JOB_TTL_S", "3600"))
|
| 165 |
+
# 120s accommodates CPU-only Ollama setups with mid-size thinking models
|
| 166 |
+
# (e.g. deepseek-r1 / phi4) where a single analysis pass can easily exceed
|
| 167 |
+
# 45s. Think mode chains three such calls, so keeping the per-call timeout
|
| 168 |
+
# tight caused ``[expert chat] TimeoutError β 502`` on local dev boxes even
|
| 169 |
+
# when the model was making progress. Override via env for prod deploys
|
| 170 |
+
# where faster providers enforce a shorter SLA.
|
| 171 |
+
EXPERT_PROVIDER_TIMEOUT_S: float = float(os.getenv("EXPERT_PROVIDER_TIMEOUT_S", "120"))
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def available_expert_providers() -> list[str]:
|
| 175 |
+
"""Return which expert providers are configured (have API keys or local URL)."""
|
| 176 |
+
providers = []
|
| 177 |
+
# Local is always available if Ollama is reachable (no key needed)
|
| 178 |
+
providers.append("local")
|
| 179 |
+
if GROQ_API_KEY:
|
| 180 |
+
providers.append("groq")
|
| 181 |
+
if GROK_API_KEY:
|
| 182 |
+
providers.append("grok")
|
| 183 |
+
if GEMINI_API_KEY:
|
| 184 |
+
providers.append("gemini")
|
| 185 |
+
# Claude / OpenAI reuse existing HomePilot keys β check them
|
| 186 |
+
if os.getenv("ANTHROPIC_API_KEY", "").strip():
|
| 187 |
+
providers.append("claude")
|
| 188 |
+
if os.getenv("OPENAI_API_KEY", "").strip():
|
| 189 |
+
providers.append("openai")
|
| 190 |
+
return providers
|
expert/backend/app/expert/deep.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deep research β a bounded deep-agent run: plan, delegate, gather with tools, write.
|
| 2 |
+
|
| 3 |
+
The harness primitives behind ChatGPT's and Claude's "deep research" modes, kept small and
|
| 4 |
+
local-first, with no extra dependency:
|
| 5 |
+
|
| 6 |
+
* a **plan** the model writes first (a few sub-questions), shown to the user as it runs;
|
| 7 |
+
* one **sub-agent per plan item**, each with a clean context and the per-turn tool loop
|
| 8 |
+
(web search, page fetch, workspace and notes, the project's documents), reporting findings
|
| 9 |
+
with [n] citations;
|
| 10 |
+
* a shared **source book** that renumbers every sub-agent's citations into one list;
|
| 11 |
+
* a **writer** pass that turns findings and sources into a cited report, streamed;
|
| 12 |
+
* a **time budget** and per-item call budget that end the run gracefully;
|
| 13 |
+
* a **durable job**: every event is numbered and written to the state store with the job's
|
| 14 |
+
snapshot, so a client can reconnect after a refresh (``?after=<seq>``) and a finished
|
| 15 |
+
report survives a restart. A job interrupted by a restart is marked so, never left running.
|
| 16 |
+
|
| 17 |
+
Gate: ``capability.py`` β offered only when the connection has a high-end model.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import re
|
| 25 |
+
import time
|
| 26 |
+
import uuid
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from typing import Any, Dict, List, Optional
|
| 29 |
+
|
| 30 |
+
from .config import (
|
| 31 |
+
EXPERT_DEEP_JOB_TTL_S,
|
| 32 |
+
EXPERT_DEEP_MAX_ITEMS,
|
| 33 |
+
EXPERT_DEEP_ROUNDS_PER_ITEM,
|
| 34 |
+
EXPERT_DEEP_TEMPERATURE,
|
| 35 |
+
EXPERT_DEEP_TIME_BUDGET_S,
|
| 36 |
+
EXPERT_MAX_TOKENS,
|
| 37 |
+
EXPERT_SYSTEM_PROMPT,
|
| 38 |
+
)
|
| 39 |
+
from .policies import is_trivial
|
| 40 |
+
from .providers import mark_background
|
| 41 |
+
from .router import dispatch, step_messages, stream_provider_events
|
| 42 |
+
from .tool_loop import SourceBook, ThinkFilter, capability_key, stream_with_tools, tools_known
|
| 43 |
+
from . import compaction
|
| 44 |
+
from . import grounding
|
| 45 |
+
from . import state_store
|
| 46 |
+
from . import target as target_mod
|
| 47 |
+
|
| 48 |
+
logger = logging.getLogger("expert.deep")
|
| 49 |
+
|
| 50 |
+
PLANNER_SYSTEM = (
|
| 51 |
+
"You are the lead researcher. Break the user's request into the few sub-questions whose "
|
| 52 |
+
"answers, together, settle it. Each must be self-contained and searchable. Return JSON only: "
|
| 53 |
+
'{{"questions": ["...", "..."]}} with 2 to {max_items} items.'
|
| 54 |
+
)
|
| 55 |
+
RESEARCHER_SYSTEM = (
|
| 56 |
+
"You are a research sub-agent. Answer the sub-question with facts found with your tools: "
|
| 57 |
+
"search, read the best pages, and report findings with the [n] numbers of the results you "
|
| 58 |
+
"relied on. Be concrete β names, numbers, dates. Say when sources disagree or when nothing "
|
| 59 |
+
"reliable was found. Keep it under 300 words."
|
| 60 |
+
)
|
| 61 |
+
WRITER_SYSTEM = (
|
| 62 |
+
"You are the writer. From the findings and the numbered sources, write the report the user "
|
| 63 |
+
"asked for: a short answer first, then sections with headings, specific facts, and [n] "
|
| 64 |
+
"citations right after the sentences they support. Note gaps and disagreements plainly. "
|
| 65 |
+
"Never invent a source or a number."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
FINAL_STATES = ("done", "failed", "cancelled")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _with_operator(system: str, operator_prompt: Optional[str]) -> str:
|
| 72 |
+
"""A role prompt plus the operator's context (a project's instructions) when given."""
|
| 73 |
+
if operator_prompt and operator_prompt.strip():
|
| 74 |
+
return f"{system}\n\nContext from the operator:\n{operator_prompt.strip()}"
|
| 75 |
+
return system
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class PlanItem:
|
| 80 |
+
id: str
|
| 81 |
+
title: str
|
| 82 |
+
status: str = "pending" # pending | running | done | skipped
|
| 83 |
+
finding: str = ""
|
| 84 |
+
sources: List[int] = field(default_factory=list)
|
| 85 |
+
|
| 86 |
+
def as_dict(self) -> Dict[str, Any]:
|
| 87 |
+
return {"id": self.id, "title": self.title, "status": self.status, "sources": list(self.sources),
|
| 88 |
+
"summary": self.finding[:300]}
|
| 89 |
+
|
| 90 |
+
@classmethod
|
| 91 |
+
def from_dict(cls, d: Dict[str, Any]) -> "PlanItem":
|
| 92 |
+
return cls(id=str(d.get("id") or ""), title=str(d.get("title") or ""), status=str(d.get("status") or "pending"),
|
| 93 |
+
finding=str(d.get("summary") or d.get("finding") or ""), sources=list(d.get("sources") or []))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class DeepJob:
|
| 98 |
+
id: str
|
| 99 |
+
query: str
|
| 100 |
+
provider: str
|
| 101 |
+
model: Optional[str]
|
| 102 |
+
session_id: Optional[str] = None
|
| 103 |
+
status: str = "queued" # queued | planning | researching | writing | done | failed | cancelled
|
| 104 |
+
plan: List[PlanItem] = field(default_factory=list)
|
| 105 |
+
report: str = ""
|
| 106 |
+
sources: List[Dict[str, Any]] = field(default_factory=list)
|
| 107 |
+
notices: List[str] = field(default_factory=list)
|
| 108 |
+
error: str = ""
|
| 109 |
+
created: float = field(default_factory=time.time)
|
| 110 |
+
finished: Optional[float] = None
|
| 111 |
+
seq: int = 0
|
| 112 |
+
events: List[Dict[str, Any]] = field(default_factory=list)
|
| 113 |
+
subscribers: List["asyncio.Queue[Dict[str, Any]]"] = field(default_factory=list)
|
| 114 |
+
task: Optional["asyncio.Task[None]"] = None
|
| 115 |
+
|
| 116 |
+
# ββ events: numbered, fanned out, written down ββββββββββββββββββββββββββ
|
| 117 |
+
def emit(self, event: Dict[str, Any]) -> None:
|
| 118 |
+
self.seq += 1
|
| 119 |
+
event = {**event, "seq": self.seq}
|
| 120 |
+
self.events.append(event)
|
| 121 |
+
state_store.event_append(self.id, self.seq, event)
|
| 122 |
+
if event["type"] != "token":
|
| 123 |
+
self.persist()
|
| 124 |
+
for q in list(self.subscribers):
|
| 125 |
+
q.put_nowait(event)
|
| 126 |
+
|
| 127 |
+
def subscribe(self, after: int = 0) -> "asyncio.Queue[Dict[str, Any]]":
|
| 128 |
+
q: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
|
| 129 |
+
for e in self.events:
|
| 130 |
+
if int(e.get("seq", 0)) > after:
|
| 131 |
+
q.put_nowait(e)
|
| 132 |
+
self.subscribers.append(q)
|
| 133 |
+
return q
|
| 134 |
+
|
| 135 |
+
def unsubscribe(self, q: "asyncio.Queue[Dict[str, Any]]") -> None:
|
| 136 |
+
if q in self.subscribers:
|
| 137 |
+
self.subscribers.remove(q)
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def finished_state(self) -> bool:
|
| 141 |
+
return self.status in FINAL_STATES
|
| 142 |
+
|
| 143 |
+
def cancel(self) -> None:
|
| 144 |
+
if self.task and not self.task.done():
|
| 145 |
+
self.task.cancel()
|
| 146 |
+
|
| 147 |
+
def snapshot(self) -> Dict[str, Any]:
|
| 148 |
+
end = self.finished or time.time()
|
| 149 |
+
return {
|
| 150 |
+
"job_id": self.id, "status": self.status, "query": self.query, "model": self.model,
|
| 151 |
+
"provider": self.provider, "session_id": self.session_id,
|
| 152 |
+
"plan": [p.as_dict() for p in self.plan], "report": self.report, "sources": self.sources,
|
| 153 |
+
"notices": list(self.notices), "error": self.error, "elapsed_s": round(end - self.created, 1),
|
| 154 |
+
"seq": self.seq, "created": self.created, "finished": self.finished,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
def persist(self) -> None:
|
| 158 |
+
state_store.job_save(self.id, self.status, self.snapshot(), created=self.created)
|
| 159 |
+
|
| 160 |
+
@classmethod
|
| 161 |
+
def from_snapshot(cls, snap: Dict[str, Any], events: List[Dict[str, Any]]) -> "DeepJob":
|
| 162 |
+
job = cls(
|
| 163 |
+
id=str(snap.get("job_id") or ""), query=str(snap.get("query") or ""),
|
| 164 |
+
provider=str(snap.get("provider") or "local"), model=snap.get("model"), session_id=snap.get("session_id"),
|
| 165 |
+
status=str(snap.get("status") or "failed"),
|
| 166 |
+
plan=[PlanItem.from_dict(p) for p in snap.get("plan") or []],
|
| 167 |
+
report=str(snap.get("report") or ""), sources=list(snap.get("sources") or []),
|
| 168 |
+
notices=list(snap.get("notices") or []), error=str(snap.get("error") or ""),
|
| 169 |
+
created=float(snap.get("created") or time.time()), finished=snap.get("finished"),
|
| 170 |
+
)
|
| 171 |
+
job.events = list(events)
|
| 172 |
+
job.seq = max([int(e.get("seq", 0)) for e in events] + [int(snap.get("seq") or 0)])
|
| 173 |
+
return job
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
JOBS: Dict[str, DeepJob] = {}
|
| 177 |
+
_RECOVERED = False
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def recover_interrupted() -> int:
|
| 181 |
+
"""Jobs the store says were running when the process last stopped: there is no task
|
| 182 |
+
behind them any more, so they are closed as failed, with the reason on record."""
|
| 183 |
+
global _RECOVERED
|
| 184 |
+
if _RECOVERED:
|
| 185 |
+
return 0
|
| 186 |
+
_RECOVERED = True
|
| 187 |
+
count = 0
|
| 188 |
+
for job_id in state_store.jobs_unfinished():
|
| 189 |
+
row = state_store.job_load(job_id)
|
| 190 |
+
if not row:
|
| 191 |
+
continue
|
| 192 |
+
job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0))
|
| 193 |
+
job.status = "failed"
|
| 194 |
+
job.error = "Interrupted: HomePilot restarted while this research was running."
|
| 195 |
+
job.finished = time.time()
|
| 196 |
+
job.seq += 1
|
| 197 |
+
event = {"type": "error", "error": job.error, "seq": job.seq}
|
| 198 |
+
job.events.append(event)
|
| 199 |
+
state_store.event_append(job.id, job.seq, event)
|
| 200 |
+
job.persist()
|
| 201 |
+
count += 1
|
| 202 |
+
if count:
|
| 203 |
+
logger.info("deep research: closed %d job(s) interrupted by a restart", count)
|
| 204 |
+
return count
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_job(job_id: str) -> Optional[DeepJob]:
|
| 208 |
+
"""A live job, or a finished one read back from the store."""
|
| 209 |
+
recover_interrupted()
|
| 210 |
+
job = JOBS.get(job_id)
|
| 211 |
+
if job is not None:
|
| 212 |
+
return job
|
| 213 |
+
row = state_store.job_load(job_id)
|
| 214 |
+
if not row:
|
| 215 |
+
return None
|
| 216 |
+
job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0))
|
| 217 |
+
JOBS[job_id] = job
|
| 218 |
+
return job
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _prune() -> None:
|
| 222 |
+
now = time.time()
|
| 223 |
+
for key, job in list(JOBS.items()):
|
| 224 |
+
if job.finished and now - job.finished > EXPERT_DEEP_JOB_TTL_S:
|
| 225 |
+
JOBS.pop(key, None)
|
| 226 |
+
state_store.jobs_prune(EXPERT_DEEP_JOB_TTL_S * 24)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def parse_plan(text: str, query: str, max_items: int) -> List[str]:
|
| 230 |
+
"""The planner's sub-questions: JSON when it obeyed, a bullet or numbered list when it did
|
| 231 |
+
not, the request itself when nothing usable came back."""
|
| 232 |
+
text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S)
|
| 233 |
+
questions: List[str] = []
|
| 234 |
+
m = re.search(r"\{.*\}", text, flags=re.S)
|
| 235 |
+
if m:
|
| 236 |
+
try:
|
| 237 |
+
data = json.loads(m.group(0))
|
| 238 |
+
qs = data.get("questions") if isinstance(data, dict) else None
|
| 239 |
+
if isinstance(qs, list):
|
| 240 |
+
questions = [str(q).strip() for q in qs if str(q).strip()]
|
| 241 |
+
except json.JSONDecodeError:
|
| 242 |
+
pass
|
| 243 |
+
if not questions:
|
| 244 |
+
for line in text.splitlines():
|
| 245 |
+
if re.match(r"^\s*(?:[-*β’]|\d+[.)])\s+\S", line):
|
| 246 |
+
questions.append(re.sub(r"^\s*(?:[-*β’]|\d+[.)])\s*", "", line).strip().strip('"'))
|
| 247 |
+
questions = [q for q in questions if len(q) > 3][:max_items]
|
| 248 |
+
return questions or [query]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _short(exc: Exception) -> str:
|
| 252 |
+
"""One line of an error for a notice."""
|
| 253 |
+
return " ".join(str(exc).split())[:160] or type(exc).__name__
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _findings_report(plan: List["PlanItem"], sources: List[Dict[str, Any]]) -> str:
|
| 257 |
+
"""The report when the writer cannot run: each researched question with its finding, and
|
| 258 |
+
the numbered sources the findings cite."""
|
| 259 |
+
blocks = [f"### {p.title}\n\n{p.finding.strip()}" for p in plan if p.finding and p.finding.strip()]
|
| 260 |
+
out = "\n\n".join(blocks)
|
| 261 |
+
if sources:
|
| 262 |
+
out += "\n\n**Sources**\n\n" + "\n".join(f"[{s['n']}] {s.get('title') or s['url']} β {s['url']}" for s in sources)
|
| 263 |
+
return out
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def renumber(text: str, mapping: Dict[int, int]) -> str:
|
| 267 |
+
"""A sub-agent's local [n] citations rewritten to the job-wide numbers."""
|
| 268 |
+
return re.sub(r"\[(\d+)\]", lambda m: f"[{mapping.get(int(m.group(1)), int(m.group(1)))}]", text or "")
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def start(
|
| 272 |
+
query: str,
|
| 273 |
+
*,
|
| 274 |
+
provider: str,
|
| 275 |
+
model: Optional[str],
|
| 276 |
+
history: List[Dict[str, Any]],
|
| 277 |
+
chosen: Optional[target_mod.ChatTarget],
|
| 278 |
+
timezone: Optional[str],
|
| 279 |
+
context: Optional[str],
|
| 280 |
+
note: Optional[str],
|
| 281 |
+
notices: Optional[List[str]] = None,
|
| 282 |
+
registry: Any = None,
|
| 283 |
+
operator_prompt: Optional[str] = None,
|
| 284 |
+
session_id: Optional[str] = None,
|
| 285 |
+
) -> DeepJob:
|
| 286 |
+
"""Create the job and run it in the background; the caller returns its id at once."""
|
| 287 |
+
recover_interrupted()
|
| 288 |
+
_prune()
|
| 289 |
+
job = DeepJob(id=uuid.uuid4().hex[:12], query=query, provider=provider, model=model, session_id=session_id)
|
| 290 |
+
job.notices.extend(notices or [])
|
| 291 |
+
JOBS[job.id] = job
|
| 292 |
+
job.persist()
|
| 293 |
+
|
| 294 |
+
async def runner() -> None:
|
| 295 |
+
with target_mod.using(chosen), grounding.using(timezone, context), compaction.using(note):
|
| 296 |
+
await _run(job, list(history or []), registry=registry, operator_prompt=operator_prompt)
|
| 297 |
+
|
| 298 |
+
job.task = asyncio.create_task(runner())
|
| 299 |
+
return job
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
async def _run(job: DeepJob, history: List[Dict[str, Any]], *, registry: Any = None,
|
| 303 |
+
operator_prompt: Optional[str] = None) -> None:
|
| 304 |
+
started = time.monotonic()
|
| 305 |
+
budget = EXPERT_DEEP_TIME_BUDGET_S
|
| 306 |
+
kw = dict(model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=EXPERT_MAX_TOKENS)
|
| 307 |
+
book = SourceBook()
|
| 308 |
+
# A research job is background work: on a busy endpoint, a person's chat goes first.
|
| 309 |
+
mark_background()
|
| 310 |
+
try:
|
| 311 |
+
if is_trivial(job.query):
|
| 312 |
+
# A greeting needs no plan, no searches and no report: one reply, and the reason.
|
| 313 |
+
# (It also spares a shared endpoint the five to eight calls a research job makes.)
|
| 314 |
+
job.status = "writing"
|
| 315 |
+
job.emit({"type": "status", "status": "writing"})
|
| 316 |
+
job.notices.append("A short message needs no research; it was answered directly.")
|
| 317 |
+
parts: List[str] = []
|
| 318 |
+
thinking = ThinkFilter()
|
| 319 |
+
async for ev in stream_provider_events(
|
| 320 |
+
step_messages(job.query, system_prompt=_with_operator(EXPERT_SYSTEM_PROMPT, operator_prompt), history=history),
|
| 321 |
+
job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=512,
|
| 322 |
+
):
|
| 323 |
+
if "delta" in ev:
|
| 324 |
+
clean = thinking.feed(ev["delta"])
|
| 325 |
+
if clean:
|
| 326 |
+
parts.append(clean)
|
| 327 |
+
job.emit({"type": "token", "content": clean})
|
| 328 |
+
tail = thinking.flush()
|
| 329 |
+
if tail:
|
| 330 |
+
parts.append(tail)
|
| 331 |
+
job.emit({"type": "token", "content": tail})
|
| 332 |
+
job.report = "".join(parts)
|
| 333 |
+
job.status = "done"
|
| 334 |
+
job.finished = time.time()
|
| 335 |
+
job.emit({
|
| 336 |
+
"type": "done", "report": job.report, "sources": [], "notices": list(job.notices),
|
| 337 |
+
"plan": [], "elapsed_s": round(time.monotonic() - started, 1),
|
| 338 |
+
})
|
| 339 |
+
return
|
| 340 |
+
|
| 341 |
+
# 1. Plan
|
| 342 |
+
job.status = "planning"
|
| 343 |
+
job.emit({"type": "status", "status": "planning"})
|
| 344 |
+
try:
|
| 345 |
+
raw = await dispatch(
|
| 346 |
+
step_messages(
|
| 347 |
+
f"Request:\n{job.query}\n\nReturn the JSON plan.",
|
| 348 |
+
system_prompt=_with_operator(PLANNER_SYSTEM.format(max_items=EXPERT_DEEP_MAX_ITEMS), operator_prompt),
|
| 349 |
+
history=history,
|
| 350 |
+
),
|
| 351 |
+
job.provider, model=job.model, temperature=0.2, max_tokens=600,
|
| 352 |
+
)
|
| 353 |
+
plan_text = raw["choices"][0]["message"]["content"] or ""
|
| 354 |
+
except (KeyError, IndexError, TypeError):
|
| 355 |
+
plan_text = ""
|
| 356 |
+
except Exception as exc: # noqa: BLE001 - no plan is not no research
|
| 357 |
+
logger.warning("Deep research %s: planner failed, researching the request as one question: %s", job.id, exc)
|
| 358 |
+
job.notices.append(f"The planner could not run ({_short(exc)}); the request was researched as one question.")
|
| 359 |
+
plan_text = ""
|
| 360 |
+
job.plan = [PlanItem(id=f"q{i + 1}", title=q) for i, q in enumerate(parse_plan(plan_text, job.query, EXPERT_DEEP_MAX_ITEMS))]
|
| 361 |
+
job.emit({"type": "plan", "items": [p.as_dict() for p in job.plan]})
|
| 362 |
+
|
| 363 |
+
# 2. Research, one sub-agent per item, within the time budget. Every item starts with a
|
| 364 |
+
# web search run by HomePilot (the question as the query), so the findings are
|
| 365 |
+
# grounded whether or not the model can call tools itself; a capable model may search
|
| 366 |
+
# further on its own.
|
| 367 |
+
job.status = "researching"
|
| 368 |
+
job.emit({"type": "status", "status": "researching"})
|
| 369 |
+
if tools_known(capability_key(target_mod.current(), job.provider, job.model)) is False:
|
| 370 |
+
job.notices.append("This model cannot call tools; HomePilot ran the web searches for it.")
|
| 371 |
+
out_of_time = False
|
| 372 |
+
for item in job.plan:
|
| 373 |
+
if time.monotonic() - started > budget:
|
| 374 |
+
if not out_of_time:
|
| 375 |
+
out_of_time = True
|
| 376 |
+
job.notices.append(f"Time budget of {budget:g}s reached; the remaining items were skipped.")
|
| 377 |
+
item.status = "skipped"
|
| 378 |
+
job.emit({"type": "item", "id": item.id, "status": "skipped", "title": item.title})
|
| 379 |
+
continue
|
| 380 |
+
item.status = "running"
|
| 381 |
+
job.emit({"type": "item", "id": item.id, "status": "running", "title": item.title})
|
| 382 |
+
messages = step_messages(
|
| 383 |
+
f"Overall request: {job.query}\n\nSub-question: {item.title}",
|
| 384 |
+
system_prompt=_with_operator(RESEARCHER_SYSTEM, operator_prompt), history=history,
|
| 385 |
+
)
|
| 386 |
+
finding, local_sources = "", []
|
| 387 |
+
try:
|
| 388 |
+
async for ev in stream_with_tools(messages, job.provider, max_rounds=EXPERT_DEEP_ROUNDS_PER_ITEM,
|
| 389 |
+
registry=registry, forced=[("hp_web_search", {"query": item.title})], **kw):
|
| 390 |
+
if ev["type"] in ("tool_start", "tool_end", "wait"):
|
| 391 |
+
job.emit({**ev, "item": item.id})
|
| 392 |
+
elif ev["type"] == "notice":
|
| 393 |
+
if ev["text"] not in job.notices:
|
| 394 |
+
job.notices.append(ev["text"])
|
| 395 |
+
job.emit({**ev, "item": item.id})
|
| 396 |
+
elif ev["type"] == "done":
|
| 397 |
+
finding, local_sources = ev.get("content") or "", ev.get("sources") or []
|
| 398 |
+
except Exception as exc: # noqa: BLE001 - one question down is not the report down
|
| 399 |
+
logger.warning("Deep research %s: item %s failed: %s", job.id, item.id, exc)
|
| 400 |
+
item.status = "failed"
|
| 401 |
+
item.finding = ""
|
| 402 |
+
note = f"β{item.title}β could not be researched ({_short(exc)})."
|
| 403 |
+
if note not in job.notices:
|
| 404 |
+
job.notices.append(note)
|
| 405 |
+
job.emit({"type": "item", "id": item.id, "status": "failed", "title": item.title,
|
| 406 |
+
"summary": f"Could not be researched: {_short(exc)}"})
|
| 407 |
+
continue
|
| 408 |
+
mapping: Dict[int, int] = {}
|
| 409 |
+
for s in local_sources:
|
| 410 |
+
book.add([s])
|
| 411 |
+
numbered = book.numbered([s])
|
| 412 |
+
if numbered:
|
| 413 |
+
mapping[int(s.get("n", 0))] = numbered[0]["n"]
|
| 414 |
+
item.finding = renumber(finding, mapping)
|
| 415 |
+
item.sources = sorted(set(mapping.values()))
|
| 416 |
+
item.status = "done"
|
| 417 |
+
job.sources = list(book.items)
|
| 418 |
+
job.emit({"type": "item", "id": item.id, "status": "done", "title": item.title,
|
| 419 |
+
"summary": item.finding[:300], "sources": item.sources})
|
| 420 |
+
if book.items:
|
| 421 |
+
job.emit({"type": "sources", "sources": list(book.items)})
|
| 422 |
+
|
| 423 |
+
# 3. Write
|
| 424 |
+
job.status = "writing"
|
| 425 |
+
job.emit({"type": "status", "status": "writing"})
|
| 426 |
+
findings = "\n\n".join(
|
| 427 |
+
f"## {p.title}\n{p.finding or ('(could not be researched)' if p.status == 'failed' else '(not researched: out of time)')}"
|
| 428 |
+
for p in job.plan
|
| 429 |
+
)
|
| 430 |
+
source_lines = "\n".join(
|
| 431 |
+
f"[{s['n']}] {s.get('title') or s['url']} β {s['url']}" for s in book.items
|
| 432 |
+
) or "(no sources were found; say so and answer from general knowledge, marked as such)"
|
| 433 |
+
writer_messages = step_messages(
|
| 434 |
+
f"Request:\n{job.query}\n\nFindings:\n{findings}\n\nSources:\n{source_lines}\n\nWrite the report.",
|
| 435 |
+
system_prompt=_with_operator(WRITER_SYSTEM, operator_prompt), history=history,
|
| 436 |
+
)
|
| 437 |
+
parts: List[str] = []
|
| 438 |
+
thinking = ThinkFilter()
|
| 439 |
+
try:
|
| 440 |
+
async for ev in stream_provider_events(
|
| 441 |
+
writer_messages, job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE,
|
| 442 |
+
max_tokens=max(EXPERT_MAX_TOKENS, 3000),
|
| 443 |
+
):
|
| 444 |
+
if "delta" in ev:
|
| 445 |
+
clean = thinking.feed(ev["delta"])
|
| 446 |
+
if clean:
|
| 447 |
+
parts.append(clean)
|
| 448 |
+
job.emit({"type": "token", "content": clean})
|
| 449 |
+
elif ev.get("notice") and ev["notice"] not in job.notices:
|
| 450 |
+
job.notices.append(ev["notice"])
|
| 451 |
+
except Exception as exc: # noqa: BLE001 - the findings are the report's substance
|
| 452 |
+
researched = [p for p in job.plan if p.finding]
|
| 453 |
+
if not researched and not parts:
|
| 454 |
+
raise
|
| 455 |
+
logger.warning("Deep research %s: writer failed, returning the findings: %s", job.id, exc)
|
| 456 |
+
job.notices.append(f"The writer could not finish ({_short(exc)}); the findings are shown as gathered.")
|
| 457 |
+
fallback = _findings_report(job.plan, book.items)
|
| 458 |
+
text = ("\n\n---\n\n" if parts else "") + fallback
|
| 459 |
+
parts.append(text)
|
| 460 |
+
job.emit({"type": "token", "content": text})
|
| 461 |
+
tail = thinking.flush()
|
| 462 |
+
if tail:
|
| 463 |
+
parts.append(tail)
|
| 464 |
+
job.emit({"type": "token", "content": tail})
|
| 465 |
+
job.report = "".join(parts)
|
| 466 |
+
job.status = "done"
|
| 467 |
+
job.finished = time.time()
|
| 468 |
+
job.emit({
|
| 469 |
+
"type": "done", "report": job.report, "sources": job.sources, "notices": list(job.notices),
|
| 470 |
+
"plan": [p.as_dict() for p in job.plan], "elapsed_s": round(time.monotonic() - started, 1),
|
| 471 |
+
})
|
| 472 |
+
except asyncio.CancelledError:
|
| 473 |
+
job.status = "cancelled"
|
| 474 |
+
job.finished = time.time()
|
| 475 |
+
job.emit({"type": "cancelled"})
|
| 476 |
+
except Exception as exc: # noqa: BLE001
|
| 477 |
+
logger.error("Deep research %s failed: %s", job.id, exc, exc_info=True)
|
| 478 |
+
job.status = "failed"
|
| 479 |
+
job.error = f"{type(exc).__name__}: {exc}"
|
| 480 |
+
job.finished = time.time()
|
| 481 |
+
job.emit({"type": "error", "error": job.error})
|
expert/backend/app/expert/eval_bench.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class EvalCase:
|
| 9 |
+
query: str
|
| 10 |
+
expected_keywords: List[str]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class EvalOutcome:
|
| 15 |
+
score: float
|
| 16 |
+
passed: bool
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def score_output(text: str, expected_keywords: List[str]) -> float:
|
| 20 |
+
if not expected_keywords:
|
| 21 |
+
return 1.0
|
| 22 |
+
hits = sum(1 for k in expected_keywords if k.lower() in text.lower())
|
| 23 |
+
return hits / len(expected_keywords)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def regression_gate(outcomes: List[EvalOutcome], min_avg: float = 0.75) -> bool:
|
| 27 |
+
if not outcomes:
|
| 28 |
+
return False
|
| 29 |
+
avg = sum(o.score for o in outcomes) / len(outcomes)
|
| 30 |
+
return avg >= min_avg and all(o.passed for o in outcomes)
|
expert/backend/app/expert/grounding.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What one Expert request is grounded in: today's date, and β when the chat searched the web β
|
| 2 |
+
the numbered sources HomePilot found (``backend/app/web_search.py``).
|
| 3 |
+
|
| 4 |
+
Expert's three pipelines build their own prompts (fast: ``router.build_messages``; think:
|
| 5 |
+
``thinking._msg``; heavy: ``heavy._msg``), and think and heavy never see the history. So the
|
| 6 |
+
grounding rides on the system prompt itself, through every step, the way ``target.py`` carries
|
| 7 |
+
the chosen model: a context variable set for the request, applied with :func:`apply`.
|
| 8 |
+
|
| 9 |
+
Without the date a model answers "which day is today?" from its training data; without sources
|
| 10 |
+
it recites old news as current. Both happened in Fast mode before this.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from contextlib import contextmanager
|
| 16 |
+
from contextvars import ContextVar
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from typing import Iterator, Optional
|
| 19 |
+
|
| 20 |
+
_CURRENT: ContextVar[str] = ContextVar("expert_grounding", default="")
|
| 21 |
+
|
| 22 |
+
#: The most a request may add to the prompt (a web-results block is ~5-8 kB).
|
| 23 |
+
MAX_CONTEXT_CHARS = 24_000
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def date_line(timezone: Optional[str] = None, now: Optional[datetime] = None) -> str:
|
| 27 |
+
"""Today's date and time, in the user's zone when the browser sent one."""
|
| 28 |
+
moment = (now or datetime.now()).astimezone()
|
| 29 |
+
zone = ""
|
| 30 |
+
if timezone:
|
| 31 |
+
try:
|
| 32 |
+
from zoneinfo import ZoneInfo
|
| 33 |
+
|
| 34 |
+
moment = moment.astimezone(ZoneInfo(timezone))
|
| 35 |
+
zone = timezone
|
| 36 |
+
except Exception: # noqa: BLE001 β an unknown name falls back to the server's zone
|
| 37 |
+
zone = ""
|
| 38 |
+
zone = zone or (moment.tzname() or "")
|
| 39 |
+
stamp = moment.strftime("%A, %B %d, %Y, %H:%M").replace(" 0", " ")
|
| 40 |
+
return (
|
| 41 |
+
f"Current date and time: {stamp}{f' ({zone})' if zone else ''}. Use it for anything that "
|
| 42 |
+
"depends on today's date. Your own knowledge stops at your training cutoff: for news, prices, "
|
| 43 |
+
"scores or anything else that may have changed since, say you cannot check live information "
|
| 44 |
+
"unless web results are provided, and never invent recent events."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@contextmanager
|
| 49 |
+
def using(timezone: Optional[str] = None, context: Optional[str] = None) -> Iterator[str]:
|
| 50 |
+
"""Ground every prompt built inside the block in today's date and ``context``."""
|
| 51 |
+
parts = [date_line(timezone)]
|
| 52 |
+
extra = (context or "").strip()
|
| 53 |
+
if extra:
|
| 54 |
+
parts.append(extra[:MAX_CONTEXT_CHARS])
|
| 55 |
+
token = _CURRENT.set("\n\n".join(parts))
|
| 56 |
+
try:
|
| 57 |
+
yield _CURRENT.get()
|
| 58 |
+
finally:
|
| 59 |
+
_CURRENT.reset(token)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def apply(system_prompt: str) -> str:
|
| 63 |
+
"""``system_prompt`` with this request's grounding appended (unchanged outside a request)."""
|
| 64 |
+
extra = _CURRENT.get()
|
| 65 |
+
return f"{system_prompt}\n\n{extra}" if extra else system_prompt
|
expert/backend/app/expert/heavy.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/heavy.py
|
| 2 |
+
# Multi-agent "heavy" pipeline for the most complex queries.
|
| 3 |
+
#
|
| 4 |
+
# Runs 4 specialized agents sequentially, each with a distinct role:
|
| 5 |
+
#
|
| 6 |
+
# Agent 1 β RESEARCHER: Deep information gathering + context building
|
| 7 |
+
# Agent 2 β REASONER: Logical inference and chain-of-thought over research
|
| 8 |
+
# Agent 3 β SYNTHESIZER: Condense into a clear, well-structured answer
|
| 9 |
+
# Agent 4 β VALIDATOR: Final sanity check + quality gate
|
| 10 |
+
#
|
| 11 |
+
# Each agent can use a different provider (e.g. Groq for fast research,
|
| 12 |
+
# Grok for deep reasoning) if configured via HEAVY_*_PROVIDER env vars.
|
| 13 |
+
# Falls back to the caller's selected provider for all agents.
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
|
| 21 |
+
|
| 22 |
+
from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT
|
| 23 |
+
from .router import (
|
| 24 |
+
dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName,
|
| 25 |
+
)
|
| 26 |
+
from .tool_loop import ThinkFilter, run_with_tools, stream_with_tools, strip_think
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("expert.heavy")
|
| 29 |
+
|
| 30 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
# Per-agent provider overrides (optional β falls back to caller's provider)
|
| 32 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
def _agent_provider(agent: str, default: ProviderName) -> ProviderName:
|
| 35 |
+
env_key = f"HEAVY_{agent.upper()}_PROVIDER"
|
| 36 |
+
val = os.getenv(env_key, "").strip()
|
| 37 |
+
return val if val else default # type: ignore[return-value]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
# Agent system prompts
|
| 42 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
|
| 44 |
+
_RESEARCHER_SYSTEM = (
|
| 45 |
+
"You are a deep research agent. Your role is to gather all relevant "
|
| 46 |
+
"context, background knowledge, related concepts, and potential edge cases "
|
| 47 |
+
"for a given topic. Be exhaustive and precise. Cite what you know."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
_REASONER_SYSTEM = (
|
| 51 |
+
"You are a logical reasoning agent. Given a research brief, your job is to "
|
| 52 |
+
"reason step-by-step to conclusions. Use deductive and inductive logic. "
|
| 53 |
+
"Identify cause-and-effect relationships. Expose hidden assumptions."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
_SYNTHESIZER_SYSTEM = (
|
| 57 |
+
"You are a synthesis agent. Given detailed research and reasoning, produce "
|
| 58 |
+
"a clear, well-structured, concise final answer. Use headers if helpful. "
|
| 59 |
+
"Prioritize clarity and actionability. Eliminate redundancy."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
_VALIDATOR_SYSTEM = (
|
| 63 |
+
"You are a validation agent. Your job is to review a synthesized answer "
|
| 64 |
+
"for accuracy, completeness, and logical consistency. "
|
| 65 |
+
"Reply with JSON only, in this shape: "
|
| 66 |
+
'{"verdict": "validated" | "corrected" | "needs_review", '
|
| 67 |
+
'"issues": [{"severity": "high" | "medium" | "low", "text": "..."}], '
|
| 68 |
+
'"corrected_answer": "the full corrected answer, only when verdict is corrected" | null, '
|
| 69 |
+
'"method": "model review", "limitations": "what you could not verify"}. '
|
| 70 |
+
"Use \"corrected\" only when you can supply the complete corrected answer; use "
|
| 71 |
+
"\"needs_review\" when something important is doubtful but you cannot fix it; "
|
| 72 |
+
"use \"validated\" when the answer holds."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
VERDICTS = ("validated", "corrected", "needs_review", "reviewed")
|
| 76 |
+
|
| 77 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
# Agent prompts
|
| 79 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
|
| 81 |
+
_RESEARCH_PROMPT = "Research this topic comprehensively:\n\n{input}"
|
| 82 |
+
|
| 83 |
+
_REASON_PROMPT = (
|
| 84 |
+
"Using this research brief, reason step-by-step toward an answer:\n\n"
|
| 85 |
+
"Research:\n{research}\n\nOriginal question: {input}"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
_REASON_DIRECT_PROMPT = (
|
| 89 |
+
"Reason step-by-step toward an answer. Use your tools when a fact, a figure or a current "
|
| 90 |
+
"detail matters.\n\nQuestion: {input}"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
_SYNTHESIZE_PROMPT = (
|
| 94 |
+
"Synthesize a final answer from this reasoning:\n\n"
|
| 95 |
+
"Question: {input}\n\n"
|
| 96 |
+
"Reasoning:\n{reasoning}"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
_VALIDATE_PROMPT = (
|
| 100 |
+
"Validate this answer for the given question:\n\n"
|
| 101 |
+
"Question: {input}\n\n"
|
| 102 |
+
"Answer:\n{synthesis}"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _role(system: str, operator: Optional[str]) -> str:
|
| 107 |
+
"""An agent's role prompt, plus the operator's (persona or user) instructions when given."""
|
| 108 |
+
if operator and operator.strip():
|
| 109 |
+
return f"{system}\n\nOperator instructions for this assistant:\n{operator.strip()}"
|
| 110 |
+
return system
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
_NOTES = {
|
| 114 |
+
"validated": "The validator confirmed the answer.",
|
| 115 |
+
"corrected": "The validator found issues and corrected the answer.",
|
| 116 |
+
"needs_review": "The validator raised doubts it could not resolve; read the answer with care.",
|
| 117 |
+
"reviewed": "The validator reviewed the answer without a clear verdict.",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _issues(raw: Any) -> List[Dict[str, str]]:
|
| 122 |
+
out: List[Dict[str, str]] = []
|
| 123 |
+
for item in raw if isinstance(raw, list) else []:
|
| 124 |
+
if isinstance(item, str) and item.strip():
|
| 125 |
+
out.append({"severity": "medium", "text": item.strip()})
|
| 126 |
+
elif isinstance(item, dict) and str(item.get("text") or item.get("issue") or "").strip():
|
| 127 |
+
severity = str(item.get("severity") or "medium").lower()
|
| 128 |
+
out.append({"severity": severity if severity in ("high", "medium", "low") else "medium",
|
| 129 |
+
"text": str(item.get("text") or item.get("issue")).strip()})
|
| 130 |
+
return out[:12]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def verdict_detail(validation: str, synthesis: str) -> Dict[str, Any]:
|
| 134 |
+
"""The validator's structured result: ``verdict`` (validated / corrected / needs_review /
|
| 135 |
+
reviewed), ``final_answer``, ``note``, ``issues``, ``method``, ``limitations``.
|
| 136 |
+
|
| 137 |
+
JSON first (what the prompt asks for); the older prose shape as a fallback. A correction
|
| 138 |
+
without text, or a "validated" with a high-severity issue, is a disagreement: needs_review."""
|
| 139 |
+
text = (validation or "").strip()
|
| 140 |
+
data: Optional[Dict[str, Any]] = None
|
| 141 |
+
m = re.search(r"\{.*\}", text, flags=re.S)
|
| 142 |
+
if m:
|
| 143 |
+
try:
|
| 144 |
+
parsed = json.loads(m.group(0))
|
| 145 |
+
data = parsed if isinstance(parsed, dict) else None
|
| 146 |
+
except json.JSONDecodeError:
|
| 147 |
+
data = None
|
| 148 |
+
if data is not None:
|
| 149 |
+
verdict = str(data.get("verdict") or "").strip().lower().replace(" ", "_")
|
| 150 |
+
if verdict not in VERDICTS:
|
| 151 |
+
verdict = "reviewed"
|
| 152 |
+
issues = _issues(data.get("issues"))
|
| 153 |
+
corrected = str(data.get("corrected_answer") or "").strip()
|
| 154 |
+
method = str(data.get("method") or "model review").strip()
|
| 155 |
+
limitations = str(data.get("limitations") or "").strip()
|
| 156 |
+
if verdict == "corrected" and not corrected:
|
| 157 |
+
verdict = "needs_review"
|
| 158 |
+
if verdict == "validated" and any(i["severity"] == "high" for i in issues):
|
| 159 |
+
verdict = "needs_review"
|
| 160 |
+
final = corrected if verdict == "corrected" else synthesis
|
| 161 |
+
return {"verdict": verdict, "final_answer": final, "note": _NOTES[verdict], "issues": issues,
|
| 162 |
+
"method": method, "limitations": limitations, "structured": True}
|
| 163 |
+
|
| 164 |
+
upper = text.upper()
|
| 165 |
+
at = upper.find("CORRECTION:")
|
| 166 |
+
if 0 <= at <= 40:
|
| 167 |
+
corrected = text[at + len("CORRECTION:"):].lstrip(" *#:-β\n").strip()
|
| 168 |
+
if corrected:
|
| 169 |
+
return {"verdict": "corrected", "final_answer": corrected, "note": _NOTES["corrected"], "issues": [],
|
| 170 |
+
"method": "model review", "limitations": "", "structured": False}
|
| 171 |
+
if "VALIDATED" in upper[:160]:
|
| 172 |
+
return {"verdict": "validated", "final_answer": synthesis, "note": _NOTES["validated"], "issues": [],
|
| 173 |
+
"method": "model review", "limitations": "", "structured": False}
|
| 174 |
+
return {"verdict": "reviewed", "final_answer": synthesis, "note": _NOTES["reviewed"], "issues": [],
|
| 175 |
+
"method": "model review", "limitations": "", "structured": False}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def verdict_of(validation: str, synthesis: str) -> Tuple[str, str, str]:
|
| 179 |
+
"""``(verdict, final_answer, note)`` β the short form of :func:`verdict_detail`."""
|
| 180 |
+
d = verdict_detail(validation, synthesis)
|
| 181 |
+
return d["verdict"], d["final_answer"], d["note"]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
# Sync heavy pipeline
|
| 186 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
|
| 188 |
+
async def heavy(
|
| 189 |
+
query: str,
|
| 190 |
+
provider: ProviderName = "auto",
|
| 191 |
+
*,
|
| 192 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 193 |
+
model: Optional[str] = None,
|
| 194 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 195 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 196 |
+
system_prompt: Optional[str] = None,
|
| 197 |
+
forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
|
| 198 |
+
research: bool = True,
|
| 199 |
+
registry: Any = None,
|
| 200 |
+
tool_rounds: Optional[int] = None,
|
| 201 |
+
) -> Dict[str, Any]:
|
| 202 |
+
"""
|
| 203 |
+
Run the heavy pipeline: research (when ``research``; else the reasoner gets the tools) β
|
| 204 |
+
reason β synthesize β validate.
|
| 205 |
+
Returns final answer + all intermediate agent outputs, the validator's structured
|
| 206 |
+
verdict, and the tool trace and sources. ``system_prompt``: operator instructions added
|
| 207 |
+
to every agent's role prompt; ``registry``: the request's tool set.
|
| 208 |
+
"""
|
| 209 |
+
|
| 210 |
+
# Each agent keeps its own role prompt and gets the recent conversation ahead of its
|
| 211 |
+
# brief (see step_messages).
|
| 212 |
+
def _msg(system: str, user: str) -> List[Dict[str, str]]:
|
| 213 |
+
return step_messages(user, system_prompt=_role(system, system_prompt), history=history)
|
| 214 |
+
|
| 215 |
+
kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 216 |
+
# A model that cannot call tools still gets grounded: HomePilot searches the question for it.
|
| 217 |
+
assist = [("hp_web_search", {"query": query})]
|
| 218 |
+
loop_kw = dict(call=dispatch, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist)
|
| 219 |
+
notices: List[str] = []
|
| 220 |
+
fallback_applied = False
|
| 221 |
+
research_text = ""
|
| 222 |
+
tooled: Dict[str, Any] = {}
|
| 223 |
+
|
| 224 |
+
researched = research
|
| 225 |
+
if research:
|
| 226 |
+
# Agent 1: Research β with the tool loop (web search, workspace, notes)
|
| 227 |
+
logger.debug("Heavy agent 1: researcher")
|
| 228 |
+
p_research = _agent_provider("researcher", provider)
|
| 229 |
+
try:
|
| 230 |
+
tooled = await run_with_tools(
|
| 231 |
+
_msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw
|
| 232 |
+
)
|
| 233 |
+
except Exception as exc: # noqa: BLE001 - one agent down is not the turn down
|
| 234 |
+
researched = False
|
| 235 |
+
logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc)
|
| 236 |
+
notices.append(_agent_down("research agent", exc))
|
| 237 |
+
else:
|
| 238 |
+
research_text = tooled.get("content") or ""
|
| 239 |
+
notices.extend(tooled.get("notices", []))
|
| 240 |
+
fallback_applied = bool(tooled.get("fallback_applied"))
|
| 241 |
+
|
| 242 |
+
# Agent 2: Reason (with the tools itself when no research agent ran)
|
| 243 |
+
logger.debug("Heavy agent 2: reasoner")
|
| 244 |
+
p_reason = _agent_provider("reasoner", provider)
|
| 245 |
+
if researched:
|
| 246 |
+
raw_reason = await dispatch(
|
| 247 |
+
_msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)),
|
| 248 |
+
p_reason, **kw
|
| 249 |
+
)
|
| 250 |
+
reasoning = _extract(raw_reason)
|
| 251 |
+
notices.extend(_collect_notice(raw_reason))
|
| 252 |
+
fallback_applied = fallback_applied or _fallback(raw_reason)
|
| 253 |
+
else:
|
| 254 |
+
tooled = await run_with_tools(
|
| 255 |
+
_msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw
|
| 256 |
+
)
|
| 257 |
+
reasoning = tooled.get("content") or ""
|
| 258 |
+
notices.extend(tooled.get("notices", []))
|
| 259 |
+
fallback_applied = fallback_applied or bool(tooled.get("fallback_applied"))
|
| 260 |
+
|
| 261 |
+
# Agent 3: Synthesize
|
| 262 |
+
logger.debug("Heavy agent 3: synthesizer")
|
| 263 |
+
p_synth = _agent_provider("synthesizer", provider)
|
| 264 |
+
raw_synth = await dispatch(
|
| 265 |
+
_msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)),
|
| 266 |
+
p_synth, **kw
|
| 267 |
+
)
|
| 268 |
+
synthesis = _extract(raw_synth)
|
| 269 |
+
notices.extend(_collect_notice(raw_synth))
|
| 270 |
+
fallback_applied = fallback_applied or _fallback(raw_synth)
|
| 271 |
+
|
| 272 |
+
# Agent 4: Validate
|
| 273 |
+
logger.debug("Heavy agent 4: validator")
|
| 274 |
+
p_valid = _agent_provider("validator", provider)
|
| 275 |
+
validation_skipped = ""
|
| 276 |
+
try:
|
| 277 |
+
raw_valid = await dispatch(
|
| 278 |
+
_msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)),
|
| 279 |
+
p_valid, **kw
|
| 280 |
+
)
|
| 281 |
+
except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal
|
| 282 |
+
raw_valid, validation = {}, ""
|
| 283 |
+
validation_skipped = _agent_down("validator", exc)
|
| 284 |
+
logger.warning("Heavy validator failed; answer returned unverified: %s", exc)
|
| 285 |
+
notices.append(validation_skipped)
|
| 286 |
+
else:
|
| 287 |
+
validation = _extract(raw_valid)
|
| 288 |
+
notices.extend(_collect_notice(raw_valid))
|
| 289 |
+
fallback_applied = fallback_applied or _fallback(raw_valid)
|
| 290 |
+
|
| 291 |
+
synthesis, validation = strip_think(synthesis), strip_think(validation)
|
| 292 |
+
detail = verdict_detail(validation, synthesis)
|
| 293 |
+
if validation_skipped:
|
| 294 |
+
detail["note"] = "Not validated: the validator could not run. Read the answer with care."
|
| 295 |
+
detail["limitations"] = validation_skipped
|
| 296 |
+
|
| 297 |
+
return {
|
| 298 |
+
"final_answer": detail["final_answer"],
|
| 299 |
+
"agents": {
|
| 300 |
+
**({"research": research_text} if research else {}),
|
| 301 |
+
"reasoning": reasoning,
|
| 302 |
+
"synthesis": synthesis,
|
| 303 |
+
"validation": validation,
|
| 304 |
+
},
|
| 305 |
+
"verdict": detail["verdict"],
|
| 306 |
+
"verdict_note": detail["note"],
|
| 307 |
+
"verdict_detail": {k: detail[k] for k in ("verdict", "note", "issues", "method", "limitations", "structured")},
|
| 308 |
+
"researched": research,
|
| 309 |
+
"provider": raw_valid.get("provider", provider),
|
| 310 |
+
"pipeline": "heavy",
|
| 311 |
+
"fallback_applied": fallback_applied,
|
| 312 |
+
"notices": _dedupe_notices(notices),
|
| 313 |
+
"tool_trace": tooled.get("tool_trace", []),
|
| 314 |
+
"sources": tooled.get("sources", []),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 319 |
+
# Streaming heavy pipeline
|
| 320 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
|
| 322 |
+
async def stream_heavy(
|
| 323 |
+
query: str,
|
| 324 |
+
provider: ProviderName = "auto",
|
| 325 |
+
*,
|
| 326 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 327 |
+
model: Optional[str] = None,
|
| 328 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 329 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 330 |
+
system_prompt: Optional[str] = None,
|
| 331 |
+
forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
|
| 332 |
+
research: bool = True,
|
| 333 |
+
registry: Any = None,
|
| 334 |
+
tool_rounds: Optional[int] = None,
|
| 335 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 336 |
+
"""
|
| 337 |
+
Stream the heavy pipeline agent by agent (the research agent only when ``research``;
|
| 338 |
+
otherwise the reasoner has the tools).
|
| 339 |
+
Each agent's output is streamed live; the UI renders each in a collapsible panel.
|
| 340 |
+
Besides step_start/token/step_end the stream carries the tool events, then ``verdict``
|
| 341 |
+
(the validator's structured call), ``final`` (the answer after validation), ``done``.
|
| 342 |
+
"""
|
| 343 |
+
|
| 344 |
+
def _msg(system: str, user: str) -> List[Dict[str, str]]:
|
| 345 |
+
return step_messages(user, system_prompt=_role(system, system_prompt), history=history)
|
| 346 |
+
|
| 347 |
+
kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 348 |
+
assist = [("hp_web_search", {"query": query})]
|
| 349 |
+
loop_kw = dict(stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist)
|
| 350 |
+
research_text = ""
|
| 351 |
+
|
| 352 |
+
researched = research
|
| 353 |
+
if research:
|
| 354 |
+
# ββ Agent 1: Research β with the tool loop βββββββββββββββββββββββββββ
|
| 355 |
+
p_research = _agent_provider("researcher", provider)
|
| 356 |
+
yield {"type": "step_start", "step": "research", "label": "π¬ Researching deeplyβ¦",
|
| 357 |
+
"provider": p_research, "role": "work"}
|
| 358 |
+
try:
|
| 359 |
+
async for ev in stream_with_tools(
|
| 360 |
+
_msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw
|
| 361 |
+
):
|
| 362 |
+
if ev["type"] == "token":
|
| 363 |
+
yield {"type": "token", "step": "research", "content": ev["content"]}
|
| 364 |
+
elif ev["type"] == "done":
|
| 365 |
+
research_text = ev.get("content") or ""
|
| 366 |
+
else:
|
| 367 |
+
yield {**ev, "step": "research"}
|
| 368 |
+
except Exception as exc: # noqa: BLE001 - one agent down is not the turn down
|
| 369 |
+
researched = False
|
| 370 |
+
logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc)
|
| 371 |
+
yield {"type": "notice", "text": _agent_down("research agent", exc)}
|
| 372 |
+
yield {"type": "step_end", "step": "research"}
|
| 373 |
+
|
| 374 |
+
# ββ Agent 2: Reason (with the tools itself when no research agent ran) βββ
|
| 375 |
+
p_reason = _agent_provider("reasoner", provider)
|
| 376 |
+
yield {"type": "step_start", "step": "reasoning", "label": "π§ Reasoning step-by-stepβ¦",
|
| 377 |
+
"provider": p_reason, "role": "work"}
|
| 378 |
+
reason_chunks: List[str] = []
|
| 379 |
+
if researched:
|
| 380 |
+
async for chunk in dispatch_stream(
|
| 381 |
+
_msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)), p_reason, **kw
|
| 382 |
+
):
|
| 383 |
+
reason_chunks.append(chunk)
|
| 384 |
+
yield {"type": "token", "step": "reasoning", "content": chunk}
|
| 385 |
+
else:
|
| 386 |
+
async for ev in stream_with_tools(
|
| 387 |
+
_msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw
|
| 388 |
+
):
|
| 389 |
+
if ev["type"] == "token":
|
| 390 |
+
reason_chunks.append(ev["content"])
|
| 391 |
+
yield {"type": "token", "step": "reasoning", "content": ev["content"]}
|
| 392 |
+
elif ev["type"] == "done":
|
| 393 |
+
reason_chunks = [ev.get("content") or ""]
|
| 394 |
+
else:
|
| 395 |
+
yield {**ev, "step": "reasoning"}
|
| 396 |
+
reasoning = "".join(reason_chunks)
|
| 397 |
+
yield {"type": "step_end", "step": "reasoning"}
|
| 398 |
+
|
| 399 |
+
# ββ Agent 3: Synthesize ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 400 |
+
p_synth = _agent_provider("synthesizer", provider)
|
| 401 |
+
yield {"type": "step_start", "step": "synthesis", "label": "βοΈ Synthesizing answerβ¦",
|
| 402 |
+
"provider": p_synth, "role": "answer"}
|
| 403 |
+
synth_chunks: List[str] = []
|
| 404 |
+
thinking = ThinkFilter() # the synthesis is the reply: no reasoning tags in it
|
| 405 |
+
try:
|
| 406 |
+
async for chunk in dispatch_stream(
|
| 407 |
+
_msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)),
|
| 408 |
+
p_synth, **kw
|
| 409 |
+
):
|
| 410 |
+
clean = thinking.feed(chunk)
|
| 411 |
+
if clean:
|
| 412 |
+
synth_chunks.append(clean)
|
| 413 |
+
yield {"type": "token", "step": "synthesis", "content": clean}
|
| 414 |
+
except Exception as exc: # noqa: BLE001 - the reasoning already answers the question
|
| 415 |
+
if not reasoning.strip() or synth_chunks:
|
| 416 |
+
raise
|
| 417 |
+
logger.warning("Heavy synthesizer failed; answering from the reasoning: %s", exc)
|
| 418 |
+
yield {"type": "notice", "text": _agent_down("synthesizer", exc)}
|
| 419 |
+
fallback = strip_think(reasoning)
|
| 420 |
+
synth_chunks.append(fallback)
|
| 421 |
+
yield {"type": "token", "step": "synthesis", "content": fallback}
|
| 422 |
+
tail = thinking.flush()
|
| 423 |
+
if tail:
|
| 424 |
+
synth_chunks.append(tail)
|
| 425 |
+
yield {"type": "token", "step": "synthesis", "content": tail}
|
| 426 |
+
synthesis = "".join(synth_chunks)
|
| 427 |
+
yield {"type": "step_end", "step": "synthesis"}
|
| 428 |
+
|
| 429 |
+
# ββ Agent 4: Validate ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 430 |
+
p_valid = _agent_provider("validator", provider)
|
| 431 |
+
yield {"type": "step_start", "step": "validation", "label": "β
Validating answerβ¦",
|
| 432 |
+
"provider": p_valid, "role": "work"}
|
| 433 |
+
validation_chunks: List[str] = []
|
| 434 |
+
validation_skipped = ""
|
| 435 |
+
try:
|
| 436 |
+
async for chunk in dispatch_stream(
|
| 437 |
+
_msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)),
|
| 438 |
+
p_valid, **kw
|
| 439 |
+
):
|
| 440 |
+
validation_chunks.append(chunk)
|
| 441 |
+
yield {"type": "token", "step": "validation", "content": chunk}
|
| 442 |
+
except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal
|
| 443 |
+
validation_skipped = _agent_down("validator", exc)
|
| 444 |
+
logger.warning("Heavy validator failed; answer returned unverified: %s", exc)
|
| 445 |
+
yield {"type": "notice", "text": validation_skipped}
|
| 446 |
+
yield {"type": "step_end", "step": "validation"}
|
| 447 |
+
|
| 448 |
+
# The verdict decides what the reply is: the synthesis, or the validator's correction.
|
| 449 |
+
detail = verdict_detail(strip_think("".join(validation_chunks)), synthesis)
|
| 450 |
+
if validation_skipped:
|
| 451 |
+
detail["note"] = "Not validated: the validator could not run. Read the answer with care."
|
| 452 |
+
detail["limitations"] = validation_skipped
|
| 453 |
+
yield {"type": "verdict", "verdict": detail["verdict"], "note": detail["note"],
|
| 454 |
+
"detail": {k: detail[k] for k in ("issues", "method", "limitations", "structured")}}
|
| 455 |
+
yield {"type": "final", "content": detail["final_answer"]}
|
| 456 |
+
yield {"type": "done"}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 460 |
+
# Helper
|
| 461 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 462 |
+
|
| 463 |
+
def _extract(raw: Dict[str, Any]) -> str:
|
| 464 |
+
try:
|
| 465 |
+
return raw["choices"][0]["message"]["content"] or ""
|
| 466 |
+
except (KeyError, IndexError):
|
| 467 |
+
return ""
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _agent_down(agent: str, exc: Exception) -> str:
|
| 471 |
+
"""The notice for an agent that could not run (a provider rate limit past its retries, an
|
| 472 |
+
outage): the turn goes on without it, and the reader is told what is missing."""
|
| 473 |
+
reason = " ".join(str(exc).split())[:160] or type(exc).__name__
|
| 474 |
+
return f"Heavy's {agent} could not run ({reason}); the answer was produced without it."
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _fallback(raw: Dict[str, Any]) -> bool:
|
| 478 |
+
return bool(extract_dispatch_meta(raw).get("fallback_applied"))
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _collect_notice(raw: Dict[str, Any]) -> List[str]:
|
| 482 |
+
notice = extract_dispatch_meta(raw).get("notice")
|
| 483 |
+
return [notice] if isinstance(notice, str) and notice else []
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _dedupe_notices(notices: List[str]) -> List[str]:
|
| 487 |
+
return list(dict.fromkeys(notices))
|
expert/backend/app/expert/mcp_catalog.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class MCPServerSpec:
|
| 9 |
+
key: str
|
| 10 |
+
priority: str
|
| 11 |
+
purpose: str
|
| 12 |
+
required_apis: List[str]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ESSENTIAL_MCP_SERVERS: List[MCPServerSpec] = [
|
| 16 |
+
MCPServerSpec(
|
| 17 |
+
key="mcp-web-search",
|
| 18 |
+
priority="P0",
|
| 19 |
+
purpose="Grounded web retrieval and citation candidates.",
|
| 20 |
+
required_apis=["search(query, top_k, recency_days)", "fetch(url)", "extract(content)"],
|
| 21 |
+
),
|
| 22 |
+
MCPServerSpec(
|
| 23 |
+
key="mcp-doc-retrieval",
|
| 24 |
+
priority="P0",
|
| 25 |
+
purpose="Private knowledge retrieval and RAG.",
|
| 26 |
+
required_apis=["index(document_id, text, metadata)", "query(text, top_k, filters)", "delete(document_id)"],
|
| 27 |
+
),
|
| 28 |
+
MCPServerSpec(
|
| 29 |
+
key="mcp-code-sandbox",
|
| 30 |
+
priority="P0",
|
| 31 |
+
purpose="Safe code execution and deterministic computation.",
|
| 32 |
+
required_apis=["run(language, code, timeout_s, memory_mb)", "status(run_id)", "result(run_id)"],
|
| 33 |
+
),
|
| 34 |
+
MCPServerSpec(
|
| 35 |
+
key="mcp-citation-provenance",
|
| 36 |
+
priority="P0",
|
| 37 |
+
purpose="Claim to source lineage and citation integrity checks.",
|
| 38 |
+
required_apis=["attach_claim(claim_text, source_refs)", "verify_citations(response_text)", "get_lineage(response_id)"],
|
| 39 |
+
),
|
| 40 |
+
MCPServerSpec(
|
| 41 |
+
key="mcp-memory-store",
|
| 42 |
+
priority="P0",
|
| 43 |
+
purpose="Durable memory service with privacy controls.",
|
| 44 |
+
required_apis=["append(session_id, role, content)", "recall(session_id, limit)", "profile_upsert(user_id, key, value)", "forget(scope_id)"],
|
| 45 |
+
),
|
| 46 |
+
MCPServerSpec(
|
| 47 |
+
key="mcp-safety-policy",
|
| 48 |
+
priority="P1",
|
| 49 |
+
purpose="Input and output policy checks plus risk scoring.",
|
| 50 |
+
required_apis=["check_input(text, profile)", "check_output(text, profile)", "risk_score(context)"],
|
| 51 |
+
),
|
| 52 |
+
MCPServerSpec(
|
| 53 |
+
key="mcp-eval-runner",
|
| 54 |
+
priority="P1",
|
| 55 |
+
purpose="Automated quality, safety, and cost evaluation suites.",
|
| 56 |
+
required_apis=["run_suite(suite_id, model_profile)", "report(run_id)", "regression_gate(run_id, baseline_id)"],
|
| 57 |
+
),
|
| 58 |
+
MCPServerSpec(
|
| 59 |
+
key="mcp-observability",
|
| 60 |
+
priority="P1",
|
| 61 |
+
purpose="Central export for metrics, traces, and event logs.",
|
| 62 |
+
required_apis=["emit_metric(name, value, tags)", "emit_trace(trace_id, spans)", "emit_event(event_type, payload)"],
|
| 63 |
+
),
|
| 64 |
+
MCPServerSpec(
|
| 65 |
+
key="mcp-cost-router",
|
| 66 |
+
priority="P2",
|
| 67 |
+
purpose="Budget-aware route recommendations and cost recording.",
|
| 68 |
+
required_apis=["recommend_route(query_meta, budget_state)", "record_cost(provider, tokens, latency_ms, success)", "monthly_budget_status(org_id)"],
|
| 69 |
+
),
|
| 70 |
+
MCPServerSpec(
|
| 71 |
+
key="mcp-job-orchestrator",
|
| 72 |
+
priority="P2",
|
| 73 |
+
purpose="Long-running simulation and research job queue orchestration.",
|
| 74 |
+
required_apis=["submit(job_type, payload, priority)", "status(job_id)", "cancel(job_id)", "result(job_id)"],
|
| 75 |
+
),
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
TOOL_MAPPING: Dict[str, str] = {
|
| 80 |
+
"web_search": "mcp-web-search",
|
| 81 |
+
"retrieval": "mcp-doc-retrieval",
|
| 82 |
+
"code_exec": "mcp-code-sandbox",
|
| 83 |
+
"model_compare": "mcp-eval-runner",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# MCP Context Forge tool identifiers used by the Expert gateway.
|
| 87 |
+
# Default pattern keeps names predictable and can be overridden by env.
|
| 88 |
+
FORGE_TOOL_MAPPING: Dict[str, str] = {
|
| 89 |
+
"mcp-web-search": "mcp_web_search",
|
| 90 |
+
"mcp-doc-retrieval": "mcp_doc_retrieval",
|
| 91 |
+
"mcp-code-sandbox": "mcp_code_sandbox",
|
| 92 |
+
"mcp-citation-provenance": "mcp_citation_provenance",
|
| 93 |
+
"mcp-memory-store": "mcp_memory_store",
|
| 94 |
+
"mcp-safety-policy": "mcp_safety_policy",
|
| 95 |
+
"mcp-eval-runner": "mcp_eval_runner",
|
| 96 |
+
"mcp-observability": "mcp_observability",
|
| 97 |
+
"mcp-cost-router": "mcp_cost_router",
|
| 98 |
+
"mcp-job-orchestrator": "mcp_job_orchestrator",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def mcp_key_to_env(key: str) -> str:
|
| 103 |
+
normalized = key.upper().replace("-", "_")
|
| 104 |
+
return f"EXPERT_{normalized}_URL"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def p0_required_server_keys() -> List[str]:
|
| 108 |
+
return [s.key for s in ESSENTIAL_MCP_SERVERS if s.priority == "P0"]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def mcp_key_to_forge_tool_env(key: str) -> str:
|
| 112 |
+
normalized = key.upper().replace("-", "_")
|
| 113 |
+
return f"EXPERT_{normalized}_FORGE_TOOL_ID"
|
expert/backend/app/expert/mcp_gateway.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
try:
|
| 9 |
+
from app.agentic.client import ContextForgeClient # type: ignore
|
| 10 |
+
except Exception: # pragma: no cover - optional dependency in isolated expert test env
|
| 11 |
+
ContextForgeClient = None # type: ignore
|
| 12 |
+
|
| 13 |
+
from .mcp_catalog import ESSENTIAL_MCP_SERVERS, FORGE_TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class MCPServerStatus:
|
| 18 |
+
key: str
|
| 19 |
+
priority: str
|
| 20 |
+
configured: bool
|
| 21 |
+
endpoint: str
|
| 22 |
+
forge_tool_id: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MCPGateway:
|
| 26 |
+
"""Thin HTTP gateway for Expert MCP servers.
|
| 27 |
+
|
| 28 |
+
The gateway is intentionally generic and additive so servers can be swapped
|
| 29 |
+
without changing Expert routes/orchestration contracts.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, timeout_s: float = 15.0, env: Optional[Dict[str, str]] = None) -> None:
|
| 33 |
+
self.timeout_s = timeout_s
|
| 34 |
+
self.env = env or dict(os.environ)
|
| 35 |
+
self.orchestrator = (self.env.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower()
|
| 36 |
+
self.forge_base_url = (self.env.get("CONTEXT_FORGE_URL", "http://localhost:4444") or "").strip().rstrip("/")
|
| 37 |
+
self.forge_auth_user = (self.env.get("CONTEXT_FORGE_AUTH_USER", "admin") or "admin").strip()
|
| 38 |
+
self.forge_auth_pass = (self.env.get("CONTEXT_FORGE_AUTH_PASS", "changeme") or "changeme").strip()
|
| 39 |
+
self.forge_token = (self.env.get("CONTEXT_FORGE_TOKEN", "") or "").strip()
|
| 40 |
+
self._endpoint_map = self._build_endpoint_map()
|
| 41 |
+
self._forge_tool_map = self._build_forge_tool_map()
|
| 42 |
+
|
| 43 |
+
def _build_endpoint_map(self) -> Dict[str, str]:
|
| 44 |
+
mapping: Dict[str, str] = {}
|
| 45 |
+
for spec in ESSENTIAL_MCP_SERVERS:
|
| 46 |
+
endpoint = (self.env.get(mcp_key_to_env(spec.key), "") or "").strip()
|
| 47 |
+
if endpoint:
|
| 48 |
+
mapping[spec.key] = endpoint.rstrip("/")
|
| 49 |
+
return mapping
|
| 50 |
+
|
| 51 |
+
def endpoint_for(self, key: str) -> str:
|
| 52 |
+
return self._endpoint_map.get(key, "")
|
| 53 |
+
|
| 54 |
+
def forge_tool_for(self, key: str) -> str:
|
| 55 |
+
return self._forge_tool_map.get(key, "")
|
| 56 |
+
|
| 57 |
+
def _build_forge_tool_map(self) -> Dict[str, str]:
|
| 58 |
+
mapping: Dict[str, str] = {}
|
| 59 |
+
for spec in ESSENTIAL_MCP_SERVERS:
|
| 60 |
+
env_key = mcp_key_to_forge_tool_env(spec.key)
|
| 61 |
+
tool_id = (self.env.get(env_key, "") or "").strip() or FORGE_TOOL_MAPPING.get(spec.key, "")
|
| 62 |
+
if tool_id:
|
| 63 |
+
mapping[spec.key] = tool_id
|
| 64 |
+
return mapping
|
| 65 |
+
|
| 66 |
+
def status(self) -> list[MCPServerStatus]:
|
| 67 |
+
status: list[MCPServerStatus] = []
|
| 68 |
+
for spec in ESSENTIAL_MCP_SERVERS:
|
| 69 |
+
endpoint = self.endpoint_for(spec.key)
|
| 70 |
+
status.append(
|
| 71 |
+
MCPServerStatus(
|
| 72 |
+
key=spec.key,
|
| 73 |
+
priority=spec.priority,
|
| 74 |
+
configured=bool(endpoint or self.forge_tool_for(spec.key)),
|
| 75 |
+
endpoint=endpoint,
|
| 76 |
+
forge_tool_id=self.forge_tool_for(spec.key),
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
return status
|
| 80 |
+
|
| 81 |
+
def _forge_client(self) -> ContextForgeClient:
|
| 82 |
+
if ContextForgeClient is None:
|
| 83 |
+
raise RuntimeError("ContextForgeClient is unavailable; ensure backend/app is on PYTHONPATH")
|
| 84 |
+
return ContextForgeClient(
|
| 85 |
+
base_url=self.forge_base_url,
|
| 86 |
+
token=self.forge_token,
|
| 87 |
+
auth_user=self.forge_auth_user,
|
| 88 |
+
auth_pass=self.forge_auth_pass,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
async def _invoke_via_forge(self, server_key: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 92 |
+
if not self.forge_base_url:
|
| 93 |
+
return {
|
| 94 |
+
"ok": False,
|
| 95 |
+
"error": "context_forge_not_configured",
|
| 96 |
+
"server": server_key,
|
| 97 |
+
}
|
| 98 |
+
tool_id = self.forge_tool_for(server_key)
|
| 99 |
+
if not tool_id:
|
| 100 |
+
return {
|
| 101 |
+
"ok": False,
|
| 102 |
+
"error": f"forge_tool_not_configured:{server_key}",
|
| 103 |
+
"server": server_key,
|
| 104 |
+
}
|
| 105 |
+
try:
|
| 106 |
+
client = self._forge_client()
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
return {
|
| 109 |
+
"ok": False,
|
| 110 |
+
"error": str(exc),
|
| 111 |
+
"server": server_key,
|
| 112 |
+
"tool_id": tool_id,
|
| 113 |
+
"orchestrator": "context_forge",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
result = await client.invoke_tool(tool_id=tool_id, args=payload, timeout=self.timeout_s)
|
| 117 |
+
if isinstance(result, dict) and result.get("error"):
|
| 118 |
+
return {
|
| 119 |
+
"ok": False,
|
| 120 |
+
"error": str(result["error"]),
|
| 121 |
+
"server": server_key,
|
| 122 |
+
"tool_id": tool_id,
|
| 123 |
+
"orchestrator": "context_forge",
|
| 124 |
+
}
|
| 125 |
+
return {
|
| 126 |
+
"ok": True,
|
| 127 |
+
"server": server_key,
|
| 128 |
+
"tool_id": tool_id,
|
| 129 |
+
"orchestrator": "context_forge",
|
| 130 |
+
"data": result,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async def invoke(self, server_key: str, method: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 134 |
+
if self.orchestrator == "context_forge":
|
| 135 |
+
return await self._invoke_via_forge(server_key=server_key, payload={"method": method, "payload": payload})
|
| 136 |
+
|
| 137 |
+
endpoint = self.endpoint_for(server_key)
|
| 138 |
+
if not endpoint:
|
| 139 |
+
return {
|
| 140 |
+
"ok": False,
|
| 141 |
+
"error": f"server_not_configured:{server_key}",
|
| 142 |
+
"server": server_key,
|
| 143 |
+
"method": method,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
url = f"{endpoint.rstrip('/')}/{method.lstrip('/')}"
|
| 147 |
+
try:
|
| 148 |
+
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
| 149 |
+
resp = await client.post(url, json=payload)
|
| 150 |
+
resp.raise_for_status()
|
| 151 |
+
data = resp.json() if resp.text else {}
|
| 152 |
+
return {"ok": True, "server": server_key, "method": method, "data": data, "orchestrator": "direct"}
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
return {
|
| 155 |
+
"ok": False,
|
| 156 |
+
"error": f"{type(exc).__name__}: {exc}",
|
| 157 |
+
"server": server_key,
|
| 158 |
+
"method": method,
|
| 159 |
+
"url": url,
|
| 160 |
+
"orchestrator": "direct",
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# P0 typed wrappers (preprod essentials)
|
| 164 |
+
async def web_search_search(self, query: str, top_k: int = 5, recency_days: int = 7) -> Dict[str, Any]:
|
| 165 |
+
return await self.invoke(
|
| 166 |
+
"mcp-web-search",
|
| 167 |
+
"search",
|
| 168 |
+
{"query": query, "top_k": top_k, "recency_days": recency_days},
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
async def doc_retrieval_query(self, text: str, top_k: int = 5, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 172 |
+
return await self.invoke(
|
| 173 |
+
"mcp-doc-retrieval",
|
| 174 |
+
"query",
|
| 175 |
+
{"text": text, "top_k": top_k, "filters": filters or {}},
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
async def code_sandbox_run(self, language: str, code: str, timeout_s: int = 15, memory_mb: int = 256) -> Dict[str, Any]:
|
| 179 |
+
return await self.invoke(
|
| 180 |
+
"mcp-code-sandbox",
|
| 181 |
+
"run",
|
| 182 |
+
{"language": language, "code": code, "timeout_s": timeout_s, "memory_mb": memory_mb},
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
async def citation_verify(self, response_text: str) -> Dict[str, Any]:
|
| 186 |
+
return await self.invoke(
|
| 187 |
+
"mcp-citation-provenance",
|
| 188 |
+
"verify_citations",
|
| 189 |
+
{"response_text": response_text},
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
async def memory_append(self, session_id: str, role: str, content: str) -> Dict[str, Any]:
|
| 193 |
+
return await self.invoke(
|
| 194 |
+
"mcp-memory-store",
|
| 195 |
+
"append",
|
| 196 |
+
{"session_id": session_id, "role": role, "content": content},
|
| 197 |
+
)
|
expert/backend/app/expert/mcp_tools.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class ToolResult:
|
| 11 |
+
tool: str
|
| 12 |
+
ok: bool
|
| 13 |
+
content: str
|
| 14 |
+
meta: Dict[str, Any]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ToolCall:
|
| 19 |
+
tool: str
|
| 20 |
+
input: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class MCPToolClient:
|
| 24 |
+
"""Production-oriented MCP tool runner with explicit call budget."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, endpoints: Dict[str, str], timeout_s: float = 15.0) -> None:
|
| 27 |
+
self.endpoints = endpoints
|
| 28 |
+
self.timeout_s = timeout_s
|
| 29 |
+
|
| 30 |
+
async def run_many(self, calls: List[ToolCall], budget: int = 3) -> List[ToolResult]:
|
| 31 |
+
out: List[ToolResult] = []
|
| 32 |
+
remaining = budget
|
| 33 |
+
for call in calls:
|
| 34 |
+
if remaining <= 0:
|
| 35 |
+
out.append(ToolResult(call.tool, False, "Skipped (budget exhausted)", {"budget": budget}))
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
endpoint = self.endpoints.get(call.tool)
|
| 39 |
+
if not endpoint:
|
| 40 |
+
out.append(ToolResult(call.tool, False, "No MCP endpoint configured", {}))
|
| 41 |
+
continue
|
| 42 |
+
|
| 43 |
+
remaining -= 1
|
| 44 |
+
out.append(await self._invoke(call.tool, endpoint, call.input))
|
| 45 |
+
return out
|
| 46 |
+
|
| 47 |
+
async def _invoke(self, tool: str, endpoint: str, payload: str) -> ToolResult:
|
| 48 |
+
try:
|
| 49 |
+
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
|
| 50 |
+
resp = await client.post(endpoint, json={"input": payload})
|
| 51 |
+
resp.raise_for_status()
|
| 52 |
+
data = resp.json() if resp.text else {}
|
| 53 |
+
content = str(data.get("content", data))
|
| 54 |
+
return ToolResult(tool, True, content, {"endpoint": endpoint})
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
return ToolResult(tool, False, f"{type(exc).__name__}: {exc}", {"endpoint": endpoint})
|
expert/backend/app/expert/observability.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class MetricEvent:
|
| 9 |
+
ok: bool
|
| 10 |
+
latency_ms: int
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SLOMonitor:
|
| 14 |
+
def __init__(self) -> None:
|
| 15 |
+
self._events: List[MetricEvent] = []
|
| 16 |
+
|
| 17 |
+
def record(self, ok: bool, latency_ms: int) -> None:
|
| 18 |
+
self._events.append(MetricEvent(ok=ok, latency_ms=latency_ms))
|
| 19 |
+
if len(self._events) > 5000:
|
| 20 |
+
self._events = self._events[-5000:]
|
| 21 |
+
|
| 22 |
+
def snapshot(self) -> dict:
|
| 23 |
+
if not self._events:
|
| 24 |
+
return {"error_rate": 0.0, "p95_ms": 0, "count": 0}
|
| 25 |
+
|
| 26 |
+
errs = sum(1 for e in self._events if not e.ok)
|
| 27 |
+
lats = sorted(e.latency_ms for e in self._events)
|
| 28 |
+
idx = min(len(lats) - 1, int(len(lats) * 0.95))
|
| 29 |
+
return {
|
| 30 |
+
"error_rate": errs / len(self._events),
|
| 31 |
+
"p95_ms": lats[idx],
|
| 32 |
+
"count": len(self._events),
|
| 33 |
+
}
|
expert/backend/app/expert/persistent_memory.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import sqlite3
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import List
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
| 12 |
+
_PHONE_RE = re.compile(r"\+?[0-9][0-9\- ]{7,}[0-9]")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class MemoryRow:
|
| 17 |
+
role: str
|
| 18 |
+
content: str
|
| 19 |
+
ts: int
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SqliteMemoryStore:
|
| 23 |
+
def __init__(self, db_path: str) -> None:
|
| 24 |
+
self.db_path = db_path
|
| 25 |
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
self._init_db()
|
| 27 |
+
|
| 28 |
+
def _conn(self):
|
| 29 |
+
return sqlite3.connect(self.db_path)
|
| 30 |
+
|
| 31 |
+
def _init_db(self) -> None:
|
| 32 |
+
with self._conn() as c:
|
| 33 |
+
c.execute(
|
| 34 |
+
"""
|
| 35 |
+
CREATE TABLE IF NOT EXISTS expert_memory(
|
| 36 |
+
session_id TEXT NOT NULL,
|
| 37 |
+
role TEXT NOT NULL,
|
| 38 |
+
content TEXT NOT NULL,
|
| 39 |
+
ts INTEGER NOT NULL
|
| 40 |
+
)
|
| 41 |
+
"""
|
| 42 |
+
)
|
| 43 |
+
c.execute("CREATE INDEX IF NOT EXISTS idx_expert_memory_session_ts ON expert_memory(session_id, ts)")
|
| 44 |
+
|
| 45 |
+
def append(self, session_id: str, role: str, content: str) -> None:
|
| 46 |
+
safe = redact_pii(content)
|
| 47 |
+
with self._conn() as c:
|
| 48 |
+
c.execute(
|
| 49 |
+
"INSERT INTO expert_memory(session_id, role, content, ts) VALUES (?, ?, ?, ?)",
|
| 50 |
+
(session_id, role, safe, int(time.time())),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def recall(self, session_id: str, limit: int = 8) -> List[MemoryRow]:
|
| 54 |
+
with self._conn() as c:
|
| 55 |
+
rows = c.execute(
|
| 56 |
+
"SELECT role, content, ts FROM expert_memory WHERE session_id = ? ORDER BY ts DESC LIMIT ?",
|
| 57 |
+
(session_id, limit),
|
| 58 |
+
).fetchall()
|
| 59 |
+
return [MemoryRow(role=r[0], content=r[1], ts=r[2]) for r in reversed(rows)]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def redact_pii(text: str) -> str:
|
| 63 |
+
text = _EMAIL_RE.sub("[REDACTED_EMAIL]", text)
|
| 64 |
+
text = _PHONE_RE.sub("[REDACTED_PHONE]", text)
|
| 65 |
+
return text
|
expert/backend/app/expert/policies.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from dataclasses import asdict, dataclass
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
from .config import (
|
| 8 |
+
EXPERT_DEEP_TEMPERATURE,
|
| 9 |
+
EXPERT_FAST_MAX_TOKENS,
|
| 10 |
+
EXPERT_FAST_TEMPERATURE,
|
| 11 |
+
EXPERT_FAST_TOOL_ROUNDS,
|
| 12 |
+
EXPERT_HEAVY_MAX_TOKENS,
|
| 13 |
+
EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY,
|
| 14 |
+
EXPERT_HEAVY_TEMPERATURE,
|
| 15 |
+
EXPERT_HEAVY_TOOL_ROUNDS,
|
| 16 |
+
EXPERT_THINK_MAX_TOKENS,
|
| 17 |
+
EXPERT_THINK_PLAN_MIN_COMPLEXITY,
|
| 18 |
+
EXPERT_THINK_TEMPERATURE,
|
| 19 |
+
EXPERT_THINK_TOOL_ROUNDS,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
ThinkingMode = Literal["auto", "fast", "think", "heavy"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class ModeResolution:
|
| 28 |
+
mode_used: ThinkingMode
|
| 29 |
+
strategy: Literal["single-pass", "expert-thinking", "heavy-multi-pass"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def resolve_thinking_mode(mode: ThinkingMode, complexity: int) -> ModeResolution:
|
| 33 |
+
"""Deterministically resolve auto/explicit mode into concrete pipeline strategy."""
|
| 34 |
+
if mode == "auto":
|
| 35 |
+
if complexity >= 8:
|
| 36 |
+
mode = "heavy"
|
| 37 |
+
elif complexity >= 5:
|
| 38 |
+
mode = "think"
|
| 39 |
+
else:
|
| 40 |
+
mode = "fast"
|
| 41 |
+
|
| 42 |
+
strategy = {
|
| 43 |
+
"fast": "single-pass",
|
| 44 |
+
"think": "expert-thinking",
|
| 45 |
+
"heavy": "heavy-multi-pass",
|
| 46 |
+
}[mode]
|
| 47 |
+
|
| 48 |
+
return ModeResolution(mode_used=mode, strategy=strategy)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
# Effort: the mode is a ceiling; the request decides how much of it is spent
|
| 53 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
|
| 55 |
+
EffortLevel = Literal["direct", "standard", "full"]
|
| 56 |
+
|
| 57 |
+
_GREETING = re.compile(
|
| 58 |
+
r"^(hi|hello|hey|yo|hola|ciao|salve|bonjour|hallo|thanks|thank you|thx|ok|okay|test|ping|"
|
| 59 |
+
r"good (morning|afternoon|evening)|how are you)\b[\s!.?,]*$",
|
| 60 |
+
re.I,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def is_trivial(query: str) -> bool:
|
| 65 |
+
"""A greeting, an acknowledgement, or a few words with no question: the kind of turn that
|
| 66 |
+
needs one generation whatever mode is selected (the same rule the composer uses)."""
|
| 67 |
+
t = (query or "").strip()
|
| 68 |
+
if not t or _GREETING.match(t):
|
| 69 |
+
return True
|
| 70 |
+
words = t.split()
|
| 71 |
+
return len(words) <= 3 and "?" not in t and len(t) <= 24
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@dataclass(frozen=True)
|
| 75 |
+
class EffortProfile:
|
| 76 |
+
mode: str
|
| 77 |
+
level: EffortLevel # direct = one generation Β· standard = the mode without its optional stages Β· full
|
| 78 |
+
temperature: float
|
| 79 |
+
max_tokens: int
|
| 80 |
+
tool_rounds: int
|
| 81 |
+
plan: bool # think: analyze β plan before solving
|
| 82 |
+
research: bool # heavy: the research agent runs
|
| 83 |
+
reason: str # one line for the trace
|
| 84 |
+
analyze: bool = False # think: a visible analysis step before the answer (always with plan)
|
| 85 |
+
|
| 86 |
+
def as_dict(self) -> dict:
|
| 87 |
+
return asdict(self)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def effort_for(mode: str, complexity: int, *, trivial: bool = False, forced_tools: bool = False) -> EffortProfile:
|
| 91 |
+
"""What this request may spend in ``mode``. Fast is always one generation. Expert plans
|
| 92 |
+
only above a complexity threshold. Heavy researches only when the question is complex or
|
| 93 |
+
the user asked for a search; a trivial message is answered directly in every mode."""
|
| 94 |
+
if mode == "fast":
|
| 95 |
+
return EffortProfile("fast", "direct", EXPERT_FAST_TEMPERATURE, EXPERT_FAST_MAX_TOKENS,
|
| 96 |
+
EXPERT_FAST_TOOL_ROUNDS, False, False, "one generation, tools on demand")
|
| 97 |
+
if mode == "think":
|
| 98 |
+
if trivial:
|
| 99 |
+
return EffortProfile("think", "direct", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS,
|
| 100 |
+
EXPERT_THINK_TOOL_ROUNDS, False, False, "a short message needs no plan")
|
| 101 |
+
if complexity < EXPERT_THINK_PLAN_MIN_COMPLEXITY:
|
| 102 |
+
# The reasoning stays visible: a short analysis, then the answer from it (two calls).
|
| 103 |
+
return EffortProfile(
|
| 104 |
+
"think", "standard", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS, EXPERT_THINK_TOOL_ROUNDS,
|
| 105 |
+
False, False,
|
| 106 |
+
f"complexity {complexity} is below {EXPERT_THINK_PLAN_MIN_COMPLEXITY}: analyzed, then answered without a separate plan",
|
| 107 |
+
analyze=True,
|
| 108 |
+
)
|
| 109 |
+
return EffortProfile("think", "full", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS,
|
| 110 |
+
EXPERT_THINK_TOOL_ROUNDS, True, False, f"complexity {complexity}: analyze, plan, solve",
|
| 111 |
+
analyze=True)
|
| 112 |
+
if mode == "heavy":
|
| 113 |
+
if trivial:
|
| 114 |
+
return EffortProfile("heavy", "direct", EXPERT_HEAVY_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS,
|
| 115 |
+
EXPERT_HEAVY_TOOL_ROUNDS, False, False, "a short message needs no verification")
|
| 116 |
+
research = forced_tools or complexity >= EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY
|
| 117 |
+
if forced_tools:
|
| 118 |
+
why = "a web search was asked for: research, reason, synthesize, validate"
|
| 119 |
+
elif research:
|
| 120 |
+
why = f"complexity {complexity}: research, reason, synthesize, validate"
|
| 121 |
+
else:
|
| 122 |
+
why = f"complexity {complexity} is below {EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY}: reason, synthesize, validate"
|
| 123 |
+
return EffortProfile("heavy", "full" if research else "standard", EXPERT_HEAVY_TEMPERATURE,
|
| 124 |
+
EXPERT_HEAVY_MAX_TOKENS, EXPERT_HEAVY_TOOL_ROUNDS, False, research, why)
|
| 125 |
+
return EffortProfile("deep", "full", EXPERT_DEEP_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS,
|
| 126 |
+
EXPERT_THINK_TOOL_ROUNDS, True, True, "deep research runs its own plan")
|
expert/backend/app/expert/project_context.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A project's context for Expert: its instructions in the system prompt, its documents as a
|
| 2 |
+
tool.
|
| 3 |
+
|
| 4 |
+
When Expert runs inside HomePilot (the usual case: the package is mounted as ``app.expert``),
|
| 5 |
+
the project's documents are searched through the backend's own retrieval β the same
|
| 6 |
+
``query_project_knowledge`` the standard chat uses β so Expert in a project sees what the
|
| 7 |
+
standard chat sees. As a standalone service that retrieval is out of reach, and the tool is
|
| 8 |
+
simply not offered; the instructions still apply.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from .config import EXPERT_SYSTEM_PROMPT
|
| 17 |
+
from .tools import LocalTool, ToolOutcome, ToolSpec
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("expert.project")
|
| 20 |
+
|
| 21 |
+
PROJECT_TOOL = "project_search"
|
| 22 |
+
PROJECT_TOOL_NATIVE = "hp.project.search"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def project_system_prompt(base: Optional[str], project: Optional[Dict[str, Any]]) -> Optional[str]:
|
| 26 |
+
"""The system prompt with the project folded in; ``base`` (or the Expert default) alone
|
| 27 |
+
when there is no project."""
|
| 28 |
+
if not project or not (project.get("name") or project.get("instructions")):
|
| 29 |
+
return base
|
| 30 |
+
name = str(project.get("name") or "this project").strip()
|
| 31 |
+
lines = [base or EXPERT_SYSTEM_PROMPT, "", f"You are working inside the project β{name}β."]
|
| 32 |
+
instructions = str(project.get("instructions") or "").strip()
|
| 33 |
+
if instructions:
|
| 34 |
+
lines += ["Project instructions:", instructions]
|
| 35 |
+
if int(project.get("document_count") or 0) > 0:
|
| 36 |
+
lines.append(
|
| 37 |
+
f"The project has documents; the {PROJECT_TOOL} tool searches them. Prefer what they say over "
|
| 38 |
+
"general knowledge and name the document you used."
|
| 39 |
+
)
|
| 40 |
+
return "\n".join(lines)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _retrieval() -> Optional[Callable[..., List[Dict[str, Any]]]]:
|
| 44 |
+
"""The backend's project retrieval, when this Expert runs inside HomePilot."""
|
| 45 |
+
try:
|
| 46 |
+
from ..vectordb import query_project_knowledge # type: ignore[import-not-found]
|
| 47 |
+
return query_project_knowledge
|
| 48 |
+
except Exception: # noqa: BLE001 β standalone Expert, or RAG disabled
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def render_passages(docs: List[Dict[str, Any]]) -> str:
|
| 53 |
+
"""Retrieved chunks grouped per document, numbered, the way the standard chat renders them."""
|
| 54 |
+
grouped: Dict[str, List[str]] = {}
|
| 55 |
+
for doc in docs or []:
|
| 56 |
+
if not isinstance(doc, dict):
|
| 57 |
+
continue
|
| 58 |
+
source = str((doc.get("metadata") or {}).get("source") or "Unknown")
|
| 59 |
+
grouped.setdefault(source, []).append(str(doc.get("content") or ""))
|
| 60 |
+
if not grouped:
|
| 61 |
+
return "No matching passages in the project's documents."
|
| 62 |
+
parts = []
|
| 63 |
+
for i, (source, chunks) in enumerate(grouped.items(), 1):
|
| 64 |
+
parts.append(f"[Document {i}: {source}]\n" + "\n...\n".join(c.strip() for c in chunks if c.strip()))
|
| 65 |
+
return "\n\n".join(parts)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def project_tool(project: Optional[Dict[str, Any]], retrieval: Optional[Callable[..., Any]] = None) -> Optional[LocalTool]:
|
| 69 |
+
"""The ``project_search`` tool for ``project``, or None when the project has no documents or
|
| 70 |
+
retrieval is not reachable from this process."""
|
| 71 |
+
if not project or not project.get("id") or int(project.get("document_count") or 0) <= 0:
|
| 72 |
+
return None
|
| 73 |
+
search = retrieval or _retrieval()
|
| 74 |
+
if search is None:
|
| 75 |
+
return None
|
| 76 |
+
project_id = str(project["id"])
|
| 77 |
+
name = str(project.get("name") or "the project")
|
| 78 |
+
|
| 79 |
+
async def handler(arguments: Dict[str, Any]) -> ToolOutcome:
|
| 80 |
+
query = str(arguments.get("query") or "").strip()
|
| 81 |
+
if not query:
|
| 82 |
+
return ToolOutcome(name=PROJECT_TOOL, ok=False, error="query is required")
|
| 83 |
+
limit = max(1, min(int(arguments.get("limit") or 5), 10))
|
| 84 |
+
docs = await asyncio.to_thread(search, project_id, query, limit)
|
| 85 |
+
docs = list(docs or [])
|
| 86 |
+
sources = {str((d.get("metadata") or {}).get("source") or "Unknown") for d in docs if isinstance(d, dict)}
|
| 87 |
+
text = render_passages(docs)
|
| 88 |
+
return ToolOutcome(
|
| 89 |
+
name=PROJECT_TOOL, ok=True, text=text,
|
| 90 |
+
data={"project_id": project_id, "passages": len(docs), "documents": sorted(sources)},
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
spec = ToolSpec(
|
| 94 |
+
name=PROJECT_TOOL,
|
| 95 |
+
native=PROJECT_TOOL_NATIVE,
|
| 96 |
+
description=f"Search the documents of the project β{name}β for passages relevant to a query.",
|
| 97 |
+
parameters={
|
| 98 |
+
"type": "object",
|
| 99 |
+
"properties": {
|
| 100 |
+
"query": {"type": "string", "description": "What to look for in the project's documents"},
|
| 101 |
+
"limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 10},
|
| 102 |
+
},
|
| 103 |
+
"required": ["query"],
|
| 104 |
+
},
|
| 105 |
+
server="local",
|
| 106 |
+
)
|
| 107 |
+
return LocalTool(spec=spec, handler=handler)
|
expert/backend/app/expert/providers.py
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/providers.py
|
| 2 |
+
# All new LLM provider adapters for the Expert module.
|
| 3 |
+
# Pattern mirrors backend/app/llm.py β returns OpenAI-compatible dicts.
|
| 4 |
+
# Streaming variants yield raw SSE chunks for the /expert/stream endpoint.
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import contextlib
|
| 9 |
+
import contextvars
|
| 10 |
+
import heapq
|
| 11 |
+
import itertools
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
import time
|
| 17 |
+
from collections import deque
|
| 18 |
+
from typing import Any, AsyncIterator, Deque, Dict, List, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
|
| 22 |
+
from .config import (
|
| 23 |
+
GROK_API_KEY, GROK_BASE_URL, GROK_MODEL,
|
| 24 |
+
GROQ_API_KEY, GROQ_BASE_URL, GROQ_MODEL,
|
| 25 |
+
GEMINI_API_KEY, GEMINI_BASE_URL, GEMINI_MODEL,
|
| 26 |
+
EXPERT_OLLAMA_URL, EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL,
|
| 27 |
+
EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE,
|
| 28 |
+
EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S,
|
| 29 |
+
EXPERT_PROVIDER_RETRIES, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S,
|
| 30 |
+
EXPERT_PROVIDER_WINDOW_S,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("expert.providers")
|
| 34 |
+
|
| 35 |
+
_TIMEOUT = httpx.Timeout(timeout=120.0, connect=15.0)
|
| 36 |
+
_STREAM_TIMEOUT = httpx.Timeout(timeout=300.0, connect=15.0)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
# Pacing and retries β one gateway is never flooded, and a rate limit is waited out
|
| 41 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
|
| 43 |
+
# Who is waiting: 0 = a person in the chat, 1 = background work (a Deep research job). When the
|
| 44 |
+
# endpoint is scarce, interactive requests are served first and may wait less long.
|
| 45 |
+
INTERACTIVE, BACKGROUND = 0, 1
|
| 46 |
+
_PRIORITY: contextvars.ContextVar[int] = contextvars.ContextVar("expert_request_priority", default=INTERACTIVE)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def mark_background() -> None:
|
| 50 |
+
"""Make the current task's model calls background work (call at the start of a job's task;
|
| 51 |
+
a task's context is its own, so the setting never leaks into a chat request)."""
|
| 52 |
+
_PRIORITY.set(BACKGROUND)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class _PriorityGate:
|
| 56 |
+
"""A semaphore whose waiters are served by priority, then first come first served β the
|
| 57 |
+
request queue in front of one endpoint. Cancellation-safe: a waiter that goes away leaves
|
| 58 |
+
the queue, and a slot granted to a request that was cancelled passes to the next one."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, capacity: int) -> None:
|
| 61 |
+
self.capacity = max(1, int(capacity))
|
| 62 |
+
self.inflight = 0
|
| 63 |
+
self._waiters: List[Tuple[int, int, "asyncio.Future[None]"]] = []
|
| 64 |
+
self._seq = itertools.count()
|
| 65 |
+
|
| 66 |
+
def waiting(self) -> int:
|
| 67 |
+
return sum(1 for _, _, fut in self._waiters if not fut.done())
|
| 68 |
+
|
| 69 |
+
async def acquire(self, priority: int) -> None:
|
| 70 |
+
if self.inflight < self.capacity and not self.waiting():
|
| 71 |
+
self.inflight += 1
|
| 72 |
+
return
|
| 73 |
+
fut: "asyncio.Future[None]" = asyncio.get_running_loop().create_future()
|
| 74 |
+
heapq.heappush(self._waiters, (priority, next(self._seq), fut))
|
| 75 |
+
try:
|
| 76 |
+
await fut
|
| 77 |
+
except asyncio.CancelledError:
|
| 78 |
+
if fut.done() and not fut.cancelled():
|
| 79 |
+
self.release() # granted, then cancelled: hand the slot on
|
| 80 |
+
raise
|
| 81 |
+
|
| 82 |
+
def release(self) -> None:
|
| 83 |
+
while self._waiters:
|
| 84 |
+
_, _, fut = heapq.heappop(self._waiters)
|
| 85 |
+
if not fut.done():
|
| 86 |
+
fut.set_result(None) # the slot passes straight to the next waiter
|
| 87 |
+
return
|
| 88 |
+
self.inflight = max(0, self.inflight - 1)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class _EndpointPacer:
|
| 92 |
+
"""Adaptive client-side throttling, per base URL (the pattern behind the AWS SDKs'
|
| 93 |
+
"adaptive" retry mode and Google SRE's client-side throttling):
|
| 94 |
+
|
| 95 |
+
* a **queue** in front of every endpoint: at most ``max_inflight`` requests at a time, the
|
| 96 |
+
rest wait in line β interactive requests ahead of background work β and a gap between
|
| 97 |
+
request starts;
|
| 98 |
+
* the gap starts at ``min_interval`` and **doubles on every rate limit** (up to
|
| 99 |
+
``max_interval``), and every other caller to that endpoint waits out the cooldown too β
|
| 100 |
+
so one 429 slows the whole app down instead of each request hammering on its own;
|
| 101 |
+
* every success **shrinks the gap back** by 15 %, so a quiet endpoint is fast again soon.
|
| 102 |
+
|
| 103 |
+
Heavy's agents and Deep research's sub-agents then reach a shared gateway as a steady
|
| 104 |
+
trickle that adapts to its real limit, instead of a burst that trips it."""
|
| 105 |
+
|
| 106 |
+
def __init__(self, max_inflight: int, min_interval: float, max_interval: float, window: float = 60.0) -> None:
|
| 107 |
+
self.max_inflight = max(1, int(max_inflight))
|
| 108 |
+
self.min_interval = max(0.0, float(min_interval))
|
| 109 |
+
self.max_interval = max(self.min_interval, float(max_interval))
|
| 110 |
+
self.window = max(0.01, float(window))
|
| 111 |
+
self._slots: Dict[str, _PriorityGate] = {}
|
| 112 |
+
self._next_start: Dict[str, float] = {}
|
| 113 |
+
self._interval: Dict[str, float] = {}
|
| 114 |
+
# The meter, per endpoint: request starts (reserved, oldest first), successes and rate
|
| 115 |
+
# limits in the rolling window, and the learned cap β requests per window the endpoint
|
| 116 |
+
# takes before it says "rate limit" β with when it was last lowered or raised.
|
| 117 |
+
self._starts: Dict[str, Deque[float]] = {}
|
| 118 |
+
self._ok: Dict[str, Deque[float]] = {}
|
| 119 |
+
self._limited: Dict[str, Deque[float]] = {}
|
| 120 |
+
self._cap: Dict[str, int] = {}
|
| 121 |
+
self._cap_since: Dict[str, float] = {}
|
| 122 |
+
# Token bucket at the learned rate (cap per window): tokens left, and when last counted.
|
| 123 |
+
# It goes negative as requests reserve future slots, so starts are spread evenly.
|
| 124 |
+
self._tokens: Dict[str, float] = {}
|
| 125 |
+
self._refill_at: Dict[str, float] = {}
|
| 126 |
+
self._totals: Dict[str, Dict[str, int]] = {}
|
| 127 |
+
self._urls: Dict[str, str] = {}
|
| 128 |
+
|
| 129 |
+
def reset(self, *, min_interval: Optional[float] = None, max_interval: Optional[float] = None,
|
| 130 |
+
window: Optional[float] = None) -> None:
|
| 131 |
+
"""Forget every endpoint's state (tests; a settings change)."""
|
| 132 |
+
if min_interval is not None:
|
| 133 |
+
self.min_interval = max(0.0, float(min_interval))
|
| 134 |
+
if max_interval is not None:
|
| 135 |
+
self.max_interval = max(self.min_interval, float(max_interval))
|
| 136 |
+
if window is not None:
|
| 137 |
+
self.window = max(0.01, float(window))
|
| 138 |
+
for store in (self._slots, self._next_start, self._interval, self._starts, self._ok, self._limited,
|
| 139 |
+
self._cap, self._cap_since, self._tokens, self._refill_at, self._totals, self._urls):
|
| 140 |
+
store.clear()
|
| 141 |
+
|
| 142 |
+
# ββ the meter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
def _trim(self, q: Deque[float], now: float) -> Deque[float]:
|
| 144 |
+
while q and q[0] < now - self.window:
|
| 145 |
+
q.popleft()
|
| 146 |
+
return q
|
| 147 |
+
|
| 148 |
+
def _count(self, key: str, what: str) -> None:
|
| 149 |
+
totals = self._totals.setdefault(key, {"requests": 0, "ok": 0, "rate_limited": 0})
|
| 150 |
+
totals[what] = totals.get(what, 0) + 1
|
| 151 |
+
|
| 152 |
+
def cap(self, base_url: str) -> Optional[int]:
|
| 153 |
+
return self._cap.get(self._key(base_url))
|
| 154 |
+
|
| 155 |
+
def usage(self) -> List[Dict[str, Any]]:
|
| 156 |
+
"""Per endpoint: what is in flight and in line, the gap and cooldown, the learned cap,
|
| 157 |
+
and the rolling window's requests, successes and rate limits (``GET /v1/expert/usage``)."""
|
| 158 |
+
now = time.monotonic()
|
| 159 |
+
out: List[Dict[str, Any]] = []
|
| 160 |
+
for key, url in self._urls.items():
|
| 161 |
+
gate = self._slots.get(key)
|
| 162 |
+
limited = self._trim(self._limited.setdefault(key, deque()), now)
|
| 163 |
+
out.append({
|
| 164 |
+
"endpoint": url,
|
| 165 |
+
"in_flight": gate.inflight if gate else 0,
|
| 166 |
+
"queued": gate.waiting() if gate else 0,
|
| 167 |
+
"interval_s": round(self._interval.get(key, self.min_interval), 2),
|
| 168 |
+
"cooldown_s": round(max(0.0, self._next_start.get(key, 0.0) - now), 1),
|
| 169 |
+
"window_s": self.window,
|
| 170 |
+
"cap_per_window": self._cap.get(key),
|
| 171 |
+
"tokens": round(self._tokens[key], 2) if key in self._tokens else None,
|
| 172 |
+
"requests_in_window": len(self._trim(self._starts.setdefault(key, deque()), now)),
|
| 173 |
+
"ok_in_window": len(self._trim(self._ok.setdefault(key, deque()), now)),
|
| 174 |
+
"rate_limited_in_window": len(limited),
|
| 175 |
+
"last_rate_limit_s_ago": round(now - limited[-1], 1) if limited else None,
|
| 176 |
+
"totals": dict(self._totals.get(key, {})),
|
| 177 |
+
})
|
| 178 |
+
return out
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def _key(base_url: str) -> str:
|
| 182 |
+
# Keyed by event loop too: a semaphore belongs to the loop it first waited in.
|
| 183 |
+
return f"{id(asyncio.get_running_loop())}|{(base_url or '').rstrip('/').lower()}"
|
| 184 |
+
|
| 185 |
+
def interval(self, base_url: str) -> float:
|
| 186 |
+
return self._interval.get(self._key(base_url), self.min_interval)
|
| 187 |
+
|
| 188 |
+
def _gate(self, key: str) -> _PriorityGate:
|
| 189 |
+
gate = self._slots.get(key)
|
| 190 |
+
if gate is None:
|
| 191 |
+
gate = self._slots[key] = _PriorityGate(self.max_inflight)
|
| 192 |
+
return gate
|
| 193 |
+
|
| 194 |
+
def expected_wait(self, base_url: str) -> float:
|
| 195 |
+
"""Roughly how long a request sent now waits before it starts: the cooldown, plus one
|
| 196 |
+
gap per request already in line. Used to tell the reader before a long wait."""
|
| 197 |
+
key = self._key(base_url)
|
| 198 |
+
gate = self._gate(key)
|
| 199 |
+
cooldown = max(0.0, self._next_start.get(key, 0.0) - time.monotonic())
|
| 200 |
+
queued = gate.waiting() + (1 if gate.inflight >= gate.capacity else 0)
|
| 201 |
+
return cooldown + queued * self._interval.get(key, self.min_interval)
|
| 202 |
+
|
| 203 |
+
def _bucket(self, key: str, cap: int, now: float) -> float:
|
| 204 |
+
"""Tokens in the bucket now (refilled at ``cap`` per window since last counted)."""
|
| 205 |
+
rate = cap / self.window
|
| 206 |
+
tokens = self._tokens.get(key, float(cap))
|
| 207 |
+
tokens = min(float(cap), tokens + (now - self._refill_at.get(key, now)) * rate)
|
| 208 |
+
self._refill_at[key] = now
|
| 209 |
+
return tokens
|
| 210 |
+
|
| 211 |
+
def _reserve(self, key: str) -> float:
|
| 212 |
+
"""This request's start time: after the cooldown and the gap, and β once a cap has been
|
| 213 |
+
learned β when the token bucket has a token for it. Starts are then spread evenly at the
|
| 214 |
+
learned rate (one every window/cap seconds) instead of a burst and a long stall."""
|
| 215 |
+
now = time.monotonic()
|
| 216 |
+
start_at = max(now, self._next_start.get(key, 0.0))
|
| 217 |
+
starts = self._starts.setdefault(key, deque())
|
| 218 |
+
cap = self._cap.get(key)
|
| 219 |
+
if cap:
|
| 220 |
+
tokens = self._bucket(key, cap, now) - 1.0
|
| 221 |
+
self._tokens[key] = tokens
|
| 222 |
+
if tokens < 0:
|
| 223 |
+
start_at = max(start_at, now + (-tokens) * self.window / cap)
|
| 224 |
+
starts.append(start_at)
|
| 225 |
+
while starts and starts[0] < now - 2 * self.window:
|
| 226 |
+
starts.popleft()
|
| 227 |
+
self._next_start[key] = start_at + self._interval.get(key, self.min_interval)
|
| 228 |
+
return start_at
|
| 229 |
+
|
| 230 |
+
@contextlib.asynccontextmanager
|
| 231 |
+
async def slot(self, base_url: str) -> AsyncIterator[None]:
|
| 232 |
+
key = self._key(base_url)
|
| 233 |
+
self._urls.setdefault(key, (base_url or "").rstrip("/"))
|
| 234 |
+
gate = self._gate(key)
|
| 235 |
+
await gate.acquire(_PRIORITY.get())
|
| 236 |
+
try:
|
| 237 |
+
# Reserve this request's start time before sleeping, so two waiting callers can never
|
| 238 |
+
# read the same free moment and start together.
|
| 239 |
+
start_at = self._reserve(key)
|
| 240 |
+
self._count(key, "requests")
|
| 241 |
+
now = time.monotonic()
|
| 242 |
+
if start_at > now:
|
| 243 |
+
await asyncio.sleep(start_at - now)
|
| 244 |
+
yield
|
| 245 |
+
finally:
|
| 246 |
+
gate.release()
|
| 247 |
+
|
| 248 |
+
def penalize(self, base_url: str, retry_after: Optional[float] = None) -> float:
|
| 249 |
+
"""A rate limit: double the gap, hold every caller until the cooldown ends, and learn the
|
| 250 |
+
cap from the successes this window took β the endpoint's limit is at most that. With no
|
| 251 |
+
success in the window there is nothing to learn from (the limit may be shared with other
|
| 252 |
+
clients): an existing cap is lowered by one, and otherwise backoff alone handles it.
|
| 253 |
+
The bucket is emptied, so the next start waits one learned interval."""
|
| 254 |
+
key = self._key(base_url)
|
| 255 |
+
now = time.monotonic()
|
| 256 |
+
self._count(key, "rate_limited")
|
| 257 |
+
self._trim(self._limited.setdefault(key, deque()), now).append(now)
|
| 258 |
+
ok = len(self._trim(self._ok.setdefault(key, deque()), now))
|
| 259 |
+
previous = self._cap.get(key)
|
| 260 |
+
if ok >= 1:
|
| 261 |
+
self._cap[key] = max(1, min(ok, previous - 1)) if previous else ok
|
| 262 |
+
elif previous:
|
| 263 |
+
self._cap[key] = max(1, previous - 1)
|
| 264 |
+
if key in self._cap:
|
| 265 |
+
self._cap_since[key] = now
|
| 266 |
+
self._tokens[key] = min(0.0, self._bucket(key, self._cap[key], now))
|
| 267 |
+
if self._cap[key] != previous:
|
| 268 |
+
logger.info("endpoint %s: learned a limit of about %d requests per %.0fs", self._urls.get(key, key),
|
| 269 |
+
self._cap[key], self.window)
|
| 270 |
+
current = self._interval.get(key, self.min_interval)
|
| 271 |
+
grown = min(self.max_interval, max(current * 2, 2.0, self.min_interval))
|
| 272 |
+
self._interval[key] = grown
|
| 273 |
+
cooldown = min(self.max_interval, max(grown, retry_after or 0.0))
|
| 274 |
+
self._next_start[key] = max(self._next_start.get(key, 0.0), now + cooldown)
|
| 275 |
+
return grown
|
| 276 |
+
|
| 277 |
+
def reward(self, base_url: str) -> None:
|
| 278 |
+
"""A success: count it, shrink the gap back toward ``min_interval``, and after a quiet
|
| 279 |
+
stretch (three windows without a rate limit) raise the learned cap by one β additive
|
| 280 |
+
increase, so a limit that was lifted is found again."""
|
| 281 |
+
key = self._key(base_url)
|
| 282 |
+
now = time.monotonic()
|
| 283 |
+
self._count(key, "ok")
|
| 284 |
+
self._trim(self._ok.setdefault(key, deque()), now).append(now)
|
| 285 |
+
cap = self._cap.get(key)
|
| 286 |
+
if cap is not None and now - self._cap_since.get(key, now) > 3 * self.window:
|
| 287 |
+
if cap >= 120:
|
| 288 |
+
self._cap.pop(key, None) # far above any limit seen: stop capping
|
| 289 |
+
self._cap_since.pop(key, None)
|
| 290 |
+
else:
|
| 291 |
+
self._cap[key] = cap + 1
|
| 292 |
+
self._cap_since[key] = now
|
| 293 |
+
current = self._interval.get(key)
|
| 294 |
+
if current is not None:
|
| 295 |
+
relaxed = max(self.min_interval, current * 0.85)
|
| 296 |
+
if relaxed <= self.min_interval:
|
| 297 |
+
self._interval.pop(key, None)
|
| 298 |
+
else:
|
| 299 |
+
self._interval[key] = relaxed
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
_PACER = _EndpointPacer(EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S,
|
| 303 |
+
EXPERT_PROVIDER_WINDOW_S)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def endpoint_usage() -> List[Dict[str, Any]]:
|
| 307 |
+
"""The usage meter, for ``GET /v1/expert/usage``."""
|
| 308 |
+
return _PACER.usage()
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
class _Transient(RuntimeError):
|
| 312 |
+
"""A failure worth a short wait and another try β before any token has been produced, so
|
| 313 |
+
nothing is repeated to the reader. Past the retries it reaches the user as it is."""
|
| 314 |
+
|
| 315 |
+
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
|
| 316 |
+
super().__init__(message)
|
| 317 |
+
self.retry_after = retry_after
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
class _RateLimited(_Transient):
|
| 321 |
+
"""HTTP 429, or a body that says "rate limit" (Open WebUI says it with a 400)."""
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class _Overloaded(_Transient):
|
| 325 |
+
"""HTTP 502/503/504: a gateway shedding load (503 + Retry-After is backpressure, not an
|
| 326 |
+
error) or a replica mid-restart. One or two tries later it is usually gone."""
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _retry_after(headers: Any) -> Optional[float]:
|
| 330 |
+
try:
|
| 331 |
+
value = float((headers or {}).get("retry-after") or 0)
|
| 332 |
+
except (TypeError, ValueError):
|
| 333 |
+
return None
|
| 334 |
+
return value if 0 < value <= 30 else None
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _backoff_s(attempt: int, retry_after: Optional[float]) -> float:
|
| 338 |
+
"""Exponential backoff with jitter: about 2 s, 4 s, 8 s, 16 s (capped at 20 s), each Β±25 %
|
| 339 |
+
so callers that failed together do not retry together. A Retry-After header is a floor."""
|
| 340 |
+
base = min(20.0, 2.0 * (2 ** attempt))
|
| 341 |
+
if retry_after:
|
| 342 |
+
base = max(base, retry_after)
|
| 343 |
+
return base * random.uniform(0.75, 1.25)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _wait_budget() -> float:
|
| 347 |
+
"""How long a request may wait in line and retry before it gives up: a person waits less
|
| 348 |
+
than a background job."""
|
| 349 |
+
return EXPERT_PROVIDER_BACKGROUND_WAIT_S if _PRIORITY.get() == BACKGROUND else EXPERT_PROVIDER_WAIT_S
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _gave_up(err: "_Transient", waited: float, model: str) -> RuntimeError:
|
| 353 |
+
"""The error a reader sees once the wait budget is spent: what happened, and what to do."""
|
| 354 |
+
name = (model or "the selected model").split("/")[-1]
|
| 355 |
+
if isinstance(err, _RateLimited):
|
| 356 |
+
text = (f"The model provider for {name} is busy right now (rate limited). HomePilot waited "
|
| 357 |
+
f"{waited:.0f} s in line; please try again in a minute.")
|
| 358 |
+
else:
|
| 359 |
+
text = (f"The model provider for {name} is overloaded or restarting. HomePilot waited "
|
| 360 |
+
f"{waited:.0f} s; please try again in a minute.")
|
| 361 |
+
out = type(err)(text, retry_after=err.retry_after)
|
| 362 |
+
out.__cause__ = err
|
| 363 |
+
return out
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _next_retry(err: "_Transient", attempt: int, started: float, base_url: str, model: str) -> float:
|
| 367 |
+
"""The delay before the next try, or raise when the budget (time or tries) is spent."""
|
| 368 |
+
delay = _backoff_s(attempt, err.retry_after)
|
| 369 |
+
waited = time.monotonic() - started
|
| 370 |
+
if attempt >= EXPERT_PROVIDER_RETRIES or waited + delay > _wait_budget():
|
| 371 |
+
logger.warning("%s for %s: giving up after %.0fs and %d tries: %s", base_url, model, waited, attempt + 1, err)
|
| 372 |
+
raise _gave_up(err, waited, model)
|
| 373 |
+
logger.info("%s for %s: %s; retry %d in %.1fs (waited %.0fs of %.0fs)", base_url, model,
|
| 374 |
+
type(err).__name__.strip("_").lower(), attempt + 1, delay, waited, _wait_budget())
|
| 375 |
+
return delay
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _wait_event(text: str, seconds: float, attempt: int = 0) -> Dict[str, Any]:
|
| 379 |
+
return {"wait": {"text": text, "retry_in": round(max(0.0, seconds), 1), "attempt": attempt}}
|
| 380 |
+
|
| 381 |
+
# If the configured EXPERT_LOCAL_MODEL / EXPERT_LOCAL_FAST_MODEL isn't pulled,
|
| 382 |
+
# fall back to whatever Ollama has locally. Set to "false" to disable and get
|
| 383 |
+
# a hard error instead β useful in production where the deployed model must
|
| 384 |
+
# match exactly.
|
| 385 |
+
_LOCAL_AUTO_FALLBACK = os.getenv("EXPERT_LOCAL_AUTO_FALLBACK", "true").lower() != "false"
|
| 386 |
+
|
| 387 |
+
# Where Ollama may live besides EXPERT_OLLAMA_URL: loopback, and the Windows /
|
| 388 |
+
# Docker host when this service runs inside WSL or a container. The first URL
|
| 389 |
+
# that answers /api/tags with models wins and is remembered for a short time
|
| 390 |
+
# (same rules as HomePilot's backend/app/model_resolver.py; this service is
|
| 391 |
+
# deliberately isolated, so the 40 lines are copied rather than imported).
|
| 392 |
+
_OLLAMA_CACHE: Dict[str, Any] = {"url": "", "ts": 0.0, "models": []}
|
| 393 |
+
_OLLAMA_CACHE_TTL_S = 30.0
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _read_text(path: str) -> str:
|
| 397 |
+
try:
|
| 398 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
| 399 |
+
return fh.read()
|
| 400 |
+
except OSError:
|
| 401 |
+
return ""
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _host_gateway_ips() -> List[str]:
|
| 405 |
+
out: List[str] = []
|
| 406 |
+
for line in _read_text("/proc/net/route").splitlines()[1:]:
|
| 407 |
+
parts = line.split()
|
| 408 |
+
if len(parts) >= 3 and parts[1] == "00000000":
|
| 409 |
+
try:
|
| 410 |
+
raw = int(parts[2], 16)
|
| 411 |
+
out.append(".".join(str((raw >> (8 * i)) & 0xFF) for i in range(4)))
|
| 412 |
+
except ValueError:
|
| 413 |
+
pass
|
| 414 |
+
for line in _read_text("/etc/resolv.conf").splitlines():
|
| 415 |
+
p = line.split()
|
| 416 |
+
if len(p) >= 2 and p[0] == "nameserver" and p[1].count(".") == 3 and p[1] not in out:
|
| 417 |
+
out.append(p[1])
|
| 418 |
+
return [ip for ip in out if not ip.startswith("127.")]
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _ollama_candidates() -> List[str]:
|
| 422 |
+
urls = [EXPERT_OLLAMA_URL, "http://127.0.0.1:11434", "http://localhost:11434"]
|
| 423 |
+
in_wsl = bool(os.environ.get("WSL_DISTRO_NAME")) or "microsoft" in _read_text("/proc/version").lower()
|
| 424 |
+
if in_wsl:
|
| 425 |
+
urls += [f"http://{ip}:11434" for ip in _host_gateway_ips()]
|
| 426 |
+
if os.path.exists("/.dockerenv"):
|
| 427 |
+
urls += ["http://host.docker.internal:11434", "http://172.17.0.1:11434"]
|
| 428 |
+
seen: List[str] = []
|
| 429 |
+
for u in urls:
|
| 430 |
+
u = (u or "").rstrip("/")
|
| 431 |
+
if u and u not in seen:
|
| 432 |
+
seen.append(u)
|
| 433 |
+
return seen
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def _ollama_base() -> str:
|
| 437 |
+
"""Runtime URL the last successful probe found (EXPERT_OLLAMA_URL until then)."""
|
| 438 |
+
return _OLLAMA_CACHE["url"] or EXPERT_OLLAMA_URL
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
async def _ollama_pulled_models() -> List[str]:
|
| 442 |
+
"""Model tags of the first reachable Ollama among ``_ollama_candidates()``
|
| 443 |
+
that has models. Remembers which URL answered so chat_local/stream_local
|
| 444 |
+
talk to the same runtime. Empty when nothing answers."""
|
| 445 |
+
import time as _time
|
| 446 |
+
now = _time.monotonic()
|
| 447 |
+
if _OLLAMA_CACHE["url"] and _OLLAMA_CACHE["models"] and now - _OLLAMA_CACHE["ts"] < _OLLAMA_CACHE_TTL_S:
|
| 448 |
+
return list(_OLLAMA_CACHE["models"])
|
| 449 |
+
best_url, best_models = "", []
|
| 450 |
+
try:
|
| 451 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client:
|
| 452 |
+
for url in _ollama_candidates():
|
| 453 |
+
try:
|
| 454 |
+
r = await client.get(f"{url}/api/tags")
|
| 455 |
+
r.raise_for_status()
|
| 456 |
+
data = r.json()
|
| 457 |
+
except Exception:
|
| 458 |
+
continue
|
| 459 |
+
models = [m.get("name") or m.get("model") for m in data.get("models", []) if isinstance(m, dict)]
|
| 460 |
+
if models:
|
| 461 |
+
best_url, best_models = url, models
|
| 462 |
+
break
|
| 463 |
+
if not best_url:
|
| 464 |
+
best_url = url
|
| 465 |
+
except Exception:
|
| 466 |
+
pass
|
| 467 |
+
if best_url:
|
| 468 |
+
_OLLAMA_CACHE.update(url=best_url, ts=now, models=best_models)
|
| 469 |
+
return best_models
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
async def _resolve_local_model(requested: str) -> str:
|
| 473 |
+
"""Return ``requested`` if Ollama has it pulled, otherwise either fall back
|
| 474 |
+
to the first available model or raise a clear error describing how to pull
|
| 475 |
+
it. Behavior controlled by ``EXPERT_LOCAL_AUTO_FALLBACK``."""
|
| 476 |
+
pulled = await _ollama_pulled_models()
|
| 477 |
+
if not pulled:
|
| 478 |
+
probed = ", ".join(_ollama_candidates())
|
| 479 |
+
raise RuntimeError(
|
| 480 |
+
f"No Ollama with models answered (tried {probed}). Open Settings β Models in "
|
| 481 |
+
f"HomePilot to download one, or run `ollama pull {requested}`. If Ollama runs on "
|
| 482 |
+
f"Windows and this service runs in WSL, enable WSL mirrored networking."
|
| 483 |
+
)
|
| 484 |
+
if requested in pulled:
|
| 485 |
+
return requested
|
| 486 |
+
if not _LOCAL_AUTO_FALLBACK:
|
| 487 |
+
raise RuntimeError(
|
| 488 |
+
f"Expert local model '{requested}' is not pulled. "
|
| 489 |
+
f"Available: {pulled}. Run `ollama pull {requested}` to fix."
|
| 490 |
+
)
|
| 491 |
+
# Prefer an exact stem match (handles ':latest' vs ':Q4_K_M' variants).
|
| 492 |
+
stem = requested.split(":")[0]
|
| 493 |
+
for m in pulled:
|
| 494 |
+
if m and m.split(":")[0] == stem:
|
| 495 |
+
return m
|
| 496 |
+
return pulled[0]
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 500 |
+
# Helpers
|
| 501 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 502 |
+
|
| 503 |
+
def _openai_wrap(
|
| 504 |
+
content: str, model: str, provider: str, tool_calls: Optional[List[Dict[str, Any]]] = None
|
| 505 |
+
) -> Dict[str, Any]:
|
| 506 |
+
"""Wrap a plain text response into OpenAI-like schema for orchestrator consistency.
|
| 507 |
+
``tool_calls`` (OpenAI shape, see :func:`_normalize_tool_calls`) ride on the message."""
|
| 508 |
+
message: Dict[str, Any] = {"role": "assistant", "content": content}
|
| 509 |
+
if tool_calls:
|
| 510 |
+
message["tool_calls"] = tool_calls
|
| 511 |
+
return {
|
| 512 |
+
"choices": [{"message": message}],
|
| 513 |
+
"model": model,
|
| 514 |
+
"provider": provider,
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def _extract_content(data: Dict[str, Any]) -> str:
|
| 519 |
+
"""Extract text content from OpenAI-style response dict."""
|
| 520 |
+
try:
|
| 521 |
+
return data["choices"][0]["message"]["content"] or ""
|
| 522 |
+
except (KeyError, IndexError):
|
| 523 |
+
return ""
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 527 |
+
# Tool calling β one wire shape inside Expert (OpenAI's), translated per provider
|
| 528 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 529 |
+
|
| 530 |
+
class ToolsUnsupported(RuntimeError):
|
| 531 |
+
"""The model or endpoint rejected the ``tools`` field; the caller retries without tools."""
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def _rate_limited(status: int, body: str) -> bool:
|
| 535 |
+
text = (body or "").lower()
|
| 536 |
+
return status == 429 or "rate limit" in text or "rate_limit" in text or "too many requests" in text
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def _tools_rejected(status: int, body: str) -> bool:
|
| 540 |
+
"""Ollama answers 400 "<model> does not support tools"; OllaBridge says ``tools_unsupported``;
|
| 541 |
+
other gateways say similar things. Never a rate limit, whatever its status."""
|
| 542 |
+
text = (body or "").lower()
|
| 543 |
+
if _rate_limited(status, text):
|
| 544 |
+
return False
|
| 545 |
+
if "tools_unsupported" in text:
|
| 546 |
+
return True
|
| 547 |
+
return status in (400, 422) and ("tool" in text or "function" in text)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _legacy_tools_failure(status: int, body: str) -> bool:
|
| 551 |
+
"""OllaBridge before October 2026 sent a tool-bearing request for a provider model to local
|
| 552 |
+
Ollama: HTTP 500 wrapping "404 Not Found β¦ /api/chat". Read as "no tools here"."""
|
| 553 |
+
text = (body or "").lower()
|
| 554 |
+
return status >= 500 and "/api/chat" in text and "404" in text
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _wrapped_local_refusal(status: int, body: str) -> bool:
|
| 558 |
+
"""An OllaBridge 500 wrapping local Ollama's 400 ("Client error '400 Bad Request' for url β¦
|
| 559 |
+
/api/chat"): on a tool-bearing request, Ollama's "this model does not support tools"."""
|
| 560 |
+
text = (body or "").lower()
|
| 561 |
+
return status >= 500 and "/api/chat" in text and "400" in text
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def _message_in(data: Any) -> str:
|
| 565 |
+
"""The human message in an error body, however it is nested: OpenAI's ``error.message``,
|
| 566 |
+
FastAPI's ``detail`` (a string, or OllaBridge's ``detail.error.message``), or ``message``."""
|
| 567 |
+
if isinstance(data, str):
|
| 568 |
+
return data.strip()
|
| 569 |
+
if not isinstance(data, dict):
|
| 570 |
+
return ""
|
| 571 |
+
for key in ("error", "detail", "message"):
|
| 572 |
+
found = _message_in(data.get(key))
|
| 573 |
+
if found:
|
| 574 |
+
return found
|
| 575 |
+
return ""
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def _detail_text(body: str) -> str:
|
| 579 |
+
"""The message inside an error body (see :func:`_message_in`), else the body."""
|
| 580 |
+
try:
|
| 581 |
+
data = json.loads(body or "")
|
| 582 |
+
except (json.JSONDecodeError, TypeError):
|
| 583 |
+
return (body or "").strip()
|
| 584 |
+
return _message_in(data) or (body or "").strip()
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def _openai_compat_error(status: int, detail: str, headers: Any, *, base_url: str, model: str, tools: bool) -> Exception:
|
| 588 |
+
"""The exception for an error answer from ``{base_url}/chat/completions``: a rate limit to
|
| 589 |
+
wait out, a refusal of the tools field to remember, or the endpoint's own explanation."""
|
| 590 |
+
who = f"{model or 'The selected model'} at {base_url}"
|
| 591 |
+
text = (detail or "").strip()
|
| 592 |
+
if _rate_limited(status, text):
|
| 593 |
+
return _RateLimited(
|
| 594 |
+
f"{who} is being rate limited by its provider (HTTP {status}"
|
| 595 |
+
+ (f": {text[:160]}" if text else "") + "). Try again in a moment.",
|
| 596 |
+
retry_after=_retry_after(headers),
|
| 597 |
+
)
|
| 598 |
+
if tools and (_tools_rejected(status, text) or _legacy_tools_failure(status, text)
|
| 599 |
+
or _wrapped_local_refusal(status, text)):
|
| 600 |
+
return ToolsUnsupported(
|
| 601 |
+
f"{who} rejected the tools field (HTTP {status}" + (f": {text[:160]}" if text else "") + ")"
|
| 602 |
+
)
|
| 603 |
+
if _legacy_tools_failure(status, text):
|
| 604 |
+
# An older OllaBridge reports a provider failure (most often its rate limit) as a 500
|
| 605 |
+
# from local Ollama; for a request without tools it is worth waiting for, not fatal.
|
| 606 |
+
return _RateLimited(
|
| 607 |
+
f"{who} could not reach its provider (HTTP {status}; an older OllaBridge reports a "
|
| 608 |
+
"provider's rate limit this way). Try again in a moment.",
|
| 609 |
+
retry_after=_retry_after(headers),
|
| 610 |
+
)
|
| 611 |
+
if status in (502, 503, 504):
|
| 612 |
+
return _Overloaded(
|
| 613 |
+
f"{who} is overloaded or restarting (HTTP {status}"
|
| 614 |
+
+ (f": {text[:160]}" if text else "") + "). Try again in a moment.",
|
| 615 |
+
retry_after=_retry_after(headers),
|
| 616 |
+
)
|
| 617 |
+
return RuntimeError(f"{who} answered HTTP {status}" + (f": {text[:240]}" if text else ""))
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _normalize_tool_calls(raw: Any) -> List[Dict[str, Any]]:
|
| 621 |
+
"""Tool calls as OpenAI emits them: ``{id, type, function: {name, arguments: <json str>}}``.
|
| 622 |
+
Ollama sends ``arguments`` as an object and no id; both are normalized here."""
|
| 623 |
+
out: List[Dict[str, Any]] = []
|
| 624 |
+
for i, call in enumerate(raw or []):
|
| 625 |
+
if not isinstance(call, dict):
|
| 626 |
+
continue
|
| 627 |
+
fn = call.get("function") or {}
|
| 628 |
+
args = fn.get("arguments")
|
| 629 |
+
if not isinstance(args, str):
|
| 630 |
+
args = json.dumps(args or {}, ensure_ascii=False)
|
| 631 |
+
out.append({
|
| 632 |
+
"id": str(call.get("id") or f"call_{i + 1}"),
|
| 633 |
+
"type": "function",
|
| 634 |
+
"function": {"name": str(fn.get("name") or ""), "arguments": args or "{}"},
|
| 635 |
+
})
|
| 636 |
+
return out
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def _openai_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 640 |
+
"""Messages for an OpenAI-compatible endpoint: role/content, plus the tool-call fields."""
|
| 641 |
+
out: List[Dict[str, Any]] = []
|
| 642 |
+
for m in messages:
|
| 643 |
+
role = m.get("role")
|
| 644 |
+
if role == "tool":
|
| 645 |
+
out.append({"role": "tool", "tool_call_id": str(m.get("tool_call_id") or ""), "content": str(m.get("content") or "")})
|
| 646 |
+
elif role == "assistant" and m.get("tool_calls"):
|
| 647 |
+
out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": _normalize_tool_calls(m["tool_calls"])})
|
| 648 |
+
else:
|
| 649 |
+
out.append({"role": role, "content": m.get("content") or ""})
|
| 650 |
+
return out
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def _ollama_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 654 |
+
"""Messages for Ollama's /api/chat: tool results carry ``tool_name`` and arguments are objects."""
|
| 655 |
+
out: List[Dict[str, Any]] = []
|
| 656 |
+
for m in messages:
|
| 657 |
+
role = m.get("role")
|
| 658 |
+
if role == "tool":
|
| 659 |
+
out.append({"role": "tool", "content": str(m.get("content") or ""), "tool_name": str(m.get("name") or "")})
|
| 660 |
+
elif role == "assistant" and m.get("tool_calls"):
|
| 661 |
+
calls = []
|
| 662 |
+
for c in _normalize_tool_calls(m["tool_calls"]):
|
| 663 |
+
try:
|
| 664 |
+
args = json.loads(c["function"]["arguments"] or "{}")
|
| 665 |
+
except json.JSONDecodeError:
|
| 666 |
+
args = {"input": c["function"]["arguments"]}
|
| 667 |
+
calls.append({"function": {"name": c["function"]["name"], "arguments": args if isinstance(args, dict) else {"input": args}}})
|
| 668 |
+
out.append({"role": "assistant", "content": m.get("content") or "", "tool_calls": calls})
|
| 669 |
+
else:
|
| 670 |
+
out.append({"role": role, "content": m.get("content") or ""})
|
| 671 |
+
return out
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def _params_b(text: Any) -> Optional[float]:
|
| 675 |
+
"""'8.2B' β 8.2, '70B' β 70, '355M' β 0.355; None when the text says nothing."""
|
| 676 |
+
s = str(text or "").strip().upper()
|
| 677 |
+
if not s:
|
| 678 |
+
return None
|
| 679 |
+
try:
|
| 680 |
+
if s.endswith("B"):
|
| 681 |
+
return float(s[:-1])
|
| 682 |
+
if s.endswith("M"):
|
| 683 |
+
return float(s[:-1]) / 1000.0
|
| 684 |
+
return float(s)
|
| 685 |
+
except ValueError:
|
| 686 |
+
return None
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
async def ollama_models_detail(base_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
| 690 |
+
"""``[{name, params_b, family, size_gb}]`` from ``/api/tags`` of ``base_url`` or the default
|
| 691 |
+
Ollama. Empty when nothing answers."""
|
| 692 |
+
urls = [base_url.rstrip("/")] if base_url else _ollama_candidates()
|
| 693 |
+
try:
|
| 694 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client:
|
| 695 |
+
for url in urls:
|
| 696 |
+
try:
|
| 697 |
+
r = await client.get(f"{url}/api/tags")
|
| 698 |
+
r.raise_for_status()
|
| 699 |
+
data = r.json()
|
| 700 |
+
except Exception:
|
| 701 |
+
continue
|
| 702 |
+
out = []
|
| 703 |
+
for m in data.get("models", []) or []:
|
| 704 |
+
if not isinstance(m, dict):
|
| 705 |
+
continue
|
| 706 |
+
details = m.get("details") or {}
|
| 707 |
+
out.append({
|
| 708 |
+
"name": m.get("name") or m.get("model") or "",
|
| 709 |
+
"params_b": _params_b(details.get("parameter_size")),
|
| 710 |
+
"family": str(details.get("family") or ""),
|
| 711 |
+
"size_gb": round(float(m.get("size") or 0) / 1e9, 1),
|
| 712 |
+
})
|
| 713 |
+
if out:
|
| 714 |
+
return out
|
| 715 |
+
except Exception:
|
| 716 |
+
pass
|
| 717 |
+
return []
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
async def openai_compat_models(base_url: str, api_key: str = "") -> List[str]:
|
| 721 |
+
"""Model ids an OpenAI-compatible endpoint lists at ``GET {base_url}/models``."""
|
| 722 |
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
| 723 |
+
try:
|
| 724 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
|
| 725 |
+
r = await client.get(f"{base_url.rstrip('/')}/models", headers=headers)
|
| 726 |
+
r.raise_for_status()
|
| 727 |
+
data = r.json()
|
| 728 |
+
except Exception:
|
| 729 |
+
return []
|
| 730 |
+
items = data.get("data") if isinstance(data, dict) else data
|
| 731 |
+
names: List[str] = []
|
| 732 |
+
for item in items or []:
|
| 733 |
+
name = item.get("id") or item.get("name") if isinstance(item, dict) else item
|
| 734 |
+
if isinstance(name, str) and name:
|
| 735 |
+
names.append(name)
|
| 736 |
+
return names
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 740 |
+
# xAI Grok (OpenAI-compatible β reuses same wire format)
|
| 741 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 742 |
+
|
| 743 |
+
async def chat_grok(
|
| 744 |
+
messages: List[Dict[str, Any]],
|
| 745 |
+
*,
|
| 746 |
+
model: Optional[str] = None,
|
| 747 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 748 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 749 |
+
) -> Dict[str, Any]:
|
| 750 |
+
"""xAI Grok via OpenAI-compatible /v1/chat/completions."""
|
| 751 |
+
if not GROK_API_KEY:
|
| 752 |
+
raise RuntimeError("GROK_API_KEY not set β cannot use Grok provider.")
|
| 753 |
+
mdl = model or GROK_MODEL
|
| 754 |
+
url = f"{GROK_BASE_URL}/chat/completions"
|
| 755 |
+
payload = {
|
| 756 |
+
"model": mdl,
|
| 757 |
+
"messages": messages,
|
| 758 |
+
"temperature": float(temperature),
|
| 759 |
+
"max_tokens": int(max_tokens),
|
| 760 |
+
}
|
| 761 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 762 |
+
r = await client.post(
|
| 763 |
+
url, json=payload,
|
| 764 |
+
headers={"Authorization": f"Bearer {GROK_API_KEY}"},
|
| 765 |
+
)
|
| 766 |
+
r.raise_for_status()
|
| 767 |
+
data = r.json()
|
| 768 |
+
data["provider"] = "grok"
|
| 769 |
+
return data
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
async def stream_grok(
|
| 773 |
+
messages: List[Dict[str, Any]],
|
| 774 |
+
*,
|
| 775 |
+
model: Optional[str] = None,
|
| 776 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 777 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 778 |
+
) -> AsyncIterator[str]:
|
| 779 |
+
"""Stream tokens from Grok, yielding SSE-ready delta strings."""
|
| 780 |
+
if not GROK_API_KEY:
|
| 781 |
+
raise RuntimeError("GROK_API_KEY not set.")
|
| 782 |
+
mdl = model or GROK_MODEL
|
| 783 |
+
url = f"{GROK_BASE_URL}/chat/completions"
|
| 784 |
+
payload = {
|
| 785 |
+
"model": mdl, "messages": messages, "stream": True,
|
| 786 |
+
"temperature": float(temperature), "max_tokens": int(max_tokens),
|
| 787 |
+
}
|
| 788 |
+
async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
|
| 789 |
+
async with client.stream(
|
| 790 |
+
"POST", url, json=payload,
|
| 791 |
+
headers={"Authorization": f"Bearer {GROK_API_KEY}"},
|
| 792 |
+
) as resp:
|
| 793 |
+
resp.raise_for_status()
|
| 794 |
+
async for line in resp.aiter_lines():
|
| 795 |
+
if line.startswith("data: "):
|
| 796 |
+
chunk = line[6:]
|
| 797 |
+
if chunk.strip() == "[DONE]":
|
| 798 |
+
break
|
| 799 |
+
try:
|
| 800 |
+
delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
|
| 801 |
+
if delta:
|
| 802 |
+
yield delta
|
| 803 |
+
except (json.JSONDecodeError, KeyError, IndexError):
|
| 804 |
+
continue
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 808 |
+
# Groq (ultra-fast, free tier, open models: Llama 3.3 70B, Mixtral, etc.)
|
| 809 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 810 |
+
|
| 811 |
+
async def chat_groq(
|
| 812 |
+
messages: List[Dict[str, Any]],
|
| 813 |
+
*,
|
| 814 |
+
model: Optional[str] = None,
|
| 815 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 816 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 817 |
+
) -> Dict[str, Any]:
|
| 818 |
+
"""Groq inference API β OpenAI-compatible, blazing fast."""
|
| 819 |
+
if not GROQ_API_KEY:
|
| 820 |
+
raise RuntimeError("GROQ_API_KEY not set β cannot use Groq provider.")
|
| 821 |
+
mdl = model or GROQ_MODEL
|
| 822 |
+
url = f"{GROQ_BASE_URL}/chat/completions"
|
| 823 |
+
payload = {
|
| 824 |
+
"model": mdl,
|
| 825 |
+
"messages": messages,
|
| 826 |
+
"temperature": float(temperature),
|
| 827 |
+
"max_tokens": int(max_tokens),
|
| 828 |
+
}
|
| 829 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 830 |
+
r = await client.post(
|
| 831 |
+
url, json=payload,
|
| 832 |
+
headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
|
| 833 |
+
)
|
| 834 |
+
r.raise_for_status()
|
| 835 |
+
data = r.json()
|
| 836 |
+
data["provider"] = "groq"
|
| 837 |
+
return data
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
async def stream_groq(
|
| 841 |
+
messages: List[Dict[str, Any]],
|
| 842 |
+
*,
|
| 843 |
+
model: Optional[str] = None,
|
| 844 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 845 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 846 |
+
) -> AsyncIterator[str]:
|
| 847 |
+
"""Stream tokens from Groq."""
|
| 848 |
+
if not GROQ_API_KEY:
|
| 849 |
+
raise RuntimeError("GROQ_API_KEY not set.")
|
| 850 |
+
mdl = model or GROQ_MODEL
|
| 851 |
+
url = f"{GROQ_BASE_URL}/chat/completions"
|
| 852 |
+
payload = {
|
| 853 |
+
"model": mdl, "messages": messages, "stream": True,
|
| 854 |
+
"temperature": float(temperature), "max_tokens": int(max_tokens),
|
| 855 |
+
}
|
| 856 |
+
async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
|
| 857 |
+
async with client.stream(
|
| 858 |
+
"POST", url, json=payload,
|
| 859 |
+
headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
|
| 860 |
+
) as resp:
|
| 861 |
+
resp.raise_for_status()
|
| 862 |
+
async for line in resp.aiter_lines():
|
| 863 |
+
if line.startswith("data: "):
|
| 864 |
+
chunk = line[6:]
|
| 865 |
+
if chunk.strip() == "[DONE]":
|
| 866 |
+
break
|
| 867 |
+
try:
|
| 868 |
+
delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
|
| 869 |
+
if delta:
|
| 870 |
+
yield delta
|
| 871 |
+
except (json.JSONDecodeError, KeyError, IndexError):
|
| 872 |
+
continue
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 876 |
+
# Google Gemini (via OpenAI-compatible endpoint β available since Gemini 1.5)
|
| 877 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 878 |
+
|
| 879 |
+
async def chat_gemini(
|
| 880 |
+
messages: List[Dict[str, Any]],
|
| 881 |
+
*,
|
| 882 |
+
model: Optional[str] = None,
|
| 883 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 884 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 885 |
+
) -> Dict[str, Any]:
|
| 886 |
+
"""Google Gemini via its OpenAI-compatible REST endpoint."""
|
| 887 |
+
if not GEMINI_API_KEY:
|
| 888 |
+
raise RuntimeError("GEMINI_API_KEY not set β cannot use Gemini provider.")
|
| 889 |
+
mdl = model or GEMINI_MODEL
|
| 890 |
+
url = f"{GEMINI_BASE_URL}/chat/completions"
|
| 891 |
+
payload = {
|
| 892 |
+
"model": mdl,
|
| 893 |
+
"messages": messages,
|
| 894 |
+
"temperature": float(temperature),
|
| 895 |
+
"max_tokens": int(max_tokens),
|
| 896 |
+
}
|
| 897 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 898 |
+
r = await client.post(
|
| 899 |
+
url, json=payload,
|
| 900 |
+
headers={"Authorization": f"Bearer {GEMINI_API_KEY}"},
|
| 901 |
+
)
|
| 902 |
+
r.raise_for_status()
|
| 903 |
+
data = r.json()
|
| 904 |
+
data["provider"] = "gemini"
|
| 905 |
+
return data
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
async def stream_gemini(
|
| 909 |
+
messages: List[Dict[str, Any]],
|
| 910 |
+
*,
|
| 911 |
+
model: Optional[str] = None,
|
| 912 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 913 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 914 |
+
) -> AsyncIterator[str]:
|
| 915 |
+
"""Stream tokens from Gemini."""
|
| 916 |
+
if not GEMINI_API_KEY:
|
| 917 |
+
raise RuntimeError("GEMINI_API_KEY not set.")
|
| 918 |
+
mdl = model or GEMINI_MODEL
|
| 919 |
+
url = f"{GEMINI_BASE_URL}/chat/completions"
|
| 920 |
+
payload = {
|
| 921 |
+
"model": mdl, "messages": messages, "stream": True,
|
| 922 |
+
"temperature": float(temperature), "max_tokens": int(max_tokens),
|
| 923 |
+
}
|
| 924 |
+
async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
|
| 925 |
+
async with client.stream(
|
| 926 |
+
"POST", url, json=payload,
|
| 927 |
+
headers={"Authorization": f"Bearer {GEMINI_API_KEY}"},
|
| 928 |
+
) as resp:
|
| 929 |
+
resp.raise_for_status()
|
| 930 |
+
async for line in resp.aiter_lines():
|
| 931 |
+
if line.startswith("data: "):
|
| 932 |
+
chunk = line[6:]
|
| 933 |
+
if chunk.strip() == "[DONE]":
|
| 934 |
+
break
|
| 935 |
+
try:
|
| 936 |
+
delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
|
| 937 |
+
if delta:
|
| 938 |
+
yield delta
|
| 939 |
+
except (json.JSONDecodeError, KeyError, IndexError):
|
| 940 |
+
continue
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 944 |
+
# OpenAI-compatible endpoint the user chose for chat (OllaBridge gateway, Cloud relay, vLLMβ¦)
|
| 945 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 946 |
+
|
| 947 |
+
async def chat_openai_compat(
|
| 948 |
+
messages: List[Dict[str, Any]],
|
| 949 |
+
*,
|
| 950 |
+
base_url: str,
|
| 951 |
+
model: str = "",
|
| 952 |
+
api_key: str = "",
|
| 953 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 954 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 955 |
+
provider_label: str = "openai_compat",
|
| 956 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 957 |
+
) -> Dict[str, Any]:
|
| 958 |
+
"""``POST {base_url}/chat/completions`` β the same request HomePilot's ordinary chat sends
|
| 959 |
+
(``app.llm.chat_openai_compat``), kept here because Expert is loaded as its own top-level
|
| 960 |
+
package and cannot import the backend's modules relatively.
|
| 961 |
+
|
| 962 |
+
Failures carry the endpoint's own explanation: a gateway that refuses a model says why in
|
| 963 |
+
its body, and "HTTP 500" alone sends the user looking in the wrong place.
|
| 964 |
+
"""
|
| 965 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 966 |
+
payload: Dict[str, Any] = {
|
| 967 |
+
"messages": _openai_messages(messages),
|
| 968 |
+
"temperature": float(temperature),
|
| 969 |
+
"max_tokens": int(max_tokens),
|
| 970 |
+
"stream": False,
|
| 971 |
+
}
|
| 972 |
+
if model:
|
| 973 |
+
payload["model"] = model
|
| 974 |
+
if tools:
|
| 975 |
+
payload["tools"] = tools
|
| 976 |
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
| 977 |
+
attempt = 0
|
| 978 |
+
started = time.monotonic()
|
| 979 |
+
while True:
|
| 980 |
+
try:
|
| 981 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client, _PACER.slot(base_url):
|
| 982 |
+
r = await client.post(url, json=payload, headers=headers)
|
| 983 |
+
except httpx.HTTPError as exc:
|
| 984 |
+
raise RuntimeError(
|
| 985 |
+
f"Could not reach {base_url} for {model or 'the selected model'} "
|
| 986 |
+
f"({type(exc).__name__}). Is it running?"
|
| 987 |
+
) from exc
|
| 988 |
+
if r.status_code < 400:
|
| 989 |
+
_PACER.reward(base_url)
|
| 990 |
+
break
|
| 991 |
+
err = _openai_compat_error(
|
| 992 |
+
r.status_code, _error_detail(r), r.headers, base_url=base_url, model=model, tools=bool(tools),
|
| 993 |
+
)
|
| 994 |
+
if not isinstance(err, _Transient):
|
| 995 |
+
raise err
|
| 996 |
+
if isinstance(err, _RateLimited):
|
| 997 |
+
_PACER.penalize(base_url, err.retry_after)
|
| 998 |
+
await asyncio.sleep(_next_retry(err, attempt, started, base_url, model))
|
| 999 |
+
attempt += 1
|
| 1000 |
+
data = r.json()
|
| 1001 |
+
try:
|
| 1002 |
+
raw_calls = data["choices"][0]["message"].get("tool_calls")
|
| 1003 |
+
except (KeyError, IndexError, AttributeError):
|
| 1004 |
+
raw_calls = None
|
| 1005 |
+
return _openai_wrap(
|
| 1006 |
+
_extract_content(data), str(data.get("model") or model), provider_label,
|
| 1007 |
+
tool_calls=_normalize_tool_calls(raw_calls),
|
| 1008 |
+
)
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
def _error_detail(r: httpx.Response) -> str:
|
| 1012 |
+
try:
|
| 1013 |
+
return _message_in(r.json()) or (r.text or "").strip()
|
| 1014 |
+
except Exception: # noqa: BLE001
|
| 1015 |
+
return (r.text or "").strip()
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
async def stream_openai_compat_events(
|
| 1019 |
+
messages: List[Dict[str, Any]],
|
| 1020 |
+
*,
|
| 1021 |
+
base_url: str,
|
| 1022 |
+
model: str = "",
|
| 1023 |
+
api_key: str = "",
|
| 1024 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 1025 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 1026 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 1027 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 1028 |
+
"""Stream ``{base_url}/chat/completions`` (:func:`_stream_openai_compat_once`) through the
|
| 1029 |
+
endpoint's queue, retried with backoff while the provider rate-limits or sheds load β only
|
| 1030 |
+
before the first token, so nothing is ever repeated to the reader. While it waits it says
|
| 1031 |
+
so: ``{"wait": {"text", "retry_in", "attempt"}}`` events, which the chat shows in place of
|
| 1032 |
+
a silent pause."""
|
| 1033 |
+
attempt = 0
|
| 1034 |
+
started = time.monotonic()
|
| 1035 |
+
ahead = _PACER.expected_wait(base_url)
|
| 1036 |
+
if ahead >= 2.0:
|
| 1037 |
+
yield _wait_event("Waiting in line for the model providerβ¦", ahead)
|
| 1038 |
+
while True:
|
| 1039 |
+
try:
|
| 1040 |
+
async for event in _stream_openai_compat_once(
|
| 1041 |
+
messages, base_url=base_url, model=model, api_key=api_key,
|
| 1042 |
+
temperature=temperature, max_tokens=max_tokens, tools=tools,
|
| 1043 |
+
):
|
| 1044 |
+
yield event
|
| 1045 |
+
_PACER.reward(base_url)
|
| 1046 |
+
return
|
| 1047 |
+
except _Transient as exc:
|
| 1048 |
+
if isinstance(exc, _RateLimited):
|
| 1049 |
+
_PACER.penalize(base_url, exc.retry_after)
|
| 1050 |
+
delay = _next_retry(exc, attempt, started, base_url, model)
|
| 1051 |
+
busy = "busy" if isinstance(exc, _RateLimited) else "overloaded"
|
| 1052 |
+
yield _wait_event(f"The model provider is {busy}; retrying in {delay:.0f} sβ¦", delay, attempt + 1)
|
| 1053 |
+
await asyncio.sleep(delay)
|
| 1054 |
+
attempt += 1
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
async def _stream_openai_compat_once(
|
| 1058 |
+
messages: List[Dict[str, Any]],
|
| 1059 |
+
*,
|
| 1060 |
+
base_url: str,
|
| 1061 |
+
model: str = "",
|
| 1062 |
+
api_key: str = "",
|
| 1063 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 1064 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 1065 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 1066 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 1067 |
+
"""Stream ``{base_url}/chat/completions``: ``{"delta": text}`` as tokens arrive and, once
|
| 1068 |
+
the stream ends, ``{"tool_calls": [...]}`` when the model called tools (their argument
|
| 1069 |
+
fragments are assembled here, by index, the way OpenAI streams them)."""
|
| 1070 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 1071 |
+
payload: Dict[str, Any] = {
|
| 1072 |
+
"messages": _openai_messages(messages),
|
| 1073 |
+
"temperature": float(temperature),
|
| 1074 |
+
"max_tokens": int(max_tokens),
|
| 1075 |
+
"stream": True,
|
| 1076 |
+
}
|
| 1077 |
+
if model:
|
| 1078 |
+
payload["model"] = model
|
| 1079 |
+
if tools:
|
| 1080 |
+
payload["tools"] = tools
|
| 1081 |
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
| 1082 |
+
pending: Dict[int, Dict[str, str]] = {}
|
| 1083 |
+
try:
|
| 1084 |
+
async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client, _PACER.slot(base_url):
|
| 1085 |
+
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
| 1086 |
+
if resp.status_code >= 400:
|
| 1087 |
+
body = (await resp.aread()).decode("utf-8", "replace")
|
| 1088 |
+
raise _openai_compat_error(
|
| 1089 |
+
resp.status_code, _detail_text(body), resp.headers,
|
| 1090 |
+
base_url=base_url, model=model, tools=bool(tools),
|
| 1091 |
+
)
|
| 1092 |
+
if "text/event-stream" not in (resp.headers.get("content-type") or "").lower():
|
| 1093 |
+
# A gateway that ignores stream=true answers with one JSON completion (OllaBridge
|
| 1094 |
+
# does): hand it over as a single delta rather than reading it as empty SSE.
|
| 1095 |
+
raw_body = (await resp.aread()).decode("utf-8", "replace")
|
| 1096 |
+
try:
|
| 1097 |
+
data = json.loads(raw_body or "{}")
|
| 1098 |
+
except json.JSONDecodeError:
|
| 1099 |
+
data = {}
|
| 1100 |
+
content = _extract_content(data) if isinstance(data, dict) else ""
|
| 1101 |
+
if content:
|
| 1102 |
+
yield {"delta": content}
|
| 1103 |
+
try:
|
| 1104 |
+
raw_calls = data["choices"][0]["message"].get("tool_calls")
|
| 1105 |
+
except (KeyError, IndexError, AttributeError, TypeError):
|
| 1106 |
+
raw_calls = None
|
| 1107 |
+
calls = _normalize_tool_calls(raw_calls)
|
| 1108 |
+
if calls:
|
| 1109 |
+
yield {"tool_calls": calls}
|
| 1110 |
+
return
|
| 1111 |
+
async for line in resp.aiter_lines():
|
| 1112 |
+
if not line.startswith("data:"):
|
| 1113 |
+
continue
|
| 1114 |
+
chunk = line[5:].strip()
|
| 1115 |
+
if chunk == "[DONE]":
|
| 1116 |
+
break
|
| 1117 |
+
try:
|
| 1118 |
+
obj = json.loads(chunk)
|
| 1119 |
+
except json.JSONDecodeError:
|
| 1120 |
+
continue
|
| 1121 |
+
choices = obj.get("choices") or []
|
| 1122 |
+
if not choices:
|
| 1123 |
+
continue
|
| 1124 |
+
delta = choices[0].get("delta") or {}
|
| 1125 |
+
if delta.get("content"):
|
| 1126 |
+
yield {"delta": delta["content"]}
|
| 1127 |
+
for tc in delta.get("tool_calls") or []:
|
| 1128 |
+
idx = int(tc.get("index", len(pending)) or 0)
|
| 1129 |
+
slot = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
|
| 1130 |
+
if tc.get("id"):
|
| 1131 |
+
slot["id"] = str(tc["id"])
|
| 1132 |
+
fn = tc.get("function") or {}
|
| 1133 |
+
if fn.get("name") and not slot["name"]:
|
| 1134 |
+
slot["name"] = str(fn["name"])
|
| 1135 |
+
if fn.get("arguments"):
|
| 1136 |
+
slot["arguments"] += str(fn["arguments"])
|
| 1137 |
+
except httpx.HTTPError as exc:
|
| 1138 |
+
raise RuntimeError(
|
| 1139 |
+
f"Could not reach {base_url} for {model or 'the selected model'} ({type(exc).__name__}). Is it running?"
|
| 1140 |
+
) from exc
|
| 1141 |
+
if pending:
|
| 1142 |
+
yield {"tool_calls": _normalize_tool_calls([
|
| 1143 |
+
{"id": s["id"], "function": {"name": s["name"], "arguments": s["arguments"] or "{}"}}
|
| 1144 |
+
for _, s in sorted(pending.items())
|
| 1145 |
+
])}
|
| 1146 |
+
|
| 1147 |
+
|
| 1148 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1149 |
+
# Local Ollama (free, on-premises, no API key needed)
|
| 1150 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1151 |
+
|
| 1152 |
+
def _ollama_failure(r: httpx.Response, mdl: str) -> RuntimeError:
|
| 1153 |
+
"""Ollama's own reason for a 5xx, which is usually the useful part ("model requires more
|
| 1154 |
+
system memory", "llama runner process has terminated") and was hidden behind a bare
|
| 1155 |
+
``500 Internal Server Error``."""
|
| 1156 |
+
reason = ""
|
| 1157 |
+
try:
|
| 1158 |
+
reason = str((r.json() or {}).get("error") or "")
|
| 1159 |
+
except Exception: # noqa: BLE001
|
| 1160 |
+
reason = (r.text or "").strip()
|
| 1161 |
+
reason = reason[:240] or r.reason_phrase or "server error"
|
| 1162 |
+
return RuntimeError(f"Ollama could not run '{mdl}' (HTTP {r.status_code}): {reason}")
|
| 1163 |
+
|
| 1164 |
+
|
| 1165 |
+
async def chat_local(
|
| 1166 |
+
messages: List[Dict[str, Any]],
|
| 1167 |
+
*,
|
| 1168 |
+
model: Optional[str] = None,
|
| 1169 |
+
fast: bool = False,
|
| 1170 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 1171 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 1172 |
+
base_url: Optional[str] = None,
|
| 1173 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 1174 |
+
) -> Dict[str, Any]:
|
| 1175 |
+
"""Ollama local inference β zero cost, fully sovereign.
|
| 1176 |
+
|
| 1177 |
+
``base_url`` is an Ollama the user chose for chat; its model is used as given, since the
|
| 1178 |
+
installed-model check below is about the default Ollama, not that one. ``tools`` (OpenAI
|
| 1179 |
+
shape) are offered to the model; a model without tool support raises ToolsUnsupported.
|
| 1180 |
+
"""
|
| 1181 |
+
requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL)
|
| 1182 |
+
mdl = requested if (base_url and model) else await _resolve_local_model(requested)
|
| 1183 |
+
url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat"
|
| 1184 |
+
payload: Dict[str, Any] = {
|
| 1185 |
+
"model": mdl,
|
| 1186 |
+
"messages": _ollama_messages(messages),
|
| 1187 |
+
"stream": False,
|
| 1188 |
+
"options": {
|
| 1189 |
+
"temperature": float(temperature),
|
| 1190 |
+
"num_predict": int(max_tokens),
|
| 1191 |
+
},
|
| 1192 |
+
}
|
| 1193 |
+
if tools:
|
| 1194 |
+
payload["tools"] = tools
|
| 1195 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 1196 |
+
r = await client.post(url, json=payload)
|
| 1197 |
+
if r.status_code == 404:
|
| 1198 |
+
raise RuntimeError(
|
| 1199 |
+
f"Ollama rejected model '{mdl}' with 404. Run "
|
| 1200 |
+
f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)."
|
| 1201 |
+
)
|
| 1202 |
+
if r.status_code >= 500:
|
| 1203 |
+
raise _ollama_failure(r, mdl)
|
| 1204 |
+
if tools and _tools_rejected(r.status_code, r.text):
|
| 1205 |
+
raise ToolsUnsupported(f"{mdl} does not support tool calling.")
|
| 1206 |
+
r.raise_for_status()
|
| 1207 |
+
data = r.json()
|
| 1208 |
+
|
| 1209 |
+
message = data.get("message", {}) or {}
|
| 1210 |
+
return _openai_wrap(message.get("content", ""), mdl, "local", tool_calls=_normalize_tool_calls(message.get("tool_calls")))
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
+
async def stream_local_events(
|
| 1214 |
+
messages: List[Dict[str, Any]],
|
| 1215 |
+
*,
|
| 1216 |
+
model: Optional[str] = None,
|
| 1217 |
+
fast: bool = False,
|
| 1218 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 1219 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 1220 |
+
base_url: Optional[str] = None,
|
| 1221 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 1222 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 1223 |
+
"""Stream Ollama: ``{"delta": text}`` per token and ``{"tool_calls": [...]}`` when the model
|
| 1224 |
+
calls tools. ``base_url`` and ``tools``: as in :func:`chat_local`."""
|
| 1225 |
+
requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL)
|
| 1226 |
+
mdl = requested if (base_url and model) else await _resolve_local_model(requested)
|
| 1227 |
+
url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat"
|
| 1228 |
+
payload: Dict[str, Any] = {
|
| 1229 |
+
"model": mdl,
|
| 1230 |
+
"messages": _ollama_messages(messages),
|
| 1231 |
+
"stream": True,
|
| 1232 |
+
"options": {"temperature": float(temperature), "num_predict": int(max_tokens)},
|
| 1233 |
+
}
|
| 1234 |
+
if tools:
|
| 1235 |
+
payload["tools"] = tools
|
| 1236 |
+
calls: List[Dict[str, Any]] = []
|
| 1237 |
+
async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
|
| 1238 |
+
async with client.stream("POST", url, json=payload) as resp:
|
| 1239 |
+
if resp.status_code == 404:
|
| 1240 |
+
raise RuntimeError(
|
| 1241 |
+
f"Ollama rejected model '{mdl}' with 404. Run "
|
| 1242 |
+
f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)."
|
| 1243 |
+
)
|
| 1244 |
+
if resp.status_code == 400 and tools:
|
| 1245 |
+
body = (await resp.aread()).decode("utf-8", "replace")
|
| 1246 |
+
if _tools_rejected(400, body):
|
| 1247 |
+
raise ToolsUnsupported(f"{mdl} does not support tool calling.")
|
| 1248 |
+
resp.raise_for_status()
|
| 1249 |
+
async for line in resp.aiter_lines():
|
| 1250 |
+
if not line.strip():
|
| 1251 |
+
continue
|
| 1252 |
+
try:
|
| 1253 |
+
chunk = json.loads(line)
|
| 1254 |
+
except json.JSONDecodeError:
|
| 1255 |
+
continue
|
| 1256 |
+
message = chunk.get("message", {}) or {}
|
| 1257 |
+
delta = message.get("content", "")
|
| 1258 |
+
if delta:
|
| 1259 |
+
yield {"delta": delta}
|
| 1260 |
+
if message.get("tool_calls"):
|
| 1261 |
+
calls.extend(_normalize_tool_calls(message["tool_calls"]))
|
| 1262 |
+
if chunk.get("done"):
|
| 1263 |
+
break
|
| 1264 |
+
if calls:
|
| 1265 |
+
yield {"tool_calls": calls}
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
+
async def stream_local(
|
| 1269 |
+
messages: List[Dict[str, Any]],
|
| 1270 |
+
*,
|
| 1271 |
+
model: Optional[str] = None,
|
| 1272 |
+
fast: bool = False,
|
| 1273 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 1274 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 1275 |
+
base_url: Optional[str] = None,
|
| 1276 |
+
) -> AsyncIterator[str]:
|
| 1277 |
+
"""Stream tokens from Ollama. ``base_url``: as in :func:`chat_local`."""
|
| 1278 |
+
async for event in stream_local_events(
|
| 1279 |
+
messages, model=model, fast=fast, temperature=temperature, max_tokens=max_tokens, base_url=base_url,
|
| 1280 |
+
):
|
| 1281 |
+
if "delta" in event:
|
| 1282 |
+
yield event["delta"]
|
expert/backend/app/expert/readiness.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Dict, List
|
| 6 |
+
|
| 7 |
+
from .mcp_catalog import mcp_key_to_env, p0_required_server_keys
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ReadinessCheck:
|
| 12 |
+
name: str
|
| 13 |
+
passed: bool
|
| 14 |
+
detail: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ReadinessReport:
|
| 19 |
+
ready: bool
|
| 20 |
+
stage: str
|
| 21 |
+
checks: List[ReadinessCheck]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_readiness_report(env: Dict[str, str] | None = None) -> ReadinessReport:
|
| 25 |
+
source = env or dict(os.environ)
|
| 26 |
+
preprod_mode = _truthy(source.get("EXPERT_PREPROD_MODE"))
|
| 27 |
+
|
| 28 |
+
checks = [
|
| 29 |
+
_check("tools_live", _truthy(source.get("EXPERT_TOOLS_ENABLED")), "Tool layer must be enabled."),
|
| 30 |
+
_check("memory_enabled", _truthy(source.get("EXPERT_MEMORY_ENABLED")), "Memory layer must be enabled."),
|
| 31 |
+
_check("evals_enabled", _truthy(source.get("EXPERT_EVALS_ENABLED")), "Eval recording must be enabled."),
|
| 32 |
+
_check("router_v2", _truthy(source.get("EXPERT_V2_ROUTER_ENABLED")), "V2 router must be enabled."),
|
| 33 |
+
_check(
|
| 34 |
+
"persistent_memory",
|
| 35 |
+
bool(source.get("EXPERT_MEMORY_BACKEND", "").strip()),
|
| 36 |
+
"Persistent memory backend must be configured.",
|
| 37 |
+
),
|
| 38 |
+
_check(
|
| 39 |
+
"observability",
|
| 40 |
+
bool(source.get("EXPERT_TELEMETRY_ENDPOINT", "").strip()),
|
| 41 |
+
"Telemetry endpoint must be configured.",
|
| 42 |
+
),
|
| 43 |
+
_check(
|
| 44 |
+
"safety_policy",
|
| 45 |
+
bool(source.get("EXPERT_SAFETY_POLICY", "").strip()),
|
| 46 |
+
"Safety policy profile must be configured.",
|
| 47 |
+
),
|
| 48 |
+
]
|
| 49 |
+
checks.extend(_mcp_preprod_checks(source, enabled=preprod_mode))
|
| 50 |
+
|
| 51 |
+
passed = sum(1 for c in checks if c.passed)
|
| 52 |
+
ready = passed == len(checks)
|
| 53 |
+
if ready:
|
| 54 |
+
stage = "production"
|
| 55 |
+
elif passed >= 4:
|
| 56 |
+
stage = "preprod"
|
| 57 |
+
else:
|
| 58 |
+
stage = "dev"
|
| 59 |
+
|
| 60 |
+
return ReadinessReport(ready=ready, stage=stage, checks=checks)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _check(name: str, passed: bool, detail: str) -> ReadinessCheck:
|
| 64 |
+
return ReadinessCheck(name=name, passed=passed, detail=detail)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _truthy(v: str | None) -> bool:
|
| 68 |
+
return (v or "").strip().lower() in {"1", "true", "yes", "on"}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _mcp_preprod_checks(source: Dict[str, str], enabled: bool) -> List[ReadinessCheck]:
|
| 72 |
+
checks: List[ReadinessCheck] = []
|
| 73 |
+
if not enabled:
|
| 74 |
+
return checks
|
| 75 |
+
orchestrator = (source.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower()
|
| 76 |
+
checks.append(
|
| 77 |
+
_check(
|
| 78 |
+
"mcp_orchestrator",
|
| 79 |
+
orchestrator in {"context_forge", "direct"},
|
| 80 |
+
"EXPERT_MCP_ORCHESTRATOR must be 'context_forge' or 'direct'.",
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if orchestrator == "context_forge":
|
| 85 |
+
checks.append(
|
| 86 |
+
_check(
|
| 87 |
+
"context_forge_url",
|
| 88 |
+
bool(source.get("CONTEXT_FORGE_URL", "").strip()),
|
| 89 |
+
"Set CONTEXT_FORGE_URL for MCP Context Forge orchestration.",
|
| 90 |
+
)
|
| 91 |
+
)
|
| 92 |
+
return checks
|
| 93 |
+
|
| 94 |
+
for key in p0_required_server_keys():
|
| 95 |
+
env_key = mcp_key_to_env(key)
|
| 96 |
+
checks.append(
|
| 97 |
+
_check(
|
| 98 |
+
f"mcp:{key}",
|
| 99 |
+
bool(source.get(env_key, "").strip()),
|
| 100 |
+
f"Set {env_key} for preprod MCP P0 integration.",
|
| 101 |
+
)
|
| 102 |
+
)
|
| 103 |
+
return checks
|
expert/backend/app/expert/router.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/router.py
|
| 2 |
+
# Smart multi-provider routing: score query complexity β pick optimal provider.
|
| 3 |
+
# Fallback chain ensures resilience: if preferred provider fails, try next tier.
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Any, AsyncIterator, Dict, List, Literal, Optional
|
| 9 |
+
|
| 10 |
+
from .config import (
|
| 11 |
+
GROK_API_KEY, GROQ_API_KEY, GEMINI_API_KEY,
|
| 12 |
+
EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD,
|
| 13 |
+
EXPERT_SYSTEM_PROMPT, EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_PROVIDER_TIMEOUT_S,
|
| 14 |
+
EXPERT_HISTORY_MAX_TURNS, EXPERT_HISTORY_MAX_CHARS,
|
| 15 |
+
available_expert_providers,
|
| 16 |
+
)
|
| 17 |
+
from .providers import (
|
| 18 |
+
chat_grok, stream_grok,
|
| 19 |
+
chat_groq, stream_groq,
|
| 20 |
+
chat_gemini, stream_gemini,
|
| 21 |
+
chat_local, stream_local, stream_local_events,
|
| 22 |
+
chat_openai_compat, stream_openai_compat_events,
|
| 23 |
+
ToolsUnsupported,
|
| 24 |
+
)
|
| 25 |
+
from . import compaction
|
| 26 |
+
from . import grounding
|
| 27 |
+
from . import target as target_mod
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger("expert.router")
|
| 30 |
+
|
| 31 |
+
ProviderName = Literal["local", "groq", "grok", "gemini", "claude", "openai", "auto"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _attach_dispatch_meta(
|
| 35 |
+
raw: Dict[str, Any],
|
| 36 |
+
*,
|
| 37 |
+
requested_provider: ProviderName,
|
| 38 |
+
fallback_applied: bool,
|
| 39 |
+
notice: Optional[str] = None,
|
| 40 |
+
) -> Dict[str, Any]:
|
| 41 |
+
provider_used = raw.get("provider", requested_provider)
|
| 42 |
+
raw["__expert_meta"] = {
|
| 43 |
+
"requested_provider": requested_provider,
|
| 44 |
+
"provider_used": provider_used,
|
| 45 |
+
"fallback_applied": fallback_applied,
|
| 46 |
+
"notice": notice,
|
| 47 |
+
}
|
| 48 |
+
return raw
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def extract_dispatch_meta(raw: Dict[str, Any]) -> Dict[str, Any]:
|
| 52 |
+
meta = raw.get("__expert_meta")
|
| 53 |
+
if isinstance(meta, dict):
|
| 54 |
+
return meta
|
| 55 |
+
provider = raw.get("provider", "local")
|
| 56 |
+
return {
|
| 57 |
+
"requested_provider": provider,
|
| 58 |
+
"provider_used": provider,
|
| 59 |
+
"fallback_applied": False,
|
| 60 |
+
"notice": None,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
+
# Complexity scorer
|
| 66 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
_COMPLEX_KEYWORDS = {
|
| 69 |
+
"analyze", "analyse", "compare", "reason", "explain", "prove",
|
| 70 |
+
"research", "summarize", "summarise", "evaluate", "critique",
|
| 71 |
+
"code", "debug", "implement", "design", "architecture",
|
| 72 |
+
"math", "calculate", "derive", "equation",
|
| 73 |
+
"write", "draft", "essay", "report",
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
_SIMPLE_KEYWORDS = {
|
| 77 |
+
"what is", "who is", "when", "where", "define", "list",
|
| 78 |
+
"how many", "translate", "hi", "hello", "thanks",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def score_complexity(query: str) -> int:
|
| 83 |
+
"""
|
| 84 |
+
Score query complexity 0β10.
|
| 85 |
+
0β3 β local fast model (simple factual, greetings)
|
| 86 |
+
4β6 β Groq 70B (moderate reasoning, explanations)
|
| 87 |
+
7β10 β Grok / Gemini (deep reasoning, long-form, code)
|
| 88 |
+
"""
|
| 89 |
+
score = 0
|
| 90 |
+
q = query.lower().strip()
|
| 91 |
+
|
| 92 |
+
# Length signals
|
| 93 |
+
words = len(q.split())
|
| 94 |
+
if words > 100:
|
| 95 |
+
score += 3
|
| 96 |
+
elif words > 40:
|
| 97 |
+
score += 2
|
| 98 |
+
elif words > 15:
|
| 99 |
+
score += 1
|
| 100 |
+
|
| 101 |
+
# Complexity keywords
|
| 102 |
+
if any(kw in q for kw in _COMPLEX_KEYWORDS):
|
| 103 |
+
score += 3
|
| 104 |
+
|
| 105 |
+
# Simple keywords (penalise complexity)
|
| 106 |
+
if any(kw in q for kw in _SIMPLE_KEYWORDS):
|
| 107 |
+
score = max(0, score - 2)
|
| 108 |
+
|
| 109 |
+
# Multi-sentence questions suggest depth
|
| 110 |
+
sentences = [s.strip() for s in q.split(".") if s.strip()]
|
| 111 |
+
if len(sentences) > 3:
|
| 112 |
+
score += 1
|
| 113 |
+
|
| 114 |
+
# Code blocks or backticks
|
| 115 |
+
if "```" in query or "`" in query:
|
| 116 |
+
score += 2
|
| 117 |
+
|
| 118 |
+
# Multiple questions
|
| 119 |
+
if query.count("?") > 1:
|
| 120 |
+
score += 1
|
| 121 |
+
|
| 122 |
+
return min(score, 10)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
# Provider selection
|
| 127 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
|
| 129 |
+
def select_provider(query: str, preferred: ProviderName = "auto") -> ProviderName:
|
| 130 |
+
"""
|
| 131 |
+
Select best available provider for this query.
|
| 132 |
+
|
| 133 |
+
If preferred == 'auto', use complexity-based routing.
|
| 134 |
+
Otherwise, use the requested provider if available, else fallback to auto.
|
| 135 |
+
"""
|
| 136 |
+
available = available_expert_providers()
|
| 137 |
+
|
| 138 |
+
if preferred != "auto":
|
| 139 |
+
if preferred in available:
|
| 140 |
+
return preferred
|
| 141 |
+
logger.warning("Provider '%s' not available, falling back to auto routing.", preferred)
|
| 142 |
+
|
| 143 |
+
complexity = score_complexity(query)
|
| 144 |
+
logger.debug("Query complexity score: %d", complexity)
|
| 145 |
+
|
| 146 |
+
if complexity <= EXPERT_LOCAL_THRESHOLD:
|
| 147 |
+
return "local"
|
| 148 |
+
|
| 149 |
+
if complexity <= EXPERT_GROQ_THRESHOLD:
|
| 150 |
+
if "groq" in available:
|
| 151 |
+
return "groq"
|
| 152 |
+
if "local" in available:
|
| 153 |
+
return "local"
|
| 154 |
+
|
| 155 |
+
# High complexity β best available cloud
|
| 156 |
+
for p in ("grok", "gemini", "claude", "openai", "groq", "local"):
|
| 157 |
+
if p in available:
|
| 158 |
+
return p
|
| 159 |
+
|
| 160 |
+
return "local" # ultimate fallback
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
# Conversation history
|
| 165 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
|
| 167 |
+
# Prefixed to a pipeline step's instruction when earlier turns precede it, so the step reads
|
| 168 |
+
# the user's latest question against them rather than as a question from nowhere.
|
| 169 |
+
CONTINUATION_NOTE = (
|
| 170 |
+
"The task below continues the conversation above. Resolve references in it (\"it\", "
|
| 171 |
+
"\"the second option\", \"as before\"), keep the constraints already agreed, and answer "
|
| 172 |
+
"in the conversation's language.\n\n"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def clean_turns(history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, str]]:
|
| 177 |
+
"""User and assistant turns with text, in order; everything else dropped."""
|
| 178 |
+
return [
|
| 179 |
+
{"role": str(m.get("role")), "content": str(m.get("content"))}
|
| 180 |
+
for m in (history or [])
|
| 181 |
+
if isinstance(m, dict)
|
| 182 |
+
and m.get("role") in ("user", "assistant")
|
| 183 |
+
and str(m.get("content") or "").strip()
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def history_window(
|
| 188 |
+
history: Optional[List[Dict[str, Any]]],
|
| 189 |
+
*,
|
| 190 |
+
max_turns: Optional[int] = None,
|
| 191 |
+
max_chars: Optional[int] = None,
|
| 192 |
+
) -> List[Dict[str, str]]:
|
| 193 |
+
"""The recent conversation as clean chat turns: user and assistant messages with text,
|
| 194 |
+
the newest that fit the turn and character budgets, opening on a user turn (providers
|
| 195 |
+
that enforce role alternation accept the window as it is). The turns before the window
|
| 196 |
+
are not lost: compaction.py carries them as a note."""
|
| 197 |
+
max_turns = EXPERT_HISTORY_MAX_TURNS if max_turns is None else max_turns
|
| 198 |
+
max_chars = EXPERT_HISTORY_MAX_CHARS if max_chars is None else max_chars
|
| 199 |
+
clean = clean_turns(history)
|
| 200 |
+
kept: List[Dict[str, str]] = []
|
| 201 |
+
used = 0
|
| 202 |
+
for m in reversed(clean):
|
| 203 |
+
if len(kept) >= max_turns or used + len(m["content"]) > max_chars:
|
| 204 |
+
break
|
| 205 |
+
kept.append(m)
|
| 206 |
+
used += len(m["content"])
|
| 207 |
+
kept.reverse()
|
| 208 |
+
while kept and kept[0]["role"] != "user":
|
| 209 |
+
kept.pop(0)
|
| 210 |
+
return kept
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def build_messages(
|
| 214 |
+
user_query: str,
|
| 215 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 216 |
+
system_prompt: Optional[str] = None,
|
| 217 |
+
) -> List[Dict[str, str]]:
|
| 218 |
+
"""Assemble the messages list: system prompt, the recent conversation as role-tagged
|
| 219 |
+
turns, then the new query as the newest user turn β the shape chat models are trained on."""
|
| 220 |
+
messages: List[Dict[str, str]] = [{
|
| 221 |
+
"role": "system",
|
| 222 |
+
"content": compaction.apply(grounding.apply(system_prompt or EXPERT_SYSTEM_PROMPT)),
|
| 223 |
+
}]
|
| 224 |
+
messages.extend(history_window(history))
|
| 225 |
+
messages.append({"role": "user", "content": user_query})
|
| 226 |
+
return messages
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def step_messages(
|
| 230 |
+
instruction: str,
|
| 231 |
+
*,
|
| 232 |
+
system_prompt: str,
|
| 233 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 234 |
+
) -> List[Dict[str, str]]:
|
| 235 |
+
"""Messages for one call of a multi-step pipeline (think, heavy). Every step sees the
|
| 236 |
+
conversation the question came from β so a follow-up ("and for the second one?") is
|
| 237 |
+
understood in the analysis and the final answer matches the thread β while the step's
|
| 238 |
+
own instruction stays the newest user turn, told that it continues that conversation."""
|
| 239 |
+
turns = history_window(history)
|
| 240 |
+
if turns:
|
| 241 |
+
instruction = CONTINUATION_NOTE + instruction
|
| 242 |
+
return build_messages(instruction, history=turns, system_prompt=system_prompt)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 246 |
+
# Dispatch β sync
|
| 247 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 248 |
+
|
| 249 |
+
async def dispatch(
|
| 250 |
+
messages: List[Dict[str, str]],
|
| 251 |
+
provider: ProviderName,
|
| 252 |
+
*,
|
| 253 |
+
model: Optional[str] = None,
|
| 254 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 255 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 256 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 257 |
+
) -> Dict[str, Any]:
|
| 258 |
+
"""
|
| 259 |
+
Call the selected provider and return an OpenAI-compatible response dict.
|
| 260 |
+
Includes one fallback level: if provider fails, try local Ollama.
|
| 261 |
+
``tools`` (OpenAI shape) reach the local/target path β Ollama and OpenAI-compatible
|
| 262 |
+
endpoints; the response message then carries ``tool_calls`` when the model used them.
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
raw = await asyncio.wait_for(
|
| 266 |
+
_call_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools),
|
| 267 |
+
timeout=EXPERT_PROVIDER_TIMEOUT_S,
|
| 268 |
+
)
|
| 269 |
+
return _attach_dispatch_meta(raw, requested_provider=provider, fallback_applied=False)
|
| 270 |
+
except ToolsUnsupported:
|
| 271 |
+
raise
|
| 272 |
+
except Exception as e:
|
| 273 |
+
logger.warning("Provider '%s' failed (%s), falling back to local.", provider, e)
|
| 274 |
+
if provider != "local":
|
| 275 |
+
# "local" is the user's chosen chat model when there is one (see target.py) β
|
| 276 |
+
# called directly, so the fallback never re-enters the path that just failed.
|
| 277 |
+
raw = await asyncio.wait_for(
|
| 278 |
+
_call_local(messages, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools),
|
| 279 |
+
timeout=EXPERT_PROVIDER_TIMEOUT_S,
|
| 280 |
+
)
|
| 281 |
+
return _attach_dispatch_meta(
|
| 282 |
+
raw,
|
| 283 |
+
requested_provider=provider,
|
| 284 |
+
fallback_applied=True,
|
| 285 |
+
notice="Requested provider failed; used local fallback.",
|
| 286 |
+
)
|
| 287 |
+
raise
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
async def _call_provider(
|
| 291 |
+
messages: List[Dict[str, str]],
|
| 292 |
+
provider: ProviderName,
|
| 293 |
+
*,
|
| 294 |
+
model: Optional[str],
|
| 295 |
+
temperature: float,
|
| 296 |
+
max_tokens: int,
|
| 297 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 298 |
+
) -> Dict[str, Any]:
|
| 299 |
+
kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 300 |
+
if provider == "grok":
|
| 301 |
+
return await chat_grok(messages, **kwargs)
|
| 302 |
+
if provider == "groq":
|
| 303 |
+
return await chat_groq(messages, **kwargs)
|
| 304 |
+
if provider == "gemini":
|
| 305 |
+
return await chat_gemini(messages, **kwargs)
|
| 306 |
+
if provider == "claude":
|
| 307 |
+
# Delegate to existing HomePilot llm.py to reuse ANTHROPIC_API_KEY
|
| 308 |
+
from ..llm import chat_claude
|
| 309 |
+
return await chat_claude(messages, **{k: v for k, v in kwargs.items() if v is not None})
|
| 310 |
+
if provider == "openai":
|
| 311 |
+
from ..llm import chat_openai
|
| 312 |
+
return await chat_openai(messages, **{k: v for k, v in kwargs.items() if v is not None})
|
| 313 |
+
return await _call_local(messages, tools=tools, **kwargs)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
async def _call_local(
|
| 317 |
+
messages: List[Dict[str, str]],
|
| 318 |
+
*,
|
| 319 |
+
model: Optional[str],
|
| 320 |
+
temperature: float,
|
| 321 |
+
max_tokens: int,
|
| 322 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 323 |
+
) -> Dict[str, Any]:
|
| 324 |
+
"""The chat model the user picked (target.py), else local Ollama."""
|
| 325 |
+
kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens, tools=tools)
|
| 326 |
+
chosen = target_mod.current()
|
| 327 |
+
if chosen is not None:
|
| 328 |
+
return await _call_target(messages, chosen, **kwargs)
|
| 329 |
+
return await chat_local(messages, **kwargs)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
async def _call_target(
|
| 333 |
+
messages: List[Dict[str, str]],
|
| 334 |
+
chosen: "target_mod.ChatTarget",
|
| 335 |
+
*,
|
| 336 |
+
model: Optional[str],
|
| 337 |
+
temperature: float,
|
| 338 |
+
max_tokens: int,
|
| 339 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 340 |
+
) -> Dict[str, Any]:
|
| 341 |
+
"""Answer from the user's chosen chat model: an Ollama model, or an OpenAI-compatible
|
| 342 |
+
endpoint such as an OllaBridge gateway (``http://localhost:11435/v1``) or the Cloud relay.
|
| 343 |
+
"""
|
| 344 |
+
if chosen.is_ollama:
|
| 345 |
+
return await chat_local(
|
| 346 |
+
messages,
|
| 347 |
+
model=chosen.model or model,
|
| 348 |
+
base_url=chosen.base_url or None,
|
| 349 |
+
temperature=temperature,
|
| 350 |
+
max_tokens=max_tokens,
|
| 351 |
+
tools=tools,
|
| 352 |
+
)
|
| 353 |
+
return await chat_openai_compat(
|
| 354 |
+
messages,
|
| 355 |
+
base_url=chosen.base_url,
|
| 356 |
+
model=chosen.model or model or "",
|
| 357 |
+
api_key=chosen.api_key,
|
| 358 |
+
temperature=temperature,
|
| 359 |
+
max_tokens=max_tokens,
|
| 360 |
+
provider_label=chosen.label,
|
| 361 |
+
tools=tools,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 366 |
+
# Dispatch β streaming
|
| 367 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 368 |
+
|
| 369 |
+
async def dispatch_stream(
|
| 370 |
+
messages: List[Dict[str, str]],
|
| 371 |
+
provider: ProviderName,
|
| 372 |
+
*,
|
| 373 |
+
model: Optional[str] = None,
|
| 374 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 375 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 376 |
+
) -> AsyncIterator[str]:
|
| 377 |
+
"""
|
| 378 |
+
Stream tokens from selected provider.
|
| 379 |
+
Falls back to local if cloud provider fails on first chunk.
|
| 380 |
+
"""
|
| 381 |
+
try:
|
| 382 |
+
async for chunk in _stream_provider(messages, provider, model=model,
|
| 383 |
+
temperature=temperature, max_tokens=max_tokens):
|
| 384 |
+
yield chunk
|
| 385 |
+
except Exception as e:
|
| 386 |
+
logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e)
|
| 387 |
+
if provider != "local":
|
| 388 |
+
async for chunk in _stream_provider(messages, "local", model=model,
|
| 389 |
+
temperature=temperature, max_tokens=max_tokens):
|
| 390 |
+
yield chunk
|
| 391 |
+
else:
|
| 392 |
+
raise
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
async def _stream_provider(
|
| 396 |
+
messages: List[Dict[str, str]],
|
| 397 |
+
provider: ProviderName,
|
| 398 |
+
*,
|
| 399 |
+
model: Optional[str],
|
| 400 |
+
temperature: float,
|
| 401 |
+
max_tokens: int,
|
| 402 |
+
) -> AsyncIterator[str]:
|
| 403 |
+
kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 404 |
+
if provider == "grok":
|
| 405 |
+
async for c in stream_grok(messages, **kwargs): yield c
|
| 406 |
+
elif provider == "groq":
|
| 407 |
+
async for c in stream_groq(messages, **kwargs): yield c
|
| 408 |
+
elif provider == "gemini":
|
| 409 |
+
async for c in stream_gemini(messages, **kwargs): yield c
|
| 410 |
+
elif provider in ("claude", "openai"):
|
| 411 |
+
# Cloud providers without dedicated stream adapter: fall back to non-streaming
|
| 412 |
+
# and emit the full response as one chunk β transparent to the client
|
| 413 |
+
resp = await _call_provider(messages, provider, **kwargs)
|
| 414 |
+
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 415 |
+
yield content
|
| 416 |
+
else:
|
| 417 |
+
chosen = target_mod.current()
|
| 418 |
+
if chosen is not None and chosen.is_ollama:
|
| 419 |
+
async for c in stream_local(messages, model=chosen.model or model,
|
| 420 |
+
base_url=chosen.base_url or None,
|
| 421 |
+
temperature=temperature, max_tokens=max_tokens):
|
| 422 |
+
yield c
|
| 423 |
+
elif chosen is not None:
|
| 424 |
+
# Real streaming from the OpenAI-compatible endpoint; a gateway that cannot stream
|
| 425 |
+
# answers in one chunk, the way Claude and OpenAI are streamed above.
|
| 426 |
+
yielded = False
|
| 427 |
+
try:
|
| 428 |
+
async for ev in stream_openai_compat_events(
|
| 429 |
+
messages, base_url=chosen.base_url, model=chosen.model or model or "",
|
| 430 |
+
api_key=chosen.api_key, temperature=temperature, max_tokens=max_tokens,
|
| 431 |
+
):
|
| 432 |
+
if "delta" in ev:
|
| 433 |
+
yielded = True
|
| 434 |
+
yield ev["delta"]
|
| 435 |
+
except Exception:
|
| 436 |
+
if yielded:
|
| 437 |
+
raise
|
| 438 |
+
resp = await _call_target(messages, chosen, **kwargs)
|
| 439 |
+
yield resp.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 440 |
+
else:
|
| 441 |
+
async for c in stream_local(messages, **kwargs): yield c
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 445 |
+
# Dispatch β streaming with structure (tokens and tool calls)
|
| 446 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 447 |
+
|
| 448 |
+
async def stream_provider_events(
|
| 449 |
+
messages: List[Dict[str, Any]],
|
| 450 |
+
provider: ProviderName,
|
| 451 |
+
*,
|
| 452 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 453 |
+
model: Optional[str] = None,
|
| 454 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 455 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 456 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 457 |
+
"""Stream ``{"delta": text}`` / ``{"tool_calls": [...]}`` events, with the same local
|
| 458 |
+
fallback as :func:`dispatch_stream` (announced as a ``{"notice": ..., "fallback": True}``
|
| 459 |
+
event). ``tools`` reach the local/target path β Ollama and OpenAI-compatible endpoints;
|
| 460 |
+
the other providers stream text only. A ToolsUnsupported refusal is passed through so the
|
| 461 |
+
caller can retry without tools."""
|
| 462 |
+
try:
|
| 463 |
+
async for ev in _stream_provider_events(messages, provider, tools=tools, model=model,
|
| 464 |
+
temperature=temperature, max_tokens=max_tokens):
|
| 465 |
+
yield ev
|
| 466 |
+
except ToolsUnsupported:
|
| 467 |
+
raise
|
| 468 |
+
except Exception as e:
|
| 469 |
+
if provider == "local":
|
| 470 |
+
logger.warning("Streaming provider 'local' failed: %s", e)
|
| 471 |
+
raise
|
| 472 |
+
logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e)
|
| 473 |
+
yield {"notice": "Requested provider failed; used local fallback.", "fallback": True}
|
| 474 |
+
async for ev in _stream_provider_events(messages, "local", tools=tools, model=model,
|
| 475 |
+
temperature=temperature, max_tokens=max_tokens):
|
| 476 |
+
yield ev
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
async def _stream_provider_events(
|
| 480 |
+
messages: List[Dict[str, Any]],
|
| 481 |
+
provider: ProviderName,
|
| 482 |
+
*,
|
| 483 |
+
tools: Optional[List[Dict[str, Any]]],
|
| 484 |
+
model: Optional[str],
|
| 485 |
+
temperature: float,
|
| 486 |
+
max_tokens: int,
|
| 487 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 488 |
+
if provider in ("grok", "groq", "gemini", "claude", "openai"):
|
| 489 |
+
async for c in _stream_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens):
|
| 490 |
+
yield {"delta": c}
|
| 491 |
+
return
|
| 492 |
+
chosen = target_mod.current()
|
| 493 |
+
kwargs = dict(temperature=temperature, max_tokens=max_tokens, tools=tools)
|
| 494 |
+
if chosen is not None and not chosen.is_ollama:
|
| 495 |
+
# Real streaming from the OpenAI-compatible endpoint; when the stream fails before its
|
| 496 |
+
# first event, the plain request is tried once (a gateway that cannot stream still
|
| 497 |
+
# answers that). A refusal of the tools field passes through for the retry without.
|
| 498 |
+
yielded = False
|
| 499 |
+
try:
|
| 500 |
+
async for ev in stream_openai_compat_events(
|
| 501 |
+
messages, base_url=chosen.base_url, model=chosen.model or model or "", api_key=chosen.api_key, **kwargs,
|
| 502 |
+
):
|
| 503 |
+
yielded = True
|
| 504 |
+
yield ev
|
| 505 |
+
except ToolsUnsupported:
|
| 506 |
+
raise
|
| 507 |
+
except Exception:
|
| 508 |
+
if yielded:
|
| 509 |
+
raise
|
| 510 |
+
raw = await _call_target(messages, chosen, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools)
|
| 511 |
+
message = ((raw.get("choices") or [{}])[0] or {}).get("message") or {}
|
| 512 |
+
if message.get("content"):
|
| 513 |
+
yield {"delta": message["content"]}
|
| 514 |
+
if message.get("tool_calls"):
|
| 515 |
+
yield {"tool_calls": message["tool_calls"]}
|
| 516 |
+
elif chosen is not None:
|
| 517 |
+
async for ev in stream_local_events(messages, model=chosen.model or model, base_url=chosen.base_url or None, **kwargs):
|
| 518 |
+
yield ev
|
| 519 |
+
else:
|
| 520 |
+
async for ev in stream_local_events(messages, model=model, **kwargs):
|
| 521 |
+
yield ev
|
expert/backend/app/expert/routes.py
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/routes.py
|
| 2 |
+
# FastAPI router for the Expert module.
|
| 3 |
+
# Mount pattern identical to upscale.py / enhance.py β include in main.py with 2 lines.
|
| 4 |
+
#
|
| 5 |
+
# Endpoints:
|
| 6 |
+
# GET /v1/expert/info β provider status + routing config
|
| 7 |
+
# POST /v1/expert/chat β single-turn or multi-turn, returns full response
|
| 8 |
+
# POST /v1/expert/stream β streaming SSE response
|
| 9 |
+
# POST /v1/expert/route β debug: returns which provider would be selected
|
| 10 |
+
#
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
from typing import Any, AsyncIterator, Dict, Optional
|
| 19 |
+
|
| 20 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 21 |
+
from fastapi.responses import StreamingResponse
|
| 22 |
+
|
| 23 |
+
from .config import (
|
| 24 |
+
EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD,
|
| 25 |
+
EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL,
|
| 26 |
+
GROQ_MODEL, GROK_MODEL, GEMINI_MODEL,
|
| 27 |
+
EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE,
|
| 28 |
+
EXPERT_HEAVY_ESCALATE, EXPERT_DEEP_TIME_BUDGET_S,
|
| 29 |
+
available_expert_providers,
|
| 30 |
+
)
|
| 31 |
+
from .router import (
|
| 32 |
+
build_messages,
|
| 33 |
+
dispatch,
|
| 34 |
+
dispatch_stream,
|
| 35 |
+
extract_dispatch_meta,
|
| 36 |
+
score_complexity,
|
| 37 |
+
select_provider,
|
| 38 |
+
)
|
| 39 |
+
from .tool_loop import run_with_tools, stream_with_tools
|
| 40 |
+
from .policies import effort_for, is_trivial, resolve_thinking_mode
|
| 41 |
+
from .project_context import PROJECT_TOOL, project_system_prompt, project_tool
|
| 42 |
+
from . import tools as tools_mod
|
| 43 |
+
from .thinking import think, stream_think
|
| 44 |
+
from .heavy import heavy, stream_heavy
|
| 45 |
+
from .schemas import (
|
| 46 |
+
CapabilitiesRequest,
|
| 47 |
+
ExpertChatRequest,
|
| 48 |
+
ExpertChatResponse,
|
| 49 |
+
ExpertInfoResponse,
|
| 50 |
+
ExpertStreamRequest,
|
| 51 |
+
OllabridgeChatRequest,
|
| 52 |
+
PersonaDraftRequest,
|
| 53 |
+
PersonaDraftResponse,
|
| 54 |
+
ProductionReadinessResponse,
|
| 55 |
+
ReadinessCheckItem,
|
| 56 |
+
)
|
| 57 |
+
from . import capability
|
| 58 |
+
from . import compaction
|
| 59 |
+
from . import config as config_mod
|
| 60 |
+
from . import deep
|
| 61 |
+
from . import grounding
|
| 62 |
+
from . import target as target_mod
|
| 63 |
+
from .readiness import build_readiness_report
|
| 64 |
+
from .mcp_catalog import ESSENTIAL_MCP_SERVERS, TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env
|
| 65 |
+
from .mcp_gateway import MCPGateway
|
| 66 |
+
|
| 67 |
+
logger = logging.getLogger("expert.routes")
|
| 68 |
+
|
| 69 |
+
router = APIRouter(prefix="/v1/expert", tags=["expert"])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _compute_request_complexity(req: ExpertChatRequest) -> int:
|
| 73 |
+
score = score_complexity(req.query)
|
| 74 |
+
if req.has_attachments:
|
| 75 |
+
score += 2
|
| 76 |
+
if len(req.history) > 8:
|
| 77 |
+
score += 1
|
| 78 |
+
return min(score, 10)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
# GET /v1/expert/info
|
| 83 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
|
| 85 |
+
@router.get("/info", response_model=ExpertInfoResponse, summary="Expert module status")
|
| 86 |
+
async def expert_info() -> ExpertInfoResponse:
|
| 87 |
+
"""Return which providers are configured and the current routing thresholds."""
|
| 88 |
+
providers = available_expert_providers()
|
| 89 |
+
return ExpertInfoResponse(
|
| 90 |
+
available_providers=providers,
|
| 91 |
+
default_provider="auto",
|
| 92 |
+
local_threshold=EXPERT_LOCAL_THRESHOLD,
|
| 93 |
+
groq_threshold=EXPERT_GROQ_THRESHOLD,
|
| 94 |
+
local_model=EXPERT_LOCAL_MODEL,
|
| 95 |
+
local_fast_model=EXPERT_LOCAL_FAST_MODEL,
|
| 96 |
+
groq_model=GROQ_MODEL,
|
| 97 |
+
grok_model=GROK_MODEL,
|
| 98 |
+
gemini_model=GEMINI_MODEL,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
# POST /v1/expert/chat (non-streaming, full response)
|
| 104 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
|
| 106 |
+
def _provider_for(req: ExpertChatRequest, chosen) -> str:
|
| 107 |
+
"""The provider to start from. With the user's own chat model chosen (``target``), 'auto'
|
| 108 |
+
means that model β a complex question is not a reason to send it to a cloud provider the
|
| 109 |
+
user did not pick."""
|
| 110 |
+
if chosen is not None and req.provider == "auto":
|
| 111 |
+
return "local"
|
| 112 |
+
return select_provider(req.query, preferred=req.provider)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _model_label(chosen, requested: Optional[str]) -> Optional[str]:
|
| 116 |
+
"""The model a response reports: the chosen target's, else the one the request named."""
|
| 117 |
+
if chosen is not None and chosen.model:
|
| 118 |
+
return chosen.model
|
| 119 |
+
return requested or None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def _escalate_for_heavy(chosen, req: ExpertChatRequest, mode: str, provider: str):
|
| 123 |
+
"""Heavy runs on the strongest model of the user's own connection when that is a clear
|
| 124 |
+
step up (capability.py) β the same Ollama or gateway, never a provider they did not pick.
|
| 125 |
+
Returns ``(target to run with, escalated model name or None)``."""
|
| 126 |
+
if mode != "heavy" or not EXPERT_HEAVY_ESCALATE or provider != "local":
|
| 127 |
+
return chosen, None
|
| 128 |
+
if isinstance(req.feature_hints, dict) and req.feature_hints.get("escalate") is False:
|
| 129 |
+
return chosen, None
|
| 130 |
+
try:
|
| 131 |
+
caps = await capability.assess(chosen, req.model)
|
| 132 |
+
except Exception as exc: # noqa: BLE001
|
| 133 |
+
logger.debug("Heavy escalation skipped: %s", exc)
|
| 134 |
+
return chosen, None
|
| 135 |
+
best, selected = caps.get("best"), caps.get("selected") or {}
|
| 136 |
+
if not best or not capability.stronger(best, selected):
|
| 137 |
+
return chosen, None
|
| 138 |
+
if chosen is None:
|
| 139 |
+
return target_mod.ChatTarget(provider="ollama", base_url="", model=best["name"]), best["name"]
|
| 140 |
+
return (
|
| 141 |
+
target_mod.ChatTarget(provider=chosen.provider, base_url=chosen.base_url, model=best["name"], api_key=chosen.api_key),
|
| 142 |
+
best["name"],
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _project(req: ExpertChatRequest) -> Optional[Dict[str, Any]]:
|
| 147 |
+
return req.project.model_dump() if req.project is not None else None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _request_tools(req: ExpertChatRequest) -> tools_mod.RequestTools:
|
| 151 |
+
"""The shared tools plus this request's own: the project's documents, when there are any
|
| 152 |
+
and the backend's retrieval is reachable (project_context.py)."""
|
| 153 |
+
local = [t for t in (project_tool(_project(req)),) if t is not None]
|
| 154 |
+
return tools_mod.RequestTools(tools_mod.registry(), local)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _forced_tools(req: ExpertChatRequest, registry: Optional[tools_mod.RequestTools] = None):
|
| 158 |
+
"""What runs before the model is asked anything: the project's documents when the chat is
|
| 159 |
+
inside a project with documents, and a web search when the chat's "Search the web" switch
|
| 160 |
+
is on (feature_hints.webSearch = "force") β in every mode."""
|
| 161 |
+
forced: list = []
|
| 162 |
+
project = _project(req)
|
| 163 |
+
if project and int(project.get("document_count") or 0) > 0 and (registry is None or registry.spec(PROJECT_TOOL)):
|
| 164 |
+
forced.append((PROJECT_TOOL, {"query": req.query}))
|
| 165 |
+
hints = req.feature_hints if isinstance(req.feature_hints, dict) else {}
|
| 166 |
+
if str(hints.get("webSearch") or "").lower() in ("force", "on", "true", "1"):
|
| 167 |
+
forced.append(("hp_web_search", {"query": req.query}))
|
| 168 |
+
return forced or None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _effort_notice(effort) -> Optional[str]:
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _escalation_notice(escalated: str, chosen, req: ExpertChatRequest) -> str:
|
| 176 |
+
was = _model_label(chosen, req.model) or EXPERT_LOCAL_MODEL
|
| 177 |
+
return f"Heavy used {escalated}, the strongest model on this connection, instead of {was}."
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.post("/chat", response_model=ExpertChatResponse, summary="Expert chat (sync)")
|
| 181 |
+
async def expert_chat(req: ExpertChatRequest) -> ExpertChatResponse:
|
| 182 |
+
"""
|
| 183 |
+
Send a message to the Expert and get a complete response.
|
| 184 |
+
|
| 185 |
+
thinking_mode controls the pipeline:
|
| 186 |
+
'fast' β single LLM call (default for simple queries)
|
| 187 |
+
'think' β analyze β plan β solve (3 chained LLM calls)
|
| 188 |
+
'heavy' β research β reason β synthesize β validate (4-agent pipeline)
|
| 189 |
+
'auto' β complexity score decides which pipeline to use
|
| 190 |
+
|
| 191 |
+
``target`` (the user's chosen chat model) is in force for every step of the pipeline.
|
| 192 |
+
"""
|
| 193 |
+
chosen = target_mod.from_request(req.target)
|
| 194 |
+
# Today's date (and any web sources HomePilot found) reach every step's system prompt.
|
| 195 |
+
with target_mod.using(chosen), grounding.using(req.client_timezone, req.context):
|
| 196 |
+
return await _expert_chat(req, chosen)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def _expert_chat(req: ExpertChatRequest, chosen) -> ExpertChatResponse:
|
| 200 |
+
complexity = _compute_request_complexity(req)
|
| 201 |
+
provider = _provider_for(req, chosen)
|
| 202 |
+
started = time.perf_counter()
|
| 203 |
+
include_raw = os.getenv("EXPERT_DEBUG", "").lower() in ("1", "true", "yes")
|
| 204 |
+
|
| 205 |
+
resolution = resolve_thinking_mode(req.thinking_mode, complexity)
|
| 206 |
+
mode = resolution.mode_used
|
| 207 |
+
strategy = resolution.strategy
|
| 208 |
+
route_notices: list[str] = []
|
| 209 |
+
if req.thinking_mode == "auto":
|
| 210 |
+
route_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.")
|
| 211 |
+
budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else ""
|
| 212 |
+
if budget_tier == "low" and mode == "heavy":
|
| 213 |
+
route_notices.append("Budget tier is low; downgraded heavy mode to think.")
|
| 214 |
+
mode = "think"
|
| 215 |
+
strategy = "expert-thinking"
|
| 216 |
+
|
| 217 |
+
# The request's tools (the project's documents among them), what runs first, and the
|
| 218 |
+
# effort this message earns inside the mode (policies.effort_for).
|
| 219 |
+
registry = _request_tools(req)
|
| 220 |
+
forced = _forced_tools(req, registry)
|
| 221 |
+
effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced))
|
| 222 |
+
temperature = req.temperature if req.temperature is not None else effort.temperature
|
| 223 |
+
max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens
|
| 224 |
+
system_prompt = project_system_prompt(req.system_prompt, _project(req))
|
| 225 |
+
direct = effort.level == "direct"
|
| 226 |
+
if direct and mode != "fast":
|
| 227 |
+
strategy = "direct"
|
| 228 |
+
|
| 229 |
+
# Compaction: the turns the window keeps go verbatim; the older ones ride as a note.
|
| 230 |
+
window, note, compaction_notice = await compaction.prepare(
|
| 231 |
+
[m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=req.model,
|
| 232 |
+
)
|
| 233 |
+
if compaction_notice:
|
| 234 |
+
route_notices.append(compaction_notice)
|
| 235 |
+
run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider)
|
| 236 |
+
if escalated:
|
| 237 |
+
route_notices.append(_escalation_notice(escalated, chosen, req))
|
| 238 |
+
|
| 239 |
+
trace_id = (req.session_id or "expert")[:8]
|
| 240 |
+
history_chars = sum(len(m.content or "") for m in req.history)
|
| 241 |
+
print(
|
| 242 |
+
f"[EXPERT CHAT {trace_id}] start requested_mode={req.thinking_mode!r} "
|
| 243 |
+
f"resolved_mode={mode!r} strategy={strategy!r} provider={provider!r} "
|
| 244 |
+
f"model={req.model!r} target={chosen!r} complexity={complexity} history={len(req.history)} "
|
| 245 |
+
f"history_chars={history_chars} query_chars={len(req.query or '')} "
|
| 246 |
+
f"max_tokens={max_tokens} temperature={temperature}"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
# ββ heavy pipeline ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
if mode == "heavy" and not direct:
|
| 252 |
+
with target_mod.using(run_target), compaction.using(note):
|
| 253 |
+
result = await heavy(
|
| 254 |
+
req.query, provider,
|
| 255 |
+
history=window, system_prompt=system_prompt, forced_tools=forced,
|
| 256 |
+
research=effort.research, registry=registry, tool_rounds=effort.tool_rounds,
|
| 257 |
+
model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 258 |
+
)
|
| 259 |
+
print(
|
| 260 |
+
f"[EXPERT CHAT {trace_id}] done mode=heavy provider={result.get('provider', provider)!r} "
|
| 261 |
+
f"elapsed_ms={int((time.perf_counter() - started) * 1000)} "
|
| 262 |
+
f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))} "
|
| 263 |
+
f"verdict={result.get('verdict')!r} escalated={escalated!r}"
|
| 264 |
+
)
|
| 265 |
+
return ExpertChatResponse(
|
| 266 |
+
content=result["final_answer"],
|
| 267 |
+
provider_used=result.get("provider", provider),
|
| 268 |
+
model_used=_model_label(run_target, req.model),
|
| 269 |
+
complexity_score=complexity,
|
| 270 |
+
thinking_mode_used="heavy",
|
| 271 |
+
steps=result.get("agents"),
|
| 272 |
+
strategy_used=strategy,
|
| 273 |
+
fallback_applied=bool(result.get("fallback_applied", False)),
|
| 274 |
+
notices=[*route_notices, *list(result.get("notices", []))],
|
| 275 |
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
| 276 |
+
tool_trace=list(result.get("tool_trace", [])),
|
| 277 |
+
sources=list(result.get("sources", [])),
|
| 278 |
+
verdict=result.get("verdict"),
|
| 279 |
+
verdict_detail=result.get("verdict_detail"),
|
| 280 |
+
model_escalated=escalated,
|
| 281 |
+
effort=effort.as_dict(),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
# ββ think pipeline ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 285 |
+
if mode == "think" and not direct:
|
| 286 |
+
with compaction.using(note):
|
| 287 |
+
result = await think(
|
| 288 |
+
req.query, provider,
|
| 289 |
+
history=window, system_prompt=system_prompt, forced_tools=forced,
|
| 290 |
+
plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds,
|
| 291 |
+
model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 292 |
+
with_critique=req.with_critique,
|
| 293 |
+
)
|
| 294 |
+
print(
|
| 295 |
+
f"[EXPERT CHAT {trace_id}] done mode=think provider={result.get('provider', provider)!r} "
|
| 296 |
+
f"elapsed_ms={int((time.perf_counter() - started) * 1000)} "
|
| 297 |
+
f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))}"
|
| 298 |
+
)
|
| 299 |
+
return ExpertChatResponse(
|
| 300 |
+
content=result["final_answer"],
|
| 301 |
+
provider_used=result.get("provider", provider),
|
| 302 |
+
model_used=result.get("model") or _model_label(chosen, req.model),
|
| 303 |
+
complexity_score=complexity,
|
| 304 |
+
thinking_mode_used="think",
|
| 305 |
+
steps=result.get("steps"),
|
| 306 |
+
strategy_used=strategy,
|
| 307 |
+
fallback_applied=bool(result.get("fallback_applied", False)),
|
| 308 |
+
notices=[*route_notices, *list(result.get("notices", []))],
|
| 309 |
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
| 310 |
+
tool_trace=list(result.get("tool_trace", [])),
|
| 311 |
+
sources=list(result.get("sources", [])),
|
| 312 |
+
effort=effort.as_dict(),
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# ββ one generation with the per-turn tool loop: Fast, and a trivial message in any mode ββ
|
| 316 |
+
with compaction.using(note):
|
| 317 |
+
messages = build_messages(req.query, history=window, system_prompt=system_prompt)
|
| 318 |
+
result = await run_with_tools(
|
| 319 |
+
messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 320 |
+
call=dispatch, forced=forced, registry=registry, max_rounds=effort.tool_rounds,
|
| 321 |
+
)
|
| 322 |
+
content = result.get("content") or ""
|
| 323 |
+
notices = [*route_notices, *list(result.get("notices", []))]
|
| 324 |
+
|
| 325 |
+
print(
|
| 326 |
+
f"[EXPERT CHAT {trace_id}] done mode={mode} effort={effort.level} provider={provider!r} "
|
| 327 |
+
f"model={_model_label(chosen, req.model)!r} elapsed_ms={int((time.perf_counter() - started) * 1000)} "
|
| 328 |
+
f"answer_len={len(content or '')} tools={len(result.get('tool_trace', []))} "
|
| 329 |
+
f"fallback={bool(result.get('fallback_applied', False))}"
|
| 330 |
+
)
|
| 331 |
+
return ExpertChatResponse(
|
| 332 |
+
content=content,
|
| 333 |
+
provider_used=(chosen.label if chosen is not None else provider),
|
| 334 |
+
model_used=_model_label(chosen, req.model),
|
| 335 |
+
complexity_score=complexity,
|
| 336 |
+
thinking_mode_used=mode,
|
| 337 |
+
strategy_used=strategy,
|
| 338 |
+
fallback_applied=bool(result.get("fallback_applied", False)),
|
| 339 |
+
notices=notices,
|
| 340 |
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
| 341 |
+
tool_trace=list(result.get("tool_trace", [])),
|
| 342 |
+
sources=list(result.get("sources", [])),
|
| 343 |
+
effort=effort.as_dict(),
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
except Exception as exc:
|
| 347 |
+
print(f"[EXPERT CHAT {trace_id}] error mode={mode!r} provider={provider!r} error={type(exc).__name__}: {exc}")
|
| 348 |
+
logger.error("Expert chat failed (mode=%s): %s", mode, exc, exc_info=True)
|
| 349 |
+
raise HTTPException(status_code=502, detail=f"Expert pipeline error: {exc}")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 353 |
+
# POST /v1/expert/stream (Server-Sent Events streaming)
|
| 354 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 355 |
+
|
| 356 |
+
@router.post("/stream", summary="Expert chat (streaming SSE)")
|
| 357 |
+
async def expert_stream(req: ExpertStreamRequest) -> StreamingResponse:
|
| 358 |
+
"""
|
| 359 |
+
Stream Expert response via SSE. Routes through fast/think/heavy pipeline.
|
| 360 |
+
|
| 361 |
+
Event types:
|
| 362 |
+
event: meta β {provider, complexity, thinking_mode}
|
| 363 |
+
event: step β {step, label, provider} (think/heavy only)
|
| 364 |
+
event: step_end β {step}
|
| 365 |
+
data: <token> β plain text token
|
| 366 |
+
data: [DONE] β end of stream
|
| 367 |
+
event: error β {error: str}
|
| 368 |
+
"""
|
| 369 |
+
complexity = _compute_request_complexity(req)
|
| 370 |
+
chosen = target_mod.from_request(req.target)
|
| 371 |
+
provider = _provider_for(req, chosen)
|
| 372 |
+
|
| 373 |
+
resolution = resolve_thinking_mode(req.thinking_mode, complexity)
|
| 374 |
+
mode = resolution.mode_used
|
| 375 |
+
stream_notices: list[str] = []
|
| 376 |
+
if req.thinking_mode == "auto":
|
| 377 |
+
stream_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.")
|
| 378 |
+
budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else ""
|
| 379 |
+
if budget_tier == "low" and mode == "heavy":
|
| 380 |
+
stream_notices.append("Budget tier is low; downgraded heavy mode to think.")
|
| 381 |
+
mode = "think"
|
| 382 |
+
resolution = resolve_thinking_mode(mode, complexity)
|
| 383 |
+
|
| 384 |
+
# The request's tools, what runs first, and the effort this message earns (as in _expert_chat).
|
| 385 |
+
registry = _request_tools(req)
|
| 386 |
+
forced = _forced_tools(req, registry)
|
| 387 |
+
effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced))
|
| 388 |
+
temperature = req.temperature if req.temperature is not None else effort.temperature
|
| 389 |
+
max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens
|
| 390 |
+
system_prompt = project_system_prompt(req.system_prompt, _project(req))
|
| 391 |
+
direct = effort.level == "direct"
|
| 392 |
+
strategy = "direct" if direct and mode != "fast" else resolution.strategy
|
| 393 |
+
|
| 394 |
+
# Compaction runs inside the stream (below): the reader sees "Compacting the conversationβ¦"
|
| 395 |
+
# while the summary is written, and the summary is written by the chat's own model (the
|
| 396 |
+
# target context is set around the stream, not around this function).
|
| 397 |
+
history_dump = [m.model_dump() for m in req.history]
|
| 398 |
+
compacting_turns = compaction.needs_summary(history_dump, req.session_id)
|
| 399 |
+
run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider)
|
| 400 |
+
if escalated:
|
| 401 |
+
stream_notices.append(_escalation_notice(escalated, chosen, req))
|
| 402 |
+
started = time.perf_counter()
|
| 403 |
+
project = _project(req)
|
| 404 |
+
|
| 405 |
+
def sse(name: str, payload: Dict[str, Any]) -> str:
|
| 406 |
+
return f"event: {name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
| 407 |
+
|
| 408 |
+
def tok(text: str) -> str:
|
| 409 |
+
return f"data: {text.replace(chr(10), chr(92) + 'n')}\n\n"
|
| 410 |
+
|
| 411 |
+
async def event_generator():
|
| 412 |
+
summary: Dict[str, Any] = {"tool_trace": [], "sources": [], "verdict": None, "notices": list(stream_notices)}
|
| 413 |
+
final_answer = ""
|
| 414 |
+
|
| 415 |
+
def relay(event: Dict[str, Any]) -> str:
|
| 416 |
+
"""A pipeline event β its SSE frame ('' for events the client has no use for)."""
|
| 417 |
+
kind = event["type"]
|
| 418 |
+
if kind == "step_start":
|
| 419 |
+
return sse("step", {
|
| 420 |
+
"step": event["step"], "label": event.get("label", ""),
|
| 421 |
+
"provider": event.get("provider", provider), "role": event.get("role", "work"),
|
| 422 |
+
})
|
| 423 |
+
if kind == "step_end":
|
| 424 |
+
return sse("step_end", {"step": event["step"]})
|
| 425 |
+
if kind == "token":
|
| 426 |
+
return tok(event["content"])
|
| 427 |
+
if kind in ("tool_start", "tool_end"):
|
| 428 |
+
payload = {k: v for k, v in event.items() if k != "type"}
|
| 429 |
+
payload["phase"] = "start" if kind == "tool_start" else "end"
|
| 430 |
+
if kind == "tool_end":
|
| 431 |
+
summary["tool_trace"].append(
|
| 432 |
+
{k: event.get(k) for k in ("id", "name", "label", "ok", "summary", "elapsed_ms", "step")}
|
| 433 |
+
)
|
| 434 |
+
return sse("tool", payload)
|
| 435 |
+
if kind == "sources":
|
| 436 |
+
summary["sources"] = event["sources"]
|
| 437 |
+
return sse("sources", {"sources": event["sources"], "step": event.get("step")})
|
| 438 |
+
if kind == "reset":
|
| 439 |
+
return sse("reset", {"step": event.get("step")})
|
| 440 |
+
if kind == "wait":
|
| 441 |
+
return sse("wait", {k: v for k, v in event.items() if k != "type"})
|
| 442 |
+
if kind == "notice":
|
| 443 |
+
if event["text"] not in summary["notices"]:
|
| 444 |
+
summary["notices"].append(event["text"])
|
| 445 |
+
return sse("notice", {"text": event["text"]})
|
| 446 |
+
if kind == "verdict":
|
| 447 |
+
summary["verdict"] = event["verdict"]
|
| 448 |
+
summary["verdict_detail"] = {"verdict": event["verdict"], "note": event.get("note", ""), **(event.get("detail") or {})}
|
| 449 |
+
return sse("verdict", {"verdict": event["verdict"], "note": event.get("note", ""), "detail": event.get("detail") or {}})
|
| 450 |
+
return ""
|
| 451 |
+
|
| 452 |
+
if compacting_turns:
|
| 453 |
+
yield sse("compacting", {
|
| 454 |
+
"turns": compacting_turns,
|
| 455 |
+
"text": f"Compacting the conversation: summarizing {compacting_turns} earlier "
|
| 456 |
+
f"message{'' if compacting_turns == 1 else 's'}β¦",
|
| 457 |
+
})
|
| 458 |
+
window, note, compaction_notice = await compaction.prepare(
|
| 459 |
+
history_dump, session_id=req.session_id, provider=provider, model=req.model,
|
| 460 |
+
)
|
| 461 |
+
if compaction_notice:
|
| 462 |
+
stream_notices.append(compaction_notice)
|
| 463 |
+
summary["notices"].append(compaction_notice)
|
| 464 |
+
yield sse("meta", {
|
| 465 |
+
"provider": provider, "complexity": complexity, "thinking_mode": mode,
|
| 466 |
+
"strategy": strategy, "notices": list(stream_notices),
|
| 467 |
+
"model": _model_label(run_target, req.model), "escalated": escalated,
|
| 468 |
+
"compacted": bool(note), "effort": effort.as_dict(),
|
| 469 |
+
"project": {"id": project["id"], "name": project.get("name")} if project else None,
|
| 470 |
+
})
|
| 471 |
+
try:
|
| 472 |
+
with compaction.using(note):
|
| 473 |
+
if mode == "heavy" and not direct:
|
| 474 |
+
with target_mod.using(run_target):
|
| 475 |
+
async for event in stream_heavy(
|
| 476 |
+
req.query, provider, history=window, system_prompt=system_prompt,
|
| 477 |
+
forced_tools=forced, research=effort.research, registry=registry,
|
| 478 |
+
tool_rounds=effort.tool_rounds,
|
| 479 |
+
model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 480 |
+
):
|
| 481 |
+
if event["type"] == "final":
|
| 482 |
+
final_answer = event.get("content") or ""
|
| 483 |
+
continue
|
| 484 |
+
frame = relay(event)
|
| 485 |
+
if frame:
|
| 486 |
+
yield frame
|
| 487 |
+
elif mode == "think" and not direct:
|
| 488 |
+
async for event in stream_think(
|
| 489 |
+
req.query, provider, history=window, system_prompt=system_prompt,
|
| 490 |
+
forced_tools=forced, plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds,
|
| 491 |
+
model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 492 |
+
with_critique=req.with_critique,
|
| 493 |
+
):
|
| 494 |
+
if event["type"] == "final":
|
| 495 |
+
final_answer = event.get("content") or ""
|
| 496 |
+
continue
|
| 497 |
+
frame = relay(event)
|
| 498 |
+
if frame:
|
| 499 |
+
yield frame
|
| 500 |
+
else:
|
| 501 |
+
# One generation with the tool loop: Fast, and a trivial message in any mode.
|
| 502 |
+
messages = build_messages(req.query, history=window, system_prompt=system_prompt)
|
| 503 |
+
async for event in stream_with_tools(
|
| 504 |
+
messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens,
|
| 505 |
+
forced=forced, registry=registry, max_rounds=effort.tool_rounds,
|
| 506 |
+
):
|
| 507 |
+
if event["type"] == "done":
|
| 508 |
+
final_answer = event.get("content") or ""
|
| 509 |
+
summary["tool_trace"] = list(event.get("tool_trace", []))
|
| 510 |
+
summary["sources"] = list(event.get("sources", []))
|
| 511 |
+
continue
|
| 512 |
+
frame = relay(event)
|
| 513 |
+
if frame:
|
| 514 |
+
yield frame
|
| 515 |
+
if mode in ("heavy", "think") and not direct and final_answer:
|
| 516 |
+
# The reply, sent whole: the step tokens above were the work, not the answer.
|
| 517 |
+
yield "event: final_answer\ndata: start\n\n"
|
| 518 |
+
yield tok(final_answer)
|
| 519 |
+
yield sse("done", {
|
| 520 |
+
"content": final_answer, "thinking_mode": mode, "strategy": strategy, "effort": effort.as_dict(),
|
| 521 |
+
"latency_ms": int((time.perf_counter() - started) * 1000), **summary,
|
| 522 |
+
})
|
| 523 |
+
except Exception as exc:
|
| 524 |
+
logger.error("Expert stream error (mode=%s): %s", mode, exc, exc_info=True)
|
| 525 |
+
yield sse("error", {"error": str(exc)})
|
| 526 |
+
yield "data: [DONE]\n\n"
|
| 527 |
+
|
| 528 |
+
async def with_target():
|
| 529 |
+
# Set inside the generator: the response body is iterated after this function has
|
| 530 |
+
# returned, so a context set around the return would already be gone.
|
| 531 |
+
with target_mod.using(chosen), grounding.using(req.client_timezone, req.context):
|
| 532 |
+
async for item in event_generator():
|
| 533 |
+
yield item
|
| 534 |
+
|
| 535 |
+
return StreamingResponse(
|
| 536 |
+
with_heartbeat(with_target()),
|
| 537 |
+
media_type="text/event-stream",
|
| 538 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
async def with_heartbeat(source: AsyncIterator[str], every: Optional[float] = None) -> AsyncIterator[str]:
|
| 543 |
+
"""``source``'s frames, plus a ``: keep-alive`` comment whenever it is silent for ``every``
|
| 544 |
+
seconds (a long model call before the first token, a slow tool). Proxies and browsers
|
| 545 |
+
then never take a working stream for a dead one. The source runs as its own task, so a
|
| 546 |
+
timeout never cancels it mid-await; when the reader goes away, that task is cancelled
|
| 547 |
+
with the response, which closes the model request behind it."""
|
| 548 |
+
every = config_mod.EXPERT_SSE_HEARTBEAT_S if every is None else every
|
| 549 |
+
if not every or every <= 0:
|
| 550 |
+
async for item in source:
|
| 551 |
+
yield item
|
| 552 |
+
return
|
| 553 |
+
queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
| 554 |
+
finished = object()
|
| 555 |
+
|
| 556 |
+
async def pump() -> None:
|
| 557 |
+
try:
|
| 558 |
+
async for item in source:
|
| 559 |
+
await queue.put(item)
|
| 560 |
+
except asyncio.CancelledError:
|
| 561 |
+
raise
|
| 562 |
+
except Exception as exc: # noqa: BLE001 - re-raised on the reader's side
|
| 563 |
+
await queue.put(exc)
|
| 564 |
+
return
|
| 565 |
+
await queue.put(finished)
|
| 566 |
+
|
| 567 |
+
task = asyncio.create_task(pump())
|
| 568 |
+
try:
|
| 569 |
+
while True:
|
| 570 |
+
try:
|
| 571 |
+
item = await asyncio.wait_for(queue.get(), timeout=every)
|
| 572 |
+
except asyncio.TimeoutError:
|
| 573 |
+
yield ": keep-alive\n\n"
|
| 574 |
+
continue
|
| 575 |
+
if item is finished:
|
| 576 |
+
break
|
| 577 |
+
if isinstance(item, Exception):
|
| 578 |
+
raise item
|
| 579 |
+
yield item
|
| 580 |
+
finally:
|
| 581 |
+
if not task.done():
|
| 582 |
+
task.cancel()
|
| 583 |
+
try:
|
| 584 |
+
await task
|
| 585 |
+
except BaseException: # noqa: BLE001 - the pump's own cancellation
|
| 586 |
+
pass
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 590 |
+
# Capabilities + Deep research
|
| 591 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 592 |
+
|
| 593 |
+
_SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
@router.get("/usage", summary="Usage meter: each model endpoint's queue, pacing and learned rate limit")
|
| 597 |
+
async def expert_usage() -> dict:
|
| 598 |
+
"""Per OpenAI-compatible endpoint this process has called: requests in flight and in line,
|
| 599 |
+
the gap between starts and any cooldown, the learned cap (requests per window before the
|
| 600 |
+
provider rate-limits), and the rolling window's requests, successes and rate limits."""
|
| 601 |
+
from .providers import endpoint_usage
|
| 602 |
+
from .config import EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S
|
| 603 |
+
return {
|
| 604 |
+
"endpoints": endpoint_usage(),
|
| 605 |
+
"policy": {
|
| 606 |
+
"max_in_flight": EXPERT_PROVIDER_MAX_INFLIGHT,
|
| 607 |
+
"interactive_wait_s": EXPERT_PROVIDER_WAIT_S,
|
| 608 |
+
"background_wait_s": EXPERT_PROVIDER_BACKGROUND_WAIT_S,
|
| 609 |
+
},
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
@router.post("/capabilities", summary="What the connected models can carry (the Deep research gate)")
|
| 614 |
+
async def expert_capabilities(req: CapabilitiesRequest) -> dict:
|
| 615 |
+
"""The selected model's tier, the strongest model on the same connection, and whether
|
| 616 |
+
Deep research is offered (capability.py). POST, so a gateway key never rides in a URL."""
|
| 617 |
+
chosen = target_mod.from_request(req.target)
|
| 618 |
+
return await capability.assess(chosen, req.model, run_probe=req.probe)
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
@router.post("/deep", summary="Start a Deep research run (background job)")
|
| 622 |
+
async def deep_start(req: ExpertChatRequest) -> dict:
|
| 623 |
+
"""Plan β sub-agents with tools β cited report, as a background job (deep.py). Refused
|
| 624 |
+
(403) when the connection has no high-end model, unless EXPERT_DEEP_RESEARCH=always."""
|
| 625 |
+
chosen = target_mod.from_request(req.target)
|
| 626 |
+
provider = _provider_for(req, chosen)
|
| 627 |
+
caps = await capability.assess(chosen, req.model)
|
| 628 |
+
gate = caps["deep_research"]
|
| 629 |
+
if not gate["enabled"]:
|
| 630 |
+
raise HTTPException(status_code=403, detail=gate["reason"])
|
| 631 |
+
notices: list = []
|
| 632 |
+
# The model chosen in the chat runs the research too (no silent swap to another model).
|
| 633 |
+
run_target, model = chosen, req.model
|
| 634 |
+
window, note, compaction_notice = await compaction.prepare(
|
| 635 |
+
[m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=model,
|
| 636 |
+
)
|
| 637 |
+
if compaction_notice:
|
| 638 |
+
notices.append(compaction_notice)
|
| 639 |
+
registry = _request_tools(req)
|
| 640 |
+
job = deep.start(
|
| 641 |
+
req.query, provider=provider, model=_model_label(run_target, model), history=window, chosen=run_target,
|
| 642 |
+
timezone=req.client_timezone, context=req.context, note=note, notices=notices,
|
| 643 |
+
registry=registry, operator_prompt=project_system_prompt(req.system_prompt, _project(req)),
|
| 644 |
+
session_id=req.session_id,
|
| 645 |
+
)
|
| 646 |
+
return {"job_id": job.id, "status": job.status, "model": job.model, "notices": notices,
|
| 647 |
+
"time_budget_s": EXPERT_DEEP_TIME_BUDGET_S}
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
@router.get("/deep/{job_id}", summary="Deep research job snapshot")
|
| 651 |
+
async def deep_status(job_id: str) -> dict:
|
| 652 |
+
job = deep.get_job(job_id)
|
| 653 |
+
if job is None:
|
| 654 |
+
raise HTTPException(status_code=404, detail="Unknown Deep research job")
|
| 655 |
+
return job.snapshot()
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
@router.get("/deep/{job_id}/events", summary="Deep research progress (SSE; replays from `after`, or from the start)")
|
| 659 |
+
async def deep_events(job_id: str, request: Request, after: int = 0) -> StreamingResponse:
|
| 660 |
+
"""Every frame carries ``id: <seq>``. A client that reconnects sends ``Last-Event-ID`` (or
|
| 661 |
+
``?after=``) and gets only what it missed β no duplicate steps, no duplicate text."""
|
| 662 |
+
job = deep.get_job(job_id)
|
| 663 |
+
if job is None:
|
| 664 |
+
raise HTTPException(status_code=404, detail="Unknown Deep research job")
|
| 665 |
+
header = request.headers.get("last-event-id")
|
| 666 |
+
if header and header.isdigit():
|
| 667 |
+
after = max(after, int(header))
|
| 668 |
+
|
| 669 |
+
async def gen():
|
| 670 |
+
queue = job.subscribe(after=after)
|
| 671 |
+
try:
|
| 672 |
+
while True:
|
| 673 |
+
try:
|
| 674 |
+
ev = await asyncio.wait_for(queue.get(), timeout=15.0)
|
| 675 |
+
except asyncio.TimeoutError:
|
| 676 |
+
if job.finished_state:
|
| 677 |
+
break
|
| 678 |
+
yield ": keep-alive\n\n"
|
| 679 |
+
continue
|
| 680 |
+
yield f"id: {ev.get('seq', 0)}\nevent: {ev['type']}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
|
| 681 |
+
if ev["type"] in ("done", "error", "cancelled"):
|
| 682 |
+
break
|
| 683 |
+
finally:
|
| 684 |
+
job.unsubscribe(queue)
|
| 685 |
+
yield "data: [DONE]\n\n"
|
| 686 |
+
|
| 687 |
+
return StreamingResponse(gen(), media_type="text/event-stream", headers=_SSE_HEADERS)
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
@router.delete("/deep/{job_id}", summary="Cancel a Deep research job")
|
| 691 |
+
async def deep_cancel(job_id: str) -> dict:
|
| 692 |
+
job = deep.get_job(job_id)
|
| 693 |
+
if job is None:
|
| 694 |
+
raise HTTPException(status_code=404, detail="Unknown Deep research job")
|
| 695 |
+
job.cancel()
|
| 696 |
+
return {"job_id": job_id, "status": job.status}
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 700 |
+
# POST /v1/expert/route (debug helper β shows routing decision)
|
| 701 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 702 |
+
|
| 703 |
+
@router.post("/route", summary="Debug: show provider routing decision")
|
| 704 |
+
async def expert_route(query: str, preferred: str = "auto") -> dict:
|
| 705 |
+
"""
|
| 706 |
+
Returns the provider that would be selected for a given query.
|
| 707 |
+
Useful for debugging and tuning routing thresholds.
|
| 708 |
+
"""
|
| 709 |
+
complexity = score_complexity(query)
|
| 710 |
+
provider = select_provider(query, preferred=preferred) # type: ignore[arg-type]
|
| 711 |
+
return {
|
| 712 |
+
"query_preview": query[:100],
|
| 713 |
+
"complexity_score": complexity,
|
| 714 |
+
"selected_provider": provider,
|
| 715 |
+
"available_providers": available_expert_providers(),
|
| 716 |
+
"thresholds": {
|
| 717 |
+
"local": f"complexity β€ {EXPERT_LOCAL_THRESHOLD}",
|
| 718 |
+
"groq": f"complexity β€ {EXPERT_GROQ_THRESHOLD}",
|
| 719 |
+
"cloud": f"complexity > {EXPERT_GROQ_THRESHOLD}",
|
| 720 |
+
},
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
@router.post("/ollabridge/chat/completions", summary="OllaBridge-compatible Expert chat")
|
| 725 |
+
async def ollabridge_chat(req: OllabridgeChatRequest) -> dict:
|
| 726 |
+
"""
|
| 727 |
+
OpenAI-compatible adapter endpoint so HomePilot can expose Expert through
|
| 728 |
+
OllaBridge cloud persona workflows.
|
| 729 |
+
"""
|
| 730 |
+
if req.stream:
|
| 731 |
+
raise HTTPException(status_code=400, detail="stream=true is not supported on this adapter endpoint yet.")
|
| 732 |
+
|
| 733 |
+
if not req.messages:
|
| 734 |
+
raise HTTPException(status_code=422, detail="messages cannot be empty.")
|
| 735 |
+
|
| 736 |
+
user_query = ""
|
| 737 |
+
history = []
|
| 738 |
+
for msg in req.messages:
|
| 739 |
+
d = msg.model_dump()
|
| 740 |
+
if d["role"] == "user":
|
| 741 |
+
user_query = d["content"]
|
| 742 |
+
else:
|
| 743 |
+
history.append(d)
|
| 744 |
+
|
| 745 |
+
if not user_query:
|
| 746 |
+
raise HTTPException(status_code=422, detail="At least one user message is required.")
|
| 747 |
+
|
| 748 |
+
expert_req = ExpertChatRequest(
|
| 749 |
+
query=user_query,
|
| 750 |
+
provider=req.provider,
|
| 751 |
+
thinking_mode=req.thinking_mode,
|
| 752 |
+
history=history,
|
| 753 |
+
model=req.model,
|
| 754 |
+
temperature=req.temperature,
|
| 755 |
+
max_tokens=req.max_tokens,
|
| 756 |
+
)
|
| 757 |
+
expert_resp = await expert_chat(expert_req)
|
| 758 |
+
|
| 759 |
+
return {
|
| 760 |
+
"id": "expert-ollabridge-chat",
|
| 761 |
+
"object": "chat.completion",
|
| 762 |
+
"provider": expert_resp.provider_used,
|
| 763 |
+
"model": expert_resp.model_used or req.model or "expert-auto",
|
| 764 |
+
"choices": [
|
| 765 |
+
{
|
| 766 |
+
"index": 0,
|
| 767 |
+
"message": {"role": "assistant", "content": expert_resp.content},
|
| 768 |
+
"finish_reason": "stop",
|
| 769 |
+
}
|
| 770 |
+
],
|
| 771 |
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
@router.post("/persona/draft", response_model=PersonaDraftResponse, summary="Create persona draft from Expert")
|
| 776 |
+
async def expert_persona_draft(req: PersonaDraftRequest) -> PersonaDraftResponse:
|
| 777 |
+
"""
|
| 778 |
+
Generate a persona draft that can be used by HomePilot persona creation
|
| 779 |
+
flows (including OllaBridge cloud personas).
|
| 780 |
+
"""
|
| 781 |
+
constraints = "\\n".join(f"- {c}" for c in req.constraints) if req.constraints else "- Keep safety-first behavior."
|
| 782 |
+
prompt = (
|
| 783 |
+
"Create a high-quality HomePilot persona draft as JSON fields:\\n"
|
| 784 |
+
"name, description, system_prompt, first_message, suggested_tools (array), "
|
| 785 |
+
"memory_policy (object with keys recall, retention_days, pii_redaction).\\n\\n"
|
| 786 |
+
f"Mission: {req.mission}\\n"
|
| 787 |
+
f"Domain: {req.domain}\\n"
|
| 788 |
+
f"Tone: {req.tone}\\n"
|
| 789 |
+
f"Constraints:\\n{constraints}\\n"
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
expert_req = ExpertChatRequest(
|
| 793 |
+
query=prompt,
|
| 794 |
+
provider=req.provider,
|
| 795 |
+
thinking_mode=req.thinking_mode,
|
| 796 |
+
model=req.model,
|
| 797 |
+
temperature=req.temperature,
|
| 798 |
+
max_tokens=req.max_tokens,
|
| 799 |
+
)
|
| 800 |
+
resp = await expert_chat(expert_req)
|
| 801 |
+
|
| 802 |
+
content = resp.content.strip()
|
| 803 |
+
default_name = f"{req.domain.title()} Research Persona"
|
| 804 |
+
default_desc = f"Persona for {req.mission[:120]}"
|
| 805 |
+
return PersonaDraftResponse(
|
| 806 |
+
name=default_name,
|
| 807 |
+
description=default_desc,
|
| 808 |
+
system_prompt=content,
|
| 809 |
+
first_message=f"Hello, I am your {default_name}. I can help with: {req.mission}.",
|
| 810 |
+
suggested_tools=["retrieval", "web_search", "model_compare"],
|
| 811 |
+
memory_policy={"recall": True, "retention_days": 30, "pii_redaction": True},
|
| 812 |
+
provider_used=resp.provider_used,
|
| 813 |
+
thinking_mode_used=resp.thinking_mode_used,
|
| 814 |
+
)
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
@router.get("/readiness", response_model=ProductionReadinessResponse, summary="Expert production readiness report")
|
| 818 |
+
async def expert_readiness() -> ProductionReadinessResponse:
|
| 819 |
+
"""
|
| 820 |
+
Returns a lightweight production-readiness report based on Expert feature
|
| 821 |
+
flags and required runtime configuration.
|
| 822 |
+
"""
|
| 823 |
+
report = build_readiness_report()
|
| 824 |
+
return ProductionReadinessResponse(
|
| 825 |
+
ready=report.ready,
|
| 826 |
+
stage=report.stage, # type: ignore[arg-type]
|
| 827 |
+
checks=[ReadinessCheckItem(name=c.name, passed=c.passed, detail=c.detail) for c in report.checks],
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
@router.get("/mcp/plan", summary="Expert MCP rollout plan and configuration status")
|
| 832 |
+
async def expert_mcp_plan() -> dict:
|
| 833 |
+
gateway = MCPGateway()
|
| 834 |
+
status_by_key = {item.key: item for item in gateway.status()}
|
| 835 |
+
servers = []
|
| 836 |
+
|
| 837 |
+
for spec in ESSENTIAL_MCP_SERVERS:
|
| 838 |
+
status = status_by_key.get(spec.key)
|
| 839 |
+
servers.append(
|
| 840 |
+
{
|
| 841 |
+
"key": spec.key,
|
| 842 |
+
"priority": spec.priority,
|
| 843 |
+
"purpose": spec.purpose,
|
| 844 |
+
"required_apis": spec.required_apis,
|
| 845 |
+
"configured": status.configured if status else False,
|
| 846 |
+
"endpoint": status.endpoint if status else "",
|
| 847 |
+
"forge_tool_id": status.forge_tool_id if status else "",
|
| 848 |
+
"env_key": mcp_key_to_env(spec.key),
|
| 849 |
+
"forge_tool_env_key": mcp_key_to_forge_tool_env(spec.key),
|
| 850 |
+
}
|
| 851 |
+
)
|
| 852 |
+
|
| 853 |
+
return {
|
| 854 |
+
"orchestrator": gateway.orchestrator,
|
| 855 |
+
"context_forge_url": gateway.forge_base_url,
|
| 856 |
+
"tool_mapping": TOOL_MAPPING,
|
| 857 |
+
"servers": servers,
|
| 858 |
+
"minimal_production_set": [
|
| 859 |
+
"mcp-web-search",
|
| 860 |
+
"mcp-doc-retrieval",
|
| 861 |
+
"mcp-memory-store",
|
| 862 |
+
"mcp-safety-policy",
|
| 863 |
+
"mcp-observability",
|
| 864 |
+
],
|
| 865 |
+
}
|
expert/backend/app/expert/safety_policy.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
_BLOCKED_PATTERNS = [
|
| 8 |
+
"how to build a bomb",
|
| 9 |
+
"weaponize",
|
| 10 |
+
"bypass biosafety",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class SafetyDecision:
|
| 16 |
+
allowed: bool
|
| 17 |
+
reasons: List[str]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def evaluate_prompt(prompt: str, profile: str = "strict-research") -> SafetyDecision:
|
| 21 |
+
q = prompt.lower()
|
| 22 |
+
reasons: List[str] = []
|
| 23 |
+
|
| 24 |
+
if profile == "off":
|
| 25 |
+
return SafetyDecision(True, reasons)
|
| 26 |
+
|
| 27 |
+
for p in _BLOCKED_PATTERNS:
|
| 28 |
+
if p in q:
|
| 29 |
+
reasons.append(f"Blocked pattern matched: {p}")
|
| 30 |
+
|
| 31 |
+
return SafetyDecision(allowed=not reasons, reasons=reasons)
|
expert/backend/app/expert/schemas.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/schemas.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ExpertMessage(BaseModel):
|
| 9 |
+
"""Single turn in conversation history."""
|
| 10 |
+
role: Literal["user", "assistant", "system"]
|
| 11 |
+
content: str
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ExpertTarget(BaseModel):
|
| 15 |
+
"""The chat model the user picked in Models / Settings (see ``target.py``)."""
|
| 16 |
+
provider: str = Field(default="", max_length=40, description="'ollama', 'openai_compat', β¦")
|
| 17 |
+
base_url: Optional[str] = Field(default=None, max_length=500)
|
| 18 |
+
model: Optional[str] = Field(default=None, max_length=300)
|
| 19 |
+
api_key: Optional[str] = Field(default=None, max_length=4096, repr=False)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ExpertProject(BaseModel):
|
| 23 |
+
"""The project a chat runs inside: its instructions reach every step; its documents become
|
| 24 |
+
the ``project_search`` tool when the backend's retrieval is reachable."""
|
| 25 |
+
id: str = Field(..., max_length=120)
|
| 26 |
+
name: Optional[str] = Field(default=None, max_length=200)
|
| 27 |
+
instructions: Optional[str] = Field(default=None, max_length=20000)
|
| 28 |
+
document_count: int = Field(default=0, ge=0)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ExpertChatRequest(BaseModel):
|
| 32 |
+
"""Request body for POST /v1/expert/chat (non-streaming)."""
|
| 33 |
+
query: str = Field(..., min_length=1, description="User's message / question")
|
| 34 |
+
project: Optional[ExpertProject] = Field(default=None, description="The project this chat runs inside.")
|
| 35 |
+
provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = Field(
|
| 36 |
+
default="auto",
|
| 37 |
+
description="Provider to use. 'auto' applies complexity-based routing.",
|
| 38 |
+
)
|
| 39 |
+
thinking_mode: Literal["auto", "fast", "think", "heavy"] = Field(
|
| 40 |
+
default="auto",
|
| 41 |
+
description=(
|
| 42 |
+
"Pipeline mode. "
|
| 43 |
+
"'auto' = complexity-based selection; "
|
| 44 |
+
"'fast' = single LLM call, local model; "
|
| 45 |
+
"'think' = analyzeβplanβsolve chain (3 LLM calls); "
|
| 46 |
+
"'heavy' = 4-agent researchβreasonβsynthesizeβvalidate pipeline."
|
| 47 |
+
),
|
| 48 |
+
)
|
| 49 |
+
with_critique: bool = Field(
|
| 50 |
+
default=False,
|
| 51 |
+
description="In 'think' mode, add a self-critique/correction step.",
|
| 52 |
+
)
|
| 53 |
+
history: List[ExpertMessage] = Field(
|
| 54 |
+
default_factory=list,
|
| 55 |
+
description="Previous conversation turns (newest last).",
|
| 56 |
+
)
|
| 57 |
+
model: Optional[str] = Field(
|
| 58 |
+
default=None,
|
| 59 |
+
description="Override the default model for the selected provider.",
|
| 60 |
+
)
|
| 61 |
+
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
| 62 |
+
max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
|
| 63 |
+
system_prompt: Optional[str] = Field(
|
| 64 |
+
default=None,
|
| 65 |
+
description="Override the default Expert system prompt.",
|
| 66 |
+
)
|
| 67 |
+
client_timezone: Optional[str] = Field(
|
| 68 |
+
default=None, max_length=64,
|
| 69 |
+
description="The browser's IANA time zone, so the prompt's 'today' is the user's (grounding.py).",
|
| 70 |
+
)
|
| 71 |
+
context: Optional[str] = Field(
|
| 72 |
+
default=None, max_length=40000,
|
| 73 |
+
description="Grounding for this request β e.g. HomePilot's numbered web sources β added to every system prompt.",
|
| 74 |
+
)
|
| 75 |
+
session_id: Optional[str] = Field(
|
| 76 |
+
default=None,
|
| 77 |
+
description="Session identifier for memory/trace linkage.",
|
| 78 |
+
)
|
| 79 |
+
has_attachments: bool = Field(
|
| 80 |
+
default=False,
|
| 81 |
+
description="Whether the request includes attachments or external artifacts.",
|
| 82 |
+
)
|
| 83 |
+
feature_hints: Dict[str, Any] = Field(
|
| 84 |
+
default_factory=dict,
|
| 85 |
+
description="Optional non-authoritative hints from UI (e.g. budget tier, tool-use preference).",
|
| 86 |
+
)
|
| 87 |
+
target: Optional[ExpertTarget] = Field(
|
| 88 |
+
default=None,
|
| 89 |
+
description=(
|
| 90 |
+
"The user's chosen chat model. When set, 'local' (and 'auto') answer from it β "
|
| 91 |
+
"an Ollama model or an OpenAI-compatible endpoint such as an OllaBridge gateway."
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class ExpertStreamRequest(ExpertChatRequest):
|
| 97 |
+
"""Same as ExpertChatRequest β exists for clarity in the route signature."""
|
| 98 |
+
pass
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class ExpertChatResponse(BaseModel):
|
| 102 |
+
"""Response from POST /v1/expert/chat."""
|
| 103 |
+
model_config = {"protected_namespaces": ()}
|
| 104 |
+
|
| 105 |
+
content: str = Field(..., description="The assistant's reply.")
|
| 106 |
+
provider_used: str = Field(..., description="Which provider actually answered.")
|
| 107 |
+
model_used: Optional[str] = Field(default=None)
|
| 108 |
+
complexity_score: int = Field(
|
| 109 |
+
..., ge=0, le=10,
|
| 110 |
+
description="Complexity score that drove provider selection (0=simple, 10=complex).",
|
| 111 |
+
)
|
| 112 |
+
thinking_mode_used: str = Field(
|
| 113 |
+
default="fast",
|
| 114 |
+
description="Which pipeline actually ran: fast / think / heavy.",
|
| 115 |
+
)
|
| 116 |
+
steps: Optional[Dict[str, Any]] = Field(
|
| 117 |
+
default=None,
|
| 118 |
+
description="Intermediate step outputs from think/heavy pipelines.",
|
| 119 |
+
)
|
| 120 |
+
provider_raw: Optional[Dict[str, Any]] = Field(
|
| 121 |
+
default=None,
|
| 122 |
+
description="Raw provider response for debugging (omitted in production).",
|
| 123 |
+
)
|
| 124 |
+
strategy_used: str = Field(
|
| 125 |
+
default="single-pass",
|
| 126 |
+
description="Resolved execution strategy: single-pass / expert-thinking / heavy-multi-pass.",
|
| 127 |
+
)
|
| 128 |
+
fallback_applied: bool = Field(
|
| 129 |
+
default=False,
|
| 130 |
+
description="True when the request had to fall back from requested provider/pipeline path.",
|
| 131 |
+
)
|
| 132 |
+
notices: List[str] = Field(
|
| 133 |
+
default_factory=list,
|
| 134 |
+
description="User-visible operational notices (fallbacks, policy blocks, etc.).",
|
| 135 |
+
)
|
| 136 |
+
latency_ms: Optional[int] = Field(
|
| 137 |
+
default=None,
|
| 138 |
+
description="Total backend processing latency in milliseconds.",
|
| 139 |
+
)
|
| 140 |
+
tool_trace: List[Dict[str, Any]] = Field(
|
| 141 |
+
default_factory=list,
|
| 142 |
+
description="Tool calls made while answering (name, label, ok, elapsed, source numbers).",
|
| 143 |
+
)
|
| 144 |
+
sources: List[Dict[str, Any]] = Field(
|
| 145 |
+
default_factory=list,
|
| 146 |
+
description="Numbered sources the answer may cite as [n] (n, title, url, domain, snippet).",
|
| 147 |
+
)
|
| 148 |
+
verdict: Optional[str] = Field(
|
| 149 |
+
default=None,
|
| 150 |
+
description="Heavy only: the validator's verdict β validated / corrected / needs_review / reviewed.",
|
| 151 |
+
)
|
| 152 |
+
verdict_detail: Optional[Dict[str, Any]] = Field(
|
| 153 |
+
default=None,
|
| 154 |
+
description="Heavy only: issues, method and limitations behind the verdict.",
|
| 155 |
+
)
|
| 156 |
+
model_escalated: Optional[str] = Field(
|
| 157 |
+
default=None,
|
| 158 |
+
description="Heavy only: the stronger model used instead of the selected one, when escalated.",
|
| 159 |
+
)
|
| 160 |
+
effort: Optional[Dict[str, Any]] = Field(
|
| 161 |
+
default=None,
|
| 162 |
+
description="What this request spent: level (direct / standard / full) and why (policies.py).",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class CapabilitiesRequest(BaseModel):
|
| 167 |
+
"""Body for POST /v1/expert/capabilities: which connection and model to assess."""
|
| 168 |
+
target: Optional[ExpertTarget] = None
|
| 169 |
+
model: Optional[str] = Field(default=None, max_length=300)
|
| 170 |
+
probe: bool = Field(default=False, description="Run the one-time tool-calling and JSON probes when not cached.")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class ExpertInfoResponse(BaseModel):
|
| 174 |
+
"""Response from GET /v1/expert/info β lists available providers and routing config."""
|
| 175 |
+
available_providers: List[str]
|
| 176 |
+
default_provider: str
|
| 177 |
+
local_threshold: int
|
| 178 |
+
groq_threshold: int
|
| 179 |
+
local_model: str
|
| 180 |
+
local_fast_model: str
|
| 181 |
+
groq_model: str
|
| 182 |
+
grok_model: str
|
| 183 |
+
gemini_model: str
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class OllabridgeChatRequest(BaseModel):
|
| 187 |
+
"""OpenAI-compatible request body for OllaBridge Expert adapter endpoint."""
|
| 188 |
+
model: Optional[str] = None
|
| 189 |
+
messages: List[ExpertMessage] = Field(default_factory=list)
|
| 190 |
+
stream: bool = False
|
| 191 |
+
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
| 192 |
+
max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
|
| 193 |
+
provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto"
|
| 194 |
+
thinking_mode: Literal["auto", "fast", "think", "heavy"] = "auto"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class PersonaDraftRequest(BaseModel):
|
| 198 |
+
"""Generate a persona draft from Expert for HomePilot persona creation."""
|
| 199 |
+
mission: str = Field(..., min_length=3, description="Primary role and objective of the persona.")
|
| 200 |
+
domain: str = Field(default="general")
|
| 201 |
+
tone: str = Field(default="helpful")
|
| 202 |
+
constraints: List[str] = Field(default_factory=list)
|
| 203 |
+
provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto"
|
| 204 |
+
thinking_mode: Literal["auto", "fast", "think", "heavy"] = "think"
|
| 205 |
+
model: Optional[str] = None
|
| 206 |
+
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
| 207 |
+
max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class PersonaDraftResponse(BaseModel):
|
| 211 |
+
name: str
|
| 212 |
+
description: str
|
| 213 |
+
system_prompt: str
|
| 214 |
+
first_message: str
|
| 215 |
+
suggested_tools: List[str] = Field(default_factory=list)
|
| 216 |
+
memory_policy: Dict[str, Any] = Field(default_factory=dict)
|
| 217 |
+
provider_used: str
|
| 218 |
+
thinking_mode_used: str
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class ReadinessCheckItem(BaseModel):
|
| 222 |
+
name: str
|
| 223 |
+
passed: bool
|
| 224 |
+
detail: str
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class ProductionReadinessResponse(BaseModel):
|
| 228 |
+
ready: bool
|
| 229 |
+
stage: Literal["dev", "preprod", "production"]
|
| 230 |
+
checks: List[ReadinessCheckItem]
|
expert/backend/app/expert/state_store.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A small durable store for the Expert service (SQLite, one file).
|
| 2 |
+
|
| 3 |
+
What must survive a restart: the capability facts learned about models ("this gateway model
|
| 4 |
+
rejects the tools field"), and Deep research jobs with their event history, so a report is
|
| 5 |
+
not lost and a client can reconnect. Opened per call with a process lock; a handful of rows
|
| 6 |
+
per day, so there is nothing to tune.
|
| 7 |
+
|
| 8 |
+
Location: ``EXPERT_STATE_DB``, else ``<DATA_DIR>/expert_state.sqlite`` (HomePilot's data
|
| 9 |
+
directory), else ``backend/data/expert_state.sqlite`` next to the main database.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import os
|
| 16 |
+
import sqlite3
|
| 17 |
+
import tempfile
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
from .config import EXPERT_STATE_DB
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("expert.state")
|
| 26 |
+
|
| 27 |
+
_LOCK = threading.RLock()
|
| 28 |
+
_PATH: Optional[Path] = None
|
| 29 |
+
_READY = False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _default_path() -> Path:
|
| 33 |
+
if EXPERT_STATE_DB:
|
| 34 |
+
return Path(EXPERT_STATE_DB)
|
| 35 |
+
data_dir = (os.getenv("DATA_DIR") or "").strip()
|
| 36 |
+
if data_dir:
|
| 37 |
+
return Path(data_dir) / "expert_state.sqlite"
|
| 38 |
+
repo = Path(__file__).resolve().parents[4]
|
| 39 |
+
return repo / "backend" / "data" / "expert_state.sqlite"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def configure(path: Optional[os.PathLike] = None) -> Path:
|
| 43 |
+
"""Point the store at ``path`` (tests), or at the default. Creates the schema."""
|
| 44 |
+
global _PATH, _READY
|
| 45 |
+
with _LOCK:
|
| 46 |
+
_PATH = Path(path) if path is not None else _default_path()
|
| 47 |
+
_READY = False
|
| 48 |
+
try:
|
| 49 |
+
_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
_init()
|
| 51 |
+
except Exception as exc: # noqa: BLE001
|
| 52 |
+
fallback = Path(tempfile.gettempdir()) / "homepilot-expert-state.sqlite"
|
| 53 |
+
logger.warning("expert state store at %s unusable (%s); using %s", _PATH, exc, fallback)
|
| 54 |
+
_PATH = fallback
|
| 55 |
+
_init()
|
| 56 |
+
return _PATH
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def path() -> Path:
|
| 60 |
+
if _PATH is None:
|
| 61 |
+
configure()
|
| 62 |
+
return _PATH # type: ignore[return-value]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _connect() -> sqlite3.Connection:
|
| 66 |
+
con = sqlite3.connect(str(path()), timeout=10, check_same_thread=False)
|
| 67 |
+
con.execute("PRAGMA journal_mode=WAL")
|
| 68 |
+
return con
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _init() -> None:
|
| 72 |
+
global _READY
|
| 73 |
+
with sqlite3.connect(str(_PATH), timeout=10) as con:
|
| 74 |
+
con.execute("CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL, expires REAL)")
|
| 75 |
+
con.execute(
|
| 76 |
+
"CREATE TABLE IF NOT EXISTS deep_jobs(id TEXT PRIMARY KEY, created REAL NOT NULL, updated REAL NOT NULL, "
|
| 77 |
+
"status TEXT NOT NULL, snapshot TEXT NOT NULL)"
|
| 78 |
+
)
|
| 79 |
+
con.execute(
|
| 80 |
+
"CREATE TABLE IF NOT EXISTS deep_events(job_id TEXT NOT NULL, seq INTEGER NOT NULL, event TEXT NOT NULL, "
|
| 81 |
+
"PRIMARY KEY(job_id, seq))"
|
| 82 |
+
)
|
| 83 |
+
_READY = True
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _ensure() -> None:
|
| 87 |
+
if not _READY:
|
| 88 |
+
configure(_PATH)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ββ key/value with expiry ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
def kv_get(key: str) -> Optional[Dict[str, Any]]:
|
| 94 |
+
with _LOCK:
|
| 95 |
+
_ensure()
|
| 96 |
+
try:
|
| 97 |
+
with _connect() as con:
|
| 98 |
+
row = con.execute("SELECT value, expires FROM kv WHERE key = ?", (key,)).fetchone()
|
| 99 |
+
except sqlite3.Error as exc:
|
| 100 |
+
logger.debug("kv_get %s failed: %s", key, exc)
|
| 101 |
+
return None
|
| 102 |
+
if not row:
|
| 103 |
+
return None
|
| 104 |
+
value, expires = row
|
| 105 |
+
if expires is not None and expires < time.time():
|
| 106 |
+
kv_delete(key)
|
| 107 |
+
return None
|
| 108 |
+
try:
|
| 109 |
+
data = json.loads(value)
|
| 110 |
+
return data if isinstance(data, dict) else None
|
| 111 |
+
except json.JSONDecodeError:
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def kv_set(key: str, value: Dict[str, Any], ttl_s: Optional[float] = None) -> None:
|
| 116 |
+
expires = time.time() + ttl_s if ttl_s else None
|
| 117 |
+
with _LOCK:
|
| 118 |
+
_ensure()
|
| 119 |
+
try:
|
| 120 |
+
with _connect() as con:
|
| 121 |
+
con.execute("INSERT OR REPLACE INTO kv(key, value, expires) VALUES (?, ?, ?)",
|
| 122 |
+
(key, json.dumps(value, ensure_ascii=False), expires))
|
| 123 |
+
except sqlite3.Error as exc:
|
| 124 |
+
logger.debug("kv_set %s failed: %s", key, exc)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def kv_delete(key: str) -> None:
|
| 128 |
+
with _LOCK:
|
| 129 |
+
_ensure()
|
| 130 |
+
try:
|
| 131 |
+
with _connect() as con:
|
| 132 |
+
con.execute("DELETE FROM kv WHERE key = ?", (key,))
|
| 133 |
+
except sqlite3.Error:
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ββ Deep research jobs βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
def job_save(job_id: str, status: str, snapshot: Dict[str, Any], created: Optional[float] = None) -> None:
|
| 140 |
+
now = time.time()
|
| 141 |
+
with _LOCK:
|
| 142 |
+
_ensure()
|
| 143 |
+
try:
|
| 144 |
+
with _connect() as con:
|
| 145 |
+
con.execute(
|
| 146 |
+
"INSERT INTO deep_jobs(id, created, updated, status, snapshot) VALUES (?, ?, ?, ?, ?) "
|
| 147 |
+
"ON CONFLICT(id) DO UPDATE SET updated = excluded.updated, status = excluded.status, snapshot = excluded.snapshot",
|
| 148 |
+
(job_id, created or now, now, status, json.dumps(snapshot, ensure_ascii=False)),
|
| 149 |
+
)
|
| 150 |
+
except sqlite3.Error as exc:
|
| 151 |
+
logger.debug("job_save %s failed: %s", job_id, exc)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def job_load(job_id: str) -> Optional[Dict[str, Any]]:
|
| 155 |
+
with _LOCK:
|
| 156 |
+
_ensure()
|
| 157 |
+
try:
|
| 158 |
+
with _connect() as con:
|
| 159 |
+
row = con.execute("SELECT created, updated, status, snapshot FROM deep_jobs WHERE id = ?", (job_id,)).fetchone()
|
| 160 |
+
except sqlite3.Error:
|
| 161 |
+
return None
|
| 162 |
+
if not row:
|
| 163 |
+
return None
|
| 164 |
+
created, updated, status, snapshot = row
|
| 165 |
+
try:
|
| 166 |
+
data = json.loads(snapshot)
|
| 167 |
+
except json.JSONDecodeError:
|
| 168 |
+
data = {}
|
| 169 |
+
return {"created": created, "updated": updated, "status": status, "snapshot": data}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def jobs_unfinished() -> List[str]:
|
| 173 |
+
with _LOCK:
|
| 174 |
+
_ensure()
|
| 175 |
+
try:
|
| 176 |
+
with _connect() as con:
|
| 177 |
+
rows = con.execute(
|
| 178 |
+
"SELECT id FROM deep_jobs WHERE status NOT IN ('done', 'failed', 'cancelled')"
|
| 179 |
+
).fetchall()
|
| 180 |
+
except sqlite3.Error:
|
| 181 |
+
return []
|
| 182 |
+
return [r[0] for r in rows]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def event_append(job_id: str, seq: int, event: Dict[str, Any]) -> None:
|
| 186 |
+
with _LOCK:
|
| 187 |
+
_ensure()
|
| 188 |
+
try:
|
| 189 |
+
with _connect() as con:
|
| 190 |
+
con.execute("INSERT OR REPLACE INTO deep_events(job_id, seq, event) VALUES (?, ?, ?)",
|
| 191 |
+
(job_id, seq, json.dumps(event, ensure_ascii=False)))
|
| 192 |
+
except sqlite3.Error as exc:
|
| 193 |
+
logger.debug("event_append %s/%s failed: %s", job_id, seq, exc)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def events_after(job_id: str, after: int = 0) -> List[Dict[str, Any]]:
|
| 197 |
+
with _LOCK:
|
| 198 |
+
_ensure()
|
| 199 |
+
try:
|
| 200 |
+
with _connect() as con:
|
| 201 |
+
rows = con.execute(
|
| 202 |
+
"SELECT event FROM deep_events WHERE job_id = ? AND seq > ? ORDER BY seq", (job_id, after)
|
| 203 |
+
).fetchall()
|
| 204 |
+
except sqlite3.Error:
|
| 205 |
+
return []
|
| 206 |
+
out: List[Dict[str, Any]] = []
|
| 207 |
+
for (raw,) in rows:
|
| 208 |
+
try:
|
| 209 |
+
out.append(json.loads(raw))
|
| 210 |
+
except json.JSONDecodeError:
|
| 211 |
+
continue
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def jobs_prune(older_than_s: float) -> int:
|
| 216 |
+
cutoff = time.time() - older_than_s
|
| 217 |
+
with _LOCK:
|
| 218 |
+
_ensure()
|
| 219 |
+
try:
|
| 220 |
+
with _connect() as con:
|
| 221 |
+
ids = [r[0] for r in con.execute("SELECT id FROM deep_jobs WHERE updated < ?", (cutoff,)).fetchall()]
|
| 222 |
+
for job_id in ids:
|
| 223 |
+
con.execute("DELETE FROM deep_events WHERE job_id = ?", (job_id,))
|
| 224 |
+
con.execute("DELETE FROM deep_jobs WHERE id = ?", (job_id,))
|
| 225 |
+
return len(ids)
|
| 226 |
+
except sqlite3.Error:
|
| 227 |
+
return 0
|
expert/backend/app/expert/target.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/target.py
|
| 2 |
+
"""The chat model the user picked, for the length of one Expert request.
|
| 3 |
+
|
| 4 |
+
Expert used to choose its own model: a request said ``provider: "auto"`` and Expert answered
|
| 5 |
+
from Ollama's default β even after the user had connected an OllaBridge gateway in Models and
|
| 6 |
+
picked ``openwebui/internal-gemma4-31b`` for chat. The reasoning modes (Fast, Think, Heavy)
|
| 7 |
+
therefore talked to a different model from the rest of the chat, and when Ollama was busy the
|
| 8 |
+
user saw ``500 Internal Server Error for url 'http://localhost:11434/api/chat'`` for a model
|
| 9 |
+
they never chose.
|
| 10 |
+
|
| 11 |
+
The client now sends the chat selection as ``target`` (provider, base URL, model, key). It is
|
| 12 |
+
held in a context variable rather than threaded through every pipeline step, so Fast, Think,
|
| 13 |
+
Heavy and their streaming forms all use it without each learning a new parameter β the steps
|
| 14 |
+
run inside the request's task, which carries the variable with it.
|
| 15 |
+
|
| 16 |
+
``local`` means "the user's chosen model" whenever a target is set. A target is never replaced
|
| 17 |
+
by a cloud provider behind the user's back: with one set, ``auto`` routes to it rather than to
|
| 18 |
+
Groq or Gemini on a complex question.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from contextlib import contextmanager
|
| 23 |
+
from contextvars import ContextVar
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from typing import Any, Iterator, Optional
|
| 26 |
+
|
| 27 |
+
#: Providers answered through Ollama's own API; everything else is OpenAI-compatible.
|
| 28 |
+
OLLAMA_PROVIDERS = ("", "ollama")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class ChatTarget:
|
| 33 |
+
provider: str
|
| 34 |
+
base_url: str
|
| 35 |
+
model: str
|
| 36 |
+
api_key: str = ""
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def is_ollama(self) -> bool:
|
| 40 |
+
return self.provider in OLLAMA_PROVIDERS
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def label(self) -> str:
|
| 44 |
+
"""What a response reports as the provider that answered."""
|
| 45 |
+
return "local" if self.is_ollama else self.provider
|
| 46 |
+
|
| 47 |
+
def __repr__(self) -> str: # never print the key
|
| 48 |
+
return f"ChatTarget(provider={self.provider!r}, base_url={self.base_url!r}, model={self.model!r})"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
_CURRENT: ContextVar[Optional[ChatTarget]] = ContextVar("expert_chat_target", default=None)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def from_request(raw: Any) -> Optional[ChatTarget]:
|
| 55 |
+
"""A target from the request's ``target`` field, or ``None`` when it names nothing.
|
| 56 |
+
|
| 57 |
+
An Ollama target with no model and no URL is the same as no target: Expert's own defaults
|
| 58 |
+
already mean "the local Ollama".
|
| 59 |
+
"""
|
| 60 |
+
if raw is None:
|
| 61 |
+
return None
|
| 62 |
+
get = raw.get if isinstance(raw, dict) else (lambda k, d=None: getattr(raw, k, d))
|
| 63 |
+
provider = str(get("provider", "") or "").strip().lower()
|
| 64 |
+
base_url = str(get("base_url", "") or "").strip().rstrip("/")
|
| 65 |
+
model = str(get("model", "") or "").strip()
|
| 66 |
+
api_key = str(get("api_key", "") or "").strip()
|
| 67 |
+
if provider in OLLAMA_PROVIDERS and not model and not base_url:
|
| 68 |
+
return None
|
| 69 |
+
if provider in OLLAMA_PROVIDERS and base_url.lower().endswith("/v1"):
|
| 70 |
+
# "Ollama" with an OpenAI-style Base URL (OllaBridge's http://localhost:11435/v1, or
|
| 71 |
+
# Ollama's own /v1): that URL speaks the OpenAI API, not Ollama's β sending
|
| 72 |
+
# /api/chat there answered 404 and the chat said "Ollama rejected model β¦".
|
| 73 |
+
provider = "openai_compat"
|
| 74 |
+
if provider not in OLLAMA_PROVIDERS and not base_url:
|
| 75 |
+
# An OpenAI-compatible provider with nowhere to send it cannot be honoured; Expert's
|
| 76 |
+
# defaults are a better answer than an error about a URL the user never typed.
|
| 77 |
+
return None
|
| 78 |
+
return ChatTarget(provider=provider, base_url=base_url, model=model, api_key=api_key)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def current() -> Optional[ChatTarget]:
|
| 82 |
+
return _CURRENT.get()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@contextmanager
|
| 86 |
+
def using(target: Optional[ChatTarget]) -> Iterator[Optional[ChatTarget]]:
|
| 87 |
+
"""Make ``target`` the chat target for everything awaited inside the block."""
|
| 88 |
+
token = _CURRENT.set(target)
|
| 89 |
+
try:
|
| 90 |
+
yield target
|
| 91 |
+
finally:
|
| 92 |
+
_CURRENT.reset(token)
|
expert/backend/app/expert/thinking.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# expert/thinking.py
|
| 2 |
+
# Multi-step chain-of-thought pipeline for Expert "think" mode.
|
| 3 |
+
#
|
| 4 |
+
# Instead of a single prompt β response, this pipeline runs 3 sequential
|
| 5 |
+
# LLM calls that mirror how a human expert actually solves hard problems:
|
| 6 |
+
#
|
| 7 |
+
# Step 1 β ANALYZE: Decompose the problem, identify what's being asked
|
| 8 |
+
# Step 2 β PLAN: Create a step-by-step strategy
|
| 9 |
+
# Step 3 β SOLVE: Execute the plan into a final answer
|
| 10 |
+
#
|
| 11 |
+
# Each step is streamed individually so the UI can show progress.
|
| 12 |
+
# The pipeline reuses the same dispatch layer β provider-agnostic.
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT
|
| 19 |
+
from .router import (
|
| 20 |
+
dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName,
|
| 21 |
+
)
|
| 22 |
+
from .tool_loop import run_with_tools, stream_with_tools
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("expert.thinking")
|
| 25 |
+
|
| 26 |
+
# Standard effort: the analysis is written (and shown), then answered from β no separate plan.
|
| 27 |
+
_SOLVE_FROM_ANALYSIS_PROMPT = """You are an expert solver. Using the analysis below, give a final, complete answer to the original problem. Speak to the user directly; do not repeat the analysis.
|
| 28 |
+
|
| 29 |
+
Original problem:
|
| 30 |
+
{input}
|
| 31 |
+
|
| 32 |
+
Analysis:
|
| 33 |
+
{analysis}
|
| 34 |
+
|
| 35 |
+
Final answer:"""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _solve_prompt(query: str, *, plan: bool, plan_text: str, analyze: bool, analysis: str) -> str:
|
| 39 |
+
"""The solve step's instruction for the stages that ran before it."""
|
| 40 |
+
if plan:
|
| 41 |
+
return _SOLVE_PROMPT.format(input=query, plan=plan_text)
|
| 42 |
+
if analyze and analysis.strip():
|
| 43 |
+
return _SOLVE_FROM_ANALYSIS_PROMPT.format(input=query, analysis=analysis.strip())
|
| 44 |
+
return _SOLVE_DIRECT_PROMPT.format(input=query)
|
| 45 |
+
|
| 46 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
# Step prompts
|
| 48 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
_ANALYZE_PROMPT = """\
|
| 51 |
+
You are an analytical reasoning engine. Your job is ONLY to decompose and analyze the following problem.
|
| 52 |
+
|
| 53 |
+
Do NOT attempt to solve it yet. Instead:
|
| 54 |
+
1. Identify what is actually being asked (restate it precisely)
|
| 55 |
+
2. Identify the domain(s) involved
|
| 56 |
+
3. Identify what information or reasoning is needed
|
| 57 |
+
4. Flag any ambiguities or unstated assumptions
|
| 58 |
+
|
| 59 |
+
Problem:
|
| 60 |
+
{input}
|
| 61 |
+
|
| 62 |
+
Analysis:"""
|
| 63 |
+
|
| 64 |
+
_PLAN_PROMPT = """\
|
| 65 |
+
You are a strategic planner. Based on the following analysis, create a clear step-by-step plan to solve the original problem.
|
| 66 |
+
|
| 67 |
+
Each step should be concrete and actionable. Do NOT solve yet β only plan.
|
| 68 |
+
|
| 69 |
+
Analysis:
|
| 70 |
+
{analysis}
|
| 71 |
+
|
| 72 |
+
Step-by-step plan:"""
|
| 73 |
+
|
| 74 |
+
_SOLVE_PROMPT = """\
|
| 75 |
+
You are an expert solver. Execute the following plan to produce a final, complete answer to the original problem.
|
| 76 |
+
|
| 77 |
+
Be thorough, accurate, and clear. Show your work where relevant.
|
| 78 |
+
|
| 79 |
+
Original problem: {input}
|
| 80 |
+
|
| 81 |
+
Plan to execute:
|
| 82 |
+
{plan}
|
| 83 |
+
|
| 84 |
+
Final answer:"""
|
| 85 |
+
|
| 86 |
+
_SOLVE_DIRECT_PROMPT = """\
|
| 87 |
+
You are an expert solver. Produce a complete, accurate answer to the following problem; show your work where it helps.
|
| 88 |
+
|
| 89 |
+
Problem: {input}
|
| 90 |
+
|
| 91 |
+
Final answer:"""
|
| 92 |
+
|
| 93 |
+
_CRITIQUE_PROMPT = """\
|
| 94 |
+
You are a rigorous critic. Review the following answer and check for:
|
| 95 |
+
- Factual errors
|
| 96 |
+
- Logical gaps
|
| 97 |
+
- Missing edge cases
|
| 98 |
+
- Clarity issues
|
| 99 |
+
|
| 100 |
+
If the answer is correct and complete, say so briefly. Otherwise, provide a corrected version.
|
| 101 |
+
|
| 102 |
+
Answer to review:
|
| 103 |
+
{solution}
|
| 104 |
+
|
| 105 |
+
Review:"""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
# Sync thinking pipeline (returns full result + steps for inspection)
|
| 110 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
|
| 112 |
+
async def think(
|
| 113 |
+
query: str,
|
| 114 |
+
provider: ProviderName = "auto",
|
| 115 |
+
*,
|
| 116 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 117 |
+
model: Optional[str] = None,
|
| 118 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 119 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 120 |
+
with_critique: bool = False,
|
| 121 |
+
system_prompt: Optional[str] = None,
|
| 122 |
+
forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
|
| 123 |
+
plan: bool = True,
|
| 124 |
+
analyze: bool = False,
|
| 125 |
+
registry: Any = None,
|
| 126 |
+
tool_rounds: Optional[int] = None,
|
| 127 |
+
) -> Dict[str, Any]:
|
| 128 |
+
"""
|
| 129 |
+
Run the analyze β plan β solve pipeline, or solve alone when ``plan`` is False (a request
|
| 130 |
+
below the planning threshold, see policies.effort_for).
|
| 131 |
+
Returns a dict with each step's output plus the final answer.
|
| 132 |
+
``system_prompt`` (the persona's or the user's instructions) replaces the Expert default;
|
| 133 |
+
``forced_tools`` run before the solve step asks the model anything; ``registry`` is the
|
| 134 |
+
request's tool set (the shared one when None).
|
| 135 |
+
"""
|
| 136 |
+
base = system_prompt or EXPERT_SYSTEM_PROMPT
|
| 137 |
+
|
| 138 |
+
# Each step gets the recent conversation ahead of its instruction (see step_messages).
|
| 139 |
+
def _msg(prompt: str) -> List[Dict[str, str]]:
|
| 140 |
+
return step_messages(prompt, system_prompt=base, history=history)
|
| 141 |
+
|
| 142 |
+
kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 143 |
+
notices: List[str] = []
|
| 144 |
+
fallback_applied = False
|
| 145 |
+
analysis = plan_text = ""
|
| 146 |
+
|
| 147 |
+
if plan or analyze:
|
| 148 |
+
# Step 1: Analyze β the visible reasoning (also alone, at standard effort)
|
| 149 |
+
logger.debug("Thinking step 1: analyze")
|
| 150 |
+
raw_analysis = await dispatch(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw)
|
| 151 |
+
analysis = _extract(raw_analysis)
|
| 152 |
+
notices.extend(_collect_notice(raw_analysis))
|
| 153 |
+
fallback_applied = _fallback(raw_analysis)
|
| 154 |
+
|
| 155 |
+
if plan:
|
| 156 |
+
# Step 2: Plan
|
| 157 |
+
logger.debug("Thinking step 2: plan")
|
| 158 |
+
raw_plan = await dispatch(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw)
|
| 159 |
+
plan_text = _extract(raw_plan)
|
| 160 |
+
notices.extend(_collect_notice(raw_plan))
|
| 161 |
+
fallback_applied = fallback_applied or _fallback(raw_plan)
|
| 162 |
+
|
| 163 |
+
# Step 3: Solve β with the per-turn tool loop, so the answer can check facts
|
| 164 |
+
logger.debug("Thinking step 3: solve")
|
| 165 |
+
solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis)
|
| 166 |
+
solved = await run_with_tools(
|
| 167 |
+
_msg(solve_prompt), provider, call=dispatch, forced=forced_tools, registry=registry,
|
| 168 |
+
max_rounds=tool_rounds, **kw
|
| 169 |
+
)
|
| 170 |
+
solution = solved.get("content") or ""
|
| 171 |
+
notices.extend(solved.get("notices", []))
|
| 172 |
+
fallback_applied = fallback_applied or bool(solved.get("fallback_applied"))
|
| 173 |
+
|
| 174 |
+
# Step 4 (optional): Self-critique
|
| 175 |
+
critique = None
|
| 176 |
+
if with_critique:
|
| 177 |
+
logger.debug("Thinking step 4: critique")
|
| 178 |
+
raw_critique = await dispatch(
|
| 179 |
+
_msg(_CRITIQUE_PROMPT.format(solution=solution)), provider, **kw
|
| 180 |
+
)
|
| 181 |
+
critique = _extract(raw_critique)
|
| 182 |
+
notices.extend(_collect_notice(raw_critique))
|
| 183 |
+
fallback_applied = fallback_applied or _fallback(raw_critique)
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"final_answer": solution,
|
| 187 |
+
"steps": {
|
| 188 |
+
**({"analysis": analysis} if (plan or analyze) else {}),
|
| 189 |
+
**({"plan": plan_text} if plan else {}),
|
| 190 |
+
"solution": solution,
|
| 191 |
+
**({"critique": critique} if critique else {}),
|
| 192 |
+
},
|
| 193 |
+
"planned": plan,
|
| 194 |
+
"provider": provider,
|
| 195 |
+
"model": model,
|
| 196 |
+
"pipeline": "think",
|
| 197 |
+
"fallback_applied": fallback_applied,
|
| 198 |
+
"notices": _dedupe_notices(notices),
|
| 199 |
+
"tool_trace": solved.get("tool_trace", []),
|
| 200 |
+
"sources": solved.get("sources", []),
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
# Streaming thinking pipeline β yields step labels + tokens
|
| 206 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 207 |
+
|
| 208 |
+
async def stream_think(
|
| 209 |
+
query: str,
|
| 210 |
+
provider: ProviderName = "auto",
|
| 211 |
+
*,
|
| 212 |
+
history: Optional[List[Dict[str, str]]] = None,
|
| 213 |
+
model: Optional[str] = None,
|
| 214 |
+
temperature: float = EXPERT_TEMPERATURE,
|
| 215 |
+
max_tokens: int = EXPERT_MAX_TOKENS,
|
| 216 |
+
with_critique: bool = False,
|
| 217 |
+
system_prompt: Optional[str] = None,
|
| 218 |
+
forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
|
| 219 |
+
plan: bool = True,
|
| 220 |
+
analyze: bool = False,
|
| 221 |
+
registry: Any = None,
|
| 222 |
+
tool_rounds: Optional[int] = None,
|
| 223 |
+
) -> AsyncIterator[Dict[str, Any]]:
|
| 224 |
+
"""
|
| 225 |
+
Stream the thinking pipeline step by step (solve alone when ``plan`` is False).
|
| 226 |
+
Yields dicts with type='step_start'|'token'|'step_end'|'final'|'done', plus the tool
|
| 227 |
+
loop's events (tool_start, tool_end, sources, reset, notice) tagged with their step.
|
| 228 |
+
A step_start carries role='work' or role='answer' (the step whose text is the reply).
|
| 229 |
+
|
| 230 |
+
The route /v1/expert/stream uses this when thinking_mode='think'.
|
| 231 |
+
The UI can render each step in a collapsible panel.
|
| 232 |
+
"""
|
| 233 |
+
base = system_prompt or EXPERT_SYSTEM_PROMPT
|
| 234 |
+
|
| 235 |
+
def _msg(prompt: str) -> List[Dict[str, str]]:
|
| 236 |
+
return step_messages(prompt, system_prompt=base, history=history)
|
| 237 |
+
|
| 238 |
+
kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
|
| 239 |
+
analysis = plan_text = ""
|
| 240 |
+
|
| 241 |
+
if plan or analyze:
|
| 242 |
+
# ββ Step 1: Analyze β the visible reasoning, shown as ChatGPT and Claude show theirs
|
| 243 |
+
yield {"type": "step_start", "step": "analyze", "label": "π Analyzing problemβ¦", "role": "work"}
|
| 244 |
+
analysis_chunks: List[str] = []
|
| 245 |
+
async for chunk in dispatch_stream(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw):
|
| 246 |
+
analysis_chunks.append(chunk)
|
| 247 |
+
yield {"type": "token", "step": "analyze", "content": chunk}
|
| 248 |
+
analysis = "".join(analysis_chunks)
|
| 249 |
+
yield {"type": "step_end", "step": "analyze"}
|
| 250 |
+
|
| 251 |
+
if plan:
|
| 252 |
+
# ββ Step 2: Plan βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
+
yield {"type": "step_start", "step": "plan", "label": "π Building planβ¦", "role": "work"}
|
| 254 |
+
plan_chunks: List[str] = []
|
| 255 |
+
async for chunk in dispatch_stream(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw):
|
| 256 |
+
plan_chunks.append(chunk)
|
| 257 |
+
yield {"type": "token", "step": "plan", "content": chunk}
|
| 258 |
+
plan_text = "".join(plan_chunks)
|
| 259 |
+
yield {"type": "step_end", "step": "plan"}
|
| 260 |
+
|
| 261 |
+
# ββ Step 3: Solve β with the per-turn tool loop βββββββββββββββββββββββββ
|
| 262 |
+
yield {"type": "step_start", "step": "solve", "label": "β‘ Solvingβ¦" if plan else "β‘ Answeringβ¦", "role": "answer"}
|
| 263 |
+
solution_text = ""
|
| 264 |
+
solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis)
|
| 265 |
+
async for ev in stream_with_tools(
|
| 266 |
+
_msg(solve_prompt), provider,
|
| 267 |
+
stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, **kw
|
| 268 |
+
):
|
| 269 |
+
if ev["type"] == "token":
|
| 270 |
+
yield {"type": "token", "step": "solve", "content": ev["content"]}
|
| 271 |
+
elif ev["type"] == "done":
|
| 272 |
+
solution_text = ev.get("content") or ""
|
| 273 |
+
else:
|
| 274 |
+
yield {**ev, "step": "solve"}
|
| 275 |
+
yield {"type": "step_end", "step": "solve"}
|
| 276 |
+
|
| 277 |
+
# ββ Step 4 (optional): Critique ββββββββββββββββββββββββββββββββββββββββββ
|
| 278 |
+
if with_critique:
|
| 279 |
+
yield {"type": "step_start", "step": "critique", "label": "π Self-reviewingβ¦", "role": "work"}
|
| 280 |
+
async for chunk in dispatch_stream(
|
| 281 |
+
_msg(_CRITIQUE_PROMPT.format(solution=solution_text)), provider, **kw
|
| 282 |
+
):
|
| 283 |
+
yield {"type": "token", "step": "critique", "content": chunk}
|
| 284 |
+
yield {"type": "step_end", "step": "critique"}
|
| 285 |
+
|
| 286 |
+
yield {"type": "final", "content": solution_text}
|
| 287 |
+
yield {"type": "done"}
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
# Helper
|
| 292 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
|
| 294 |
+
def _extract(raw: Dict[str, Any]) -> str:
|
| 295 |
+
try:
|
| 296 |
+
return raw["choices"][0]["message"]["content"] or ""
|
| 297 |
+
except (KeyError, IndexError):
|
| 298 |
+
return ""
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _fallback(raw: Dict[str, Any]) -> bool:
|
| 302 |
+
return bool(extract_dispatch_meta(raw).get("fallback_applied"))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _collect_notice(raw: Dict[str, Any]) -> List[str]:
|
| 306 |
+
notice = extract_dispatch_meta(raw).get("notice")
|
| 307 |
+
return [notice] if isinstance(notice, str) and notice else []
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _dedupe_notices(notices: List[str]) -> List[str]:
|
| 311 |
+
return list(dict.fromkeys(notices))
|