diff --git a/.gitattributes b/.gitattributes index 76052aac2d4603ae9603511030e879ef8fcee048..4f99c9c9a4f75d9403fa11aa8b3abfef7a51c0d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,9 @@ -*.jpeg filter=lfs diff=lfs merge=lfs -text *.webp filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text *.woff2 filter=lfs diff=lfs merge=lfs -text *.ico filter=lfs diff=lfs merge=lfs -text *.hpersona filter=lfs diff=lfs merge=lfs -text -*.jpg filter=lfs diff=lfs merge=lfs -text -*.woff filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 6b6792caacf040ac6122a51f5afd2210a475c4bd..a8f915777ee11386518829be45d103dc3714834a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,15 @@ COPY backend/app ./app # Ensure __init__.py exists (created by deploy script) RUN touch ./app/__init__.py +# The model installer (POST /models/install) runs this for ComfyUI and Civitai downloads; +# the backend finds the copy next to its package (app/download_script.py). +COPY scripts/download.py ./scripts/download.py + +# Expert — the chat's Fast / Expert / Heavy path (/v1/expert/*). The backend looks for it +# next to its package when there is no repo around (app/main.py). Without it every chat +# POST fell through to the frontend catch-all below and came back HTTP 405. +COPY expert/backend/app/expert ./expert + # Community persona samples COPY community/sample ./community/sample diff --git a/backend/app/asset_registry.py b/backend/app/asset_registry.py index 1648bb3df46df3e7064033c3dde4367e043aa49d..3f077f084e0ade4eafd78e9f29b32aba02b9a517 100644 --- a/backend/app/asset_registry.py +++ b/backend/app/asset_registry.py @@ -20,7 +20,9 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional -from .config import UPLOAD_DIR, SQLITE_PATH +from .config import UPLOAD_DIR, SQLITE_PATH # noqa: F401 (UPLOAD_DIR: kept for importers) +from .media_storage import backend_name as _storage_backend_name +from .media_storage import upload_root as _storage_root # Known filename patterns → feature classification _FEATURE_PATTERNS = { @@ -52,9 +54,7 @@ def _db() -> sqlite3.Connection: def _upload_root() -> Path: - p = Path(UPLOAD_DIR) - if not p.is_absolute(): - p = Path(__file__).resolve().parents[1] / "data" / "uploads" + p = _storage_root() p.mkdir(parents=True, exist_ok=True) return p @@ -106,19 +106,25 @@ def register_asset( sha256: str = "", width: int = 0, height: int = 0, + storage_backend: str = "", ) -> str: """ Register a new asset. Returns the asset_id. - If an asset with the same storage_key already exists, returns its ID. + If an asset with the same (storage_backend, storage_key) already exists, returns its ID. + + ``storage_backend`` is the MediaStorage backend holding the bytes — the configured one + when not given (``local`` today). The table always had the column; the queries assumed + 'local', which is what a second backend trips over. """ + backend = (storage_backend or _storage_backend_name()).strip().lower() _ensure_assets_table() con = _db() cur = con.cursor() # Check if already exists cur.execute( - "SELECT id FROM assets WHERE storage_backend = 'local' AND storage_key = ?", - (storage_key,), + "SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?", + (backend, storage_key), ) row = cur.fetchone() if row: @@ -138,9 +144,9 @@ def register_asset( id, kind, mime, storage_backend, storage_key, size_bytes, width, height, sha256, origin, source_hint, feature, project_id, user_id - ) VALUES (?,?,?,'local',?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, - (asset_id, kind, mime or "", storage_key, + (asset_id, kind, mime or "", backend, storage_key, size_bytes, width, height, sha256 or "", origin, source_hint or "", feature or "", project_id or "", user_id or ""), @@ -161,23 +167,25 @@ def get_asset(asset_id: str) -> Optional[Dict[str, Any]]: return dict(row) if row else None -def find_asset_id_by_storage_key(storage_key: str) -> Optional[str]: - """Reverse-lookup: storage_key → asset_id. +def find_asset_id_by_storage_key(storage_key: str, storage_backend: str = "") -> Optional[str]: + """Reverse-lookup: (storage_backend, storage_key) → asset_id. Used by the persona-library link phase to backfill the ``registry_asset_id`` field on older library rows that were saved before that field existed. The ``UNIQUE(storage_backend, storage_key)`` constraint on the assets table guarantees at - most one row per storage_key, so this is a safe O(1) lookup. + most one row per key and backend, so this is a safe O(1) lookup. + The configured backend when ``storage_backend`` is not given. """ if not storage_key: return None + backend = (storage_backend or _storage_backend_name()).strip().lower() _ensure_assets_table() con = _db() cur = con.cursor() cur.execute( - "SELECT id FROM assets WHERE storage_backend = 'local' AND storage_key = ?", - (storage_key,), + "SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?", + (backend, storage_key), ) row = cur.fetchone() con.close() @@ -412,10 +420,14 @@ def register_comfy_output( project_id: str = "", user_id: str = "", workflow_name: str = "", + storage_backend: str = "local", + size_bytes: Optional[int] = None, ) -> str: """ Register a file that was downloaded from ComfyUI. - Call this right after saving the file to UPLOAD_DIR. + Call this right after saving the file to media storage — ``dest_path`` is where it would + be on this disk (the key is its path relative to the upload root); ``storage_backend`` + and ``size_bytes`` say where it actually went when that is not the local disk. Returns the asset_id. """ @@ -426,7 +438,10 @@ def register_comfy_output( except ValueError: rel_path = str(abs_path) - size = abs_path.stat().st_size if abs_path.exists() else 0 + if size_bytes is not None: + size = int(size_bytes) + else: + size = abs_path.stat().st_size if abs_path.exists() else 0 ext = abs_path.suffix.lower() kind = "video" if ext in VIDEO_EXTS else "image" mime = mimetypes.guess_type(str(abs_path))[0] or "" @@ -444,4 +459,5 @@ def register_comfy_output( feature=feature, project_id=project_id, user_id=user_id, + storage_backend=storage_backend or "local", ) diff --git a/backend/app/background.py b/backend/app/background.py index bc824763f24edda22a09d4c64dffd7e2cac7f6ce..c278ad95251adef30001e4b21e2faf11ceb13be0 100644 --- a/backend/app/background.py +++ b/backend/app/background.py @@ -128,15 +128,11 @@ def _get_image_and_size(url: str) -> Tuple[bytes, Tuple[int, int]]: def _save_image_to_uploads(img_bytes: bytes, prefix: str = "bg", ext: str = "png") -> str: - """Save image bytes to uploads directory and return the URL path.""" - filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{ext}" - upload_path = Path(UPLOAD_DIR) - upload_path.mkdir(parents=True, exist_ok=True) - - file_path = upload_path / filename - with open(file_path, 'wb') as f: - f.write(img_bytes) + """Save image bytes to media storage (a flat key, as before) and return the URL path.""" + from .media_storage import get_storage + filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{ext}" + get_storage().put(filename, img_bytes) return f"/files/{filename}" diff --git a/backend/app/comfy.py b/backend/app/comfy.py index d7de3f5e47ced433841378472ff4a1232279da4f..3f207751c035f64d1a9fbe6934569148383a22a6 100644 --- a/backend/app/comfy.py +++ b/backend/app/comfy.py @@ -469,23 +469,10 @@ def _get_local_file_path(url: str) -> Path | None: if not file_ref or '..' in file_ref: return None - # 1) Try direct file in UPLOAD_DIR (legacy flat layout) - local_path = Path(UPLOAD_DIR) / file_ref - if local_path.exists(): - return local_path - - # 2) Try resolving as an asset_id via the file_assets DB table - try: - from .files import get_asset, _upload_root - asset = get_asset(file_ref) - if asset and asset.get("rel_path"): - asset_path = _upload_root() / asset["rel_path"] - if asset_path.exists(): - return asset_path - except Exception: - pass - - return None + # A flat key or a file_assets id — on this disk, or fetched from the configured + # storage into a local cache when the media lives elsewhere (files.py). + from .files import resolve_local_file + return resolve_local_file(file_ref) except Exception: return None @@ -602,16 +589,10 @@ def _preprocess_image_paths(variables: Dict[str, Any]) -> Dict[str, Any]: elif value.startswith('/files/'): try: file_ref = value.replace('/files/', '', 1).split('?')[0] - # Try direct file first, then asset DB lookup - local_path = Path(UPLOAD_DIR) / file_ref - if not local_path.exists(): - try: - from .files import get_asset, _upload_root - asset = get_asset(file_ref) - if asset and asset.get("rel_path"): - local_path = _upload_root() / asset["rel_path"] - except Exception: - pass + # A flat key or a file_assets id, on this disk or fetched from the + # configured storage (files.py). + from .files import resolve_local_file + local_path = resolve_local_file(file_ref) or (Path(UPLOAD_DIR) / file_ref) if local_path.exists(): # Copy to ComfyUI's input directory diff --git a/backend/app/download_script.py b/backend/app/download_script.py new file mode 100644 index 0000000000000000000000000000000000000000..8a960084d9a7bad1f90ed3f8f5ef1e37a6c10407 --- /dev/null +++ b/backend/app/download_script.py @@ -0,0 +1,32 @@ +""" +Where ``scripts/download.py`` is. + +The model installer (POST /models/install, Creator Studio bundles) runs the repo's downloader +as a subprocess. In the repo it is two levels above this package; a packaged image has no +repo around it — the Hugging Face Space copies ``backend/app`` to ``/app/app``, so the old +``parents[2] / "scripts"`` was ``/scripts/download.py`` and every install there failed with +"Download script not found", Ollama pulls included. An image ships a copy next to the +package instead (``/app/scripts/download.py``), and ``HOMEPILOT_DOWNLOAD_SCRIPT`` names any +other location. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +_PACKAGE_DIR = Path(__file__).resolve().parent + + +def download_script(package_dir: Optional[Path] = None) -> Path: + """The downloader's path: the configured one, else the first that exists, else the repo's.""" + configured = (os.getenv("HOMEPILOT_DOWNLOAD_SCRIPT") or "").strip() + if configured: + return Path(configured).expanduser() + pkg = (package_dir or _PACKAGE_DIR).resolve() # /backend/app, or /app/app + candidates = [pkg.parents[1] / "scripts" / "download.py", # /scripts + pkg.parent / "scripts" / "download.py"] # /app/scripts (packaged image) + for path in candidates: + if path.is_file(): + return path + return candidates[0] diff --git a/backend/app/files.py b/backend/app/files.py index 7b3e6f661969f743d37671e43dd4b6df89d25a81..d3bef025e7b42fcc75e9d98b9b5c99dda0b4ff66 100644 --- a/backend/app/files.py +++ b/backend/app/files.py @@ -15,18 +15,24 @@ ADDITIVE ONLY — does not modify any existing module. """ from __future__ import annotations +import hashlib import mimetypes import os import sqlite3 +import time import uuid from pathlib import Path from typing import Any, Dict, Optional from fastapi import APIRouter, Cookie, Header, HTTPException, Query, UploadFile, File -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse -from .config import UPLOAD_DIR, MAX_UPLOAD_MB -from .storage import _get_db_path +from .config import UPLOAD_DIR, MAX_UPLOAD_MB # noqa: F401 (UPLOAD_DIR: kept for importers) +from . import media_links, media_ops, media_quota, media_validate +from .media_storage import (StorageKeyError, backend_name, get_storage, local_storage, normalize_key, + storage_for_read, storage_named) +from .media_storage import upload_root as _storage_root +from .storage import _get_db_path, ensure_file_assets_columns # Cache headers for served files — prevents image flickering on React re-renders. # Uploaded images are immutable (content-addressed), so long max-age is safe. @@ -46,31 +52,30 @@ def _db() -> sqlite3.Connection: def _upload_root() -> Path: - """Resolve the absolute upload root directory.""" - p = Path(UPLOAD_DIR) - if not p.is_absolute(): - p = Path(__file__).resolve().parents[1] / "data" / "uploads" + """The local upload root (media_storage.upload_root), created. The legacy path-based + routes below read this disk directly; everything registered goes through get_storage().""" + p = _storage_root() p.mkdir(parents=True, exist_ok=True) return p -def _ensure_user_dir(user_id: str, kind: str, project_id: str = "") -> Path: - """ - Create and return the per-user storage directory. - Layout: users//{uploads|images|projects/} - """ - root = _upload_root() / "users" / user_id +def _user_key(user_id: str, kind: str, project_id: str = "") -> str: + """Where a user's files go, as a storage-key prefix: + users//{uploads|images|projects/|misc}.""" if kind == "upload": - p = root / "uploads" + sub = "uploads" elif kind == "image": - p = root / "images" + sub = "images" elif kind == "project_asset": - if not project_id: - project_id = "unknown" - p = root / "projects" / project_id + sub = f"projects/{project_id or 'unknown'}" else: - p = root / "misc" + sub = "misc" + return f"users/{user_id}/{sub}" + +def _ensure_user_dir(user_id: str, kind: str, project_id: str = "") -> Path: + """The per-user directory on the local disk (kept for callers that still write paths).""" + p = _upload_root() / _user_key(user_id, kind, project_id) p.mkdir(parents=True, exist_ok=True) return p @@ -125,6 +130,30 @@ def _resolve_user( # Asset CRUD # --------------------------------------------------------------------------- +_backend_column_checked = False + + +def _ensure_backend_column(con: sqlite3.Connection) -> None: + """The storage_backend column is added at startup (storage.init_db); a database opened + without that — a test, a script — gets it here, once per process.""" + global _backend_column_checked + if not _backend_column_checked: + ensure_file_assets_columns(con) + _backend_column_checked = True + + +def _now() -> str: + """UTC, the way SQLite's CURRENT_TIMESTAMP writes it.""" + return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + + +def _store_for(backend: Optional[str], key: str): + """The storage holding ``key``: the one a registry row names, or — for a key with no row — + whichever has it (media_storage.storage_for_read). None when the row names a backend this + HomePilot is not configured for.""" + return storage_named(backend) if backend else storage_for_read(key) + + def insert_asset( user_id: str, kind: str, @@ -134,74 +163,103 @@ def insert_asset( original_name: str = "", project_id: str = "", conversation_id: str = "", + storage_backend: Optional[str] = None, + sha256: str = "", + visibility: str = "private", ) -> str: - """Register a file in file_assets. Returns the asset_id.""" + """Register a file in file_assets. Returns the asset_id. + + ``storage_backend`` is the media backend that holds the bytes — the configured one when + not given, which is where every writer in this module just put them. ``sha256`` is of + the bytes as written ('' when the caller did not hash them); ``visibility`` is + 'private' (owner, signed link or API key) or 'public' (anyone).""" asset_id = f"f_{uuid.uuid4().hex[:20]}" + backend = (storage_backend or get_storage().name).strip().lower() + visibility = "public" if visibility == "public" else "private" con = _db() + _ensure_backend_column(con) cur = con.cursor() cur.execute( """ INSERT INTO file_assets( id, user_id, kind, rel_path, mime, size_bytes, - original_name, project_id, conversation_id + original_name, project_id, conversation_id, storage_backend, sha256, visibility ) - VALUES (?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """, (asset_id, user_id, kind, rel_path, mime or "", int(size_bytes or 0), original_name or "", - project_id or "", conversation_id or ""), + project_id or "", conversation_id or "", backend, sha256 or "", visibility), ) con.commit() con.close() return asset_id -def get_asset(asset_id: str) -> Optional[Dict[str, Any]]: - """Look up a file asset by id.""" +def get_asset(asset_id: str, include_deleted: bool = False) -> Optional[Dict[str, Any]]: + """Look up a file asset by id. One in the trash (deleted_at set) is None unless asked for.""" con = _db() cur = con.cursor() cur.execute("SELECT * FROM file_assets WHERE id = ?", (asset_id,)) row = cur.fetchone() con.close() - return dict(row) if row else None + if not row: + return None + asset = dict(row) + if asset.get("deleted_at") and not include_deleted: + return None + return asset + + +def _remove_object(rel_path: str, backend: Optional[str] = None) -> bool: + """Remove the bytes behind a file_assets row, from the backend the row names. Immediate, + as before: soft deletion with deferred removal is the multi-user phase + (docs/design/MEDIA_STORAGE.md).""" + if not rel_path: + return False + try: + storage = _store_for(backend, rel_path) + if storage is None: + print(f"[CLEANUP] {rel_path!r} is in media backend {backend!r}, which this HomePilot is not " + f"configured for (MEDIA_STORAGE_BACKEND={backend_name()}); the object was left in place.") + return False + return storage.delete(rel_path) + except Exception as e: + print(f"[CLEANUP] Failed to delete file {rel_path}: {e}") + return False -def delete_asset_and_file(asset_id: str) -> bool: +def _trash_on() -> bool: + """Deleted assets go to the trash first (MEDIA_TRASH_RETENTION_DAYS, 7 by default); 0 means + bytes and row go at once, as before Phase 4.""" + return media_ops.trash_retention_days() > 0 + + +def delete_asset_and_file(asset_id: str, *, purge: bool = False) -> bool: """ - Delete a file_assets record AND its physical file from disk. - Returns True if the DB record was removed. + Delete a file asset. The row is marked deleted and disappears from every read; its bytes + stay until the trash is purged (media_ops.purge_trash, run by the backend), so a mistaken + delete is recoverable for the retention window. ``purge=True`` — or a retention of 0 — + removes the bytes and the row now. Returns True if the record was deleted either way. """ if not asset_id: return False con = _db() + _ensure_backend_column(con) cur = con.cursor() - cur.execute("SELECT id, rel_path FROM file_assets WHERE id = ?", (asset_id,)) + cur.execute("SELECT id, rel_path, storage_backend, deleted_at FROM file_assets WHERE id = ?", (asset_id,)) row = cur.fetchone() if not row: con.close() return False - rel_path = row["rel_path"] - root = _upload_root() - - # Delete the physical file - if rel_path: - abs_path = root / rel_path - try: - if abs_path.is_file(): - abs_path.unlink() - # Clean up empty parent dirs (but not the root) - parent = abs_path.parent - try: - if parent != root and not any(parent.iterdir()): - parent.rmdir() - except Exception: - pass - except Exception as e: - print(f"[CLEANUP] Failed to delete file {rel_path}: {e}") - - # Delete the DB record - cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,)) + if purge or not _trash_on(): + _remove_object(row["rel_path"], row["storage_backend"] or "local") + cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,)) + else: + stamp = _now() + cur.execute("UPDATE file_assets SET deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?", + (stamp, stamp, asset_id)) removed = cur.rowcount > 0 con.commit() con.close() @@ -224,13 +282,9 @@ def save_generated_image_as_asset( Returns dict with asset_id and url (/files/). """ ext = "png" if "png" in (mime or "") else "jpg" - folder = _ensure_user_dir(user_id, "image", project_id=project_id) - fname = f"{uuid.uuid4().hex}.{ext}" - abs_path = folder / fname - abs_path.write_bytes(image_bytes) - - rel_path = str(abs_path.relative_to(_upload_root())) + rel_path = f"{_user_key(user_id, 'image', project_id=project_id)}/{fname}" + get_storage().put(rel_path, image_bytes) asset_id = insert_asset( user_id=user_id, @@ -241,9 +295,10 @@ def save_generated_image_as_asset( original_name=fname, project_id=project_id, conversation_id=conversation_id, + sha256=hashlib.sha256(image_bytes).hexdigest(), ) - return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png"} + return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png", "rel_path": rel_path} def save_generated_media_as_asset( @@ -259,21 +314,21 @@ def save_generated_media_as_asset( extension = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip()) allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"} extension = extension if extension in allowed_extensions else ".bin" - folder = _ensure_user_dir(user_id, safe_kind, project_id=project_id) filename = f"{uuid.uuid4().hex}{extension}" - absolute_path = folder / filename - absolute_path.write_bytes(media_bytes) + rel_path = f"{_user_key(user_id, safe_kind, project_id=project_id)}/{filename}" + get_storage().put(rel_path, media_bytes) asset_id = insert_asset( user_id=user_id, kind=safe_kind, - rel_path=str(absolute_path.relative_to(_upload_root())), + rel_path=rel_path, mime=mime or "application/octet-stream", size_bytes=len(media_bytes), original_name=filename, project_id=project_id, conversation_id=conversation_id, + sha256=hashlib.sha256(media_bytes).hexdigest(), ) - return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime} + return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime, "rel_path": rel_path} # --------------------------------------------------------------------------- @@ -442,45 +497,32 @@ def delete_conversation_media(conversation_id: str) -> int: projects//persona/appearance/). """ con = _db() + _ensure_backend_column(con) cur = con.cursor() cur.execute( - "SELECT id, rel_path FROM file_assets WHERE conversation_id = ?", + "SELECT id, rel_path, storage_backend FROM file_assets WHERE conversation_id = ? AND deleted_at IS NULL", (conversation_id,), ) rows = cur.fetchall() - root = _upload_root() deleted = 0 - for row in rows: - asset_id = row["id"] - rel_path = row["rel_path"] - - # Delete the file from disk - abs_path = root / rel_path - try: - if abs_path.is_file(): - abs_path.unlink() - deleted += 1 - # Clean up empty parent dirs (but not the root) - parent = abs_path.parent - try: - if parent != root and not any(parent.iterdir()): - parent.rmdir() - except Exception: - pass - except Exception as e: - print(f"[CLEANUP] Failed to delete file {rel_path}: {e}") - - # Delete all asset records for this conversation - if rows: - cur.execute( - "DELETE FROM file_assets WHERE conversation_id = ?", - (conversation_id,), - ) + if _trash_on(): + # To the trash: gone from every read now, bytes removed when the retention has passed. + stamp = _now() + for row in rows: + cur.execute("UPDATE file_assets SET deleted_at = ?, updated_at = ? WHERE id = ?", (stamp, stamp, row["id"])) + deleted += 1 con.commit() + else: + for row in rows: + if _remove_object(row["rel_path"], row["storage_backend"] or "local"): + deleted += 1 + if rows: + cur.execute("DELETE FROM file_assets WHERE conversation_id = ?", (conversation_id,)) + con.commit() con.close() - print(f"[CLEANUP] Deleted {deleted} files for conversation {conversation_id}") + print(f"[CLEANUP] {'Trashed' if _trash_on() else 'Deleted'} {deleted} files for conversation {conversation_id}") return deleted @@ -562,24 +604,27 @@ async def upload_file( if len(data) > max_bytes: raise HTTPException(413, f"File too large (max {MAX_UPLOAD_MB}MB)") - # Choose extension original_name = file.filename or "" content_type = (file.content_type or "").lower() + # Before any byte is stored: what it claims to be, the owner's quota, the scanner if one is set. + media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type, original_name) + media_quota.check(user["id"], len(data)) + media_validate.scan_bytes(data, original_name) + + # Choose extension ext = "" if original_name and "." in original_name: ext = original_name.rsplit(".", 1)[1].lower() if not ext: ext = (mimetypes.guess_extension(content_type) or "").lstrip(".") or "bin" - # Write file to per-user directory - folder = _ensure_user_dir(user["id"], kind, project_id=project_id) + # Write to the user's area in media storage fname = f"{uuid.uuid4().hex}.{ext}" - abs_path = folder / fname - abs_path.write_bytes(data) + rel_path = f"{_user_key(user['id'], kind, project_id=project_id)}/{fname}" + get_storage().put(rel_path, data) # Store metadata - rel_path = str(abs_path.relative_to(_upload_root())) - mime = content_type or mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream" + mime = content_type or mimetypes.guess_type(fname)[0] or "application/octet-stream" asset_id = insert_asset( user_id=user["id"], kind=kind, @@ -589,11 +634,43 @@ async def upload_file( original_name=original_name, project_id=project_id, conversation_id=conversation_id, + sha256=hashlib.sha256(data).hexdigest(), ) return {"ok": True, "asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime} +@router.get("/v1/files/usage") +def files_usage( + authorization: str = Header(default=""), + homepilot_session: Optional[str] = Cookie(default=None), +): + """How much of their media quota the caller uses (quota_bytes 0: no limit).""" + user = _resolve_user(authorization, homepilot_session) + if not user: + raise HTTPException(401, "Authentication required") + return {"ok": True, **media_quota.usage(user["id"])} + + +@router.get("/v1/files/{asset_id}/link") +def file_link( + asset_id: str, + ttl: int = Query(default=media_links.DEFAULT_TTL_S, ge=1, le=media_links.MAX_TTL_S), + authorization: str = Header(default=""), + homepilot_session: Optional[str] = Cookie(default=None), +): + """A short-lived link to one of the caller's files — for an on another origin or a + share — that carries no account credential: /files/{id}?exp=…&sig=…""" + user = _resolve_user(authorization, homepilot_session) + if not user: + raise HTTPException(401, "Authentication required") + asset = get_asset(asset_id) + if not asset or asset.get("user_id") != user["id"]: + raise HTTPException(404, "Not found") + signed = media_links.sign(asset_id, ttl) + return {"ok": True, "asset_id": asset_id, "url": signed["url"], "expires_at": signed["exp"]} + + # --------------------------------------------------------------------------- # Secure Download / Serve Endpoint # --------------------------------------------------------------------------- @@ -610,6 +687,52 @@ def _is_public_file(name: str) -> bool: return name.startswith(("avatar_", "outfit_", "thumb_")) +def _is_user_avatar(name: str) -> bool: + """Some account's current profile picture: the login screen shows those before anyone is + signed in, so they are public by design.""" + try: + con = _db() + row = con.execute("SELECT 1 FROM users WHERE avatar_url = ? LIMIT 1", (f"/files/{name}",)).fetchone() + con.close() + return row is not None + except Exception: + return False + + +def _is_public_flat_file(name: str) -> bool: + """A flat file served without an account: a user's current avatar, or a persona visual that + also lives under a project's appearance folder. Any other avatar_*/outfit_*/thumb_* file + now needs an account, a signed link or the API key (audit item A1).""" + if not _is_public_file(name): + return False + return _is_user_avatar(name) or _find_persona_file(name) is not None + + +def _is_persona_visual_path(parts: tuple) -> bool: + """projects//persona/appearance/: the persona's own pictures, + public by design (the gallery, OllaBridge's VR chatbot).""" + return (len(parts) >= 4 and parts[0] == "projects" and "persona" in parts and "appearance" in parts + and _is_public_file(parts[-1])) + + +def _owns_path(user: Dict[str, Any], parts: tuple) -> bool: + """A path under users// belongs to that account alone (audit item A2); anything else on + the legacy routes is as before — flat root files, project folders.""" + if len(parts) >= 2 and parts[0] == "users": + return parts[1] == str(user.get("id")) + return True + + +def _serve_asset(asset: Dict[str, Any]): + key = str(asset.get("rel_path") or "") + return _serve_object( + key, + asset.get("mime") or "application/octet-stream", + asset.get("original_name") or os.path.basename(key.replace("\\", "/")), + backend=asset.get("storage_backend") or "local", + ) + + def _find_persona_file(filename: str) -> Optional[Path]: """ Search all project appearance directories for a file by name. @@ -628,49 +751,128 @@ def _find_persona_file(filename: str) -> Optional[Path]: return None +def _serve_object(key: str, mime: str, filename: str, backend: Optional[str] = None): + """Serve an object from media storage — the backend its registry row names, or for a key + with no row whichever has it: straight off the disk when that is local (a FileResponse, + as before), streamed in 1 MB chunks otherwise.""" + storage = _store_for(backend, key) + if storage is None: + print(f"[FILES] {key!r} is in media backend {backend!r}, which this HomePilot is not configured " + f"for (MEDIA_STORAGE_BACKEND={backend_name()}); configure it again to serve the file.") + raise HTTPException(404, "Not found") + try: + if not storage.exists(key): + raise HTTPException(404, "Not found") + local = storage.local_path(key) + if local is not None: + return FileResponse(path=str(local), media_type=mime, filename=filename, headers=_FILE_CACHE_HEADERS) + stream = storage.open(key) + except StorageKeyError: + raise HTTPException(404, "Not found") + + def _chunks(): + try: + while True: + chunk = stream.read(1024 * 1024) + if not chunk: + break + yield chunk + finally: + stream.close() + + safe_name = filename.replace('"', "") + headers = {**_FILE_CACHE_HEADERS, "Content-Disposition": f'inline; filename="{safe_name}"'} + return StreamingResponse(_chunks(), media_type=mime, headers=headers) + + +def _object_or_404(key: str, filename: str): + """A path-based route's last resort: the object under ``key`` in the configured storage, + when that is not this disk (an avatar or background written after a switch to S3).""" + try: + storage = get_storage() + if storage.name != "local" and storage.exists(key): + return _serve_object(key, mimetypes.guess_type(filename)[0] or "application/octet-stream", filename) + except Exception: + pass + raise HTTPException(404, "Not found") + + +def resolve_local_file(file_ref: str) -> Optional[Path]: + """A readable path on this disk for a ``/files/`` reference — a flat key or a + file_assets id — for code that hands files to ComfyUI or copies them: the object itself + when it is on the disk, else a copy fetched from the configured storage into + ``/.cache/media/`` (kept; the same key resolves to the same path, and + re-fetched when the size differs). None when nothing is found.""" + ref = (file_ref or "").split("?")[0].strip().strip("/") + if not ref or ".." in Path(ref.replace("\\", "/")).parts: + return None + candidates: list = [(ref, None)] # a flat key: whichever store has it + try: + asset = get_asset(ref) + except Exception: + asset = None + if asset and asset.get("rel_path"): # a registered asset: its own backend + candidates.insert(0, (str(asset["rel_path"]), asset.get("storage_backend") or "local")) + for key, backend in candidates: + try: + storage = _store_for(backend, key) + if storage is None or not storage.exists(key): + continue + local = storage.local_path(key) + if local is not None: + return local + cache_key = f".cache/media/{normalize_key(key)}" + disk = local_storage() + if not disk.exists(cache_key) or disk.size(cache_key) != storage.size(key): + with storage.open(key) as src: + disk.put(cache_key, src) + return disk.local_path(cache_key) + except StorageKeyError: + continue + except Exception as e: + print(f"[FILES] Could not resolve {key!r} to a local file: {e}") + continue + return None + + @router.get("/files/{asset_id}") def download_file( asset_id: str, authorization: str = Header(default=""), homepilot_session: Optional[str] = Cookie(default=None), token: Optional[str] = Query(default=None, alias="token"), + exp: Optional[str] = Query(default=None), + sig: Optional[str] = Query(default=None), ): """ - Secure file serving. - Requires auth and ownership check. - This is the endpoint used by . - Supports ?token= for tags that can't set headers. - Exception: persona appearance images (avatar_*, outfit_*, thumb_*) are public. + Secure file serving — the endpoint behind . + + A registered asset is served to its owner (session, cookie, or ?token= for an on + another origin), to anyone with a signed link for it (?exp=&sig=, GET /v1/files/{id}/link), + and to everyone when its visibility is public. A flat file is public only when it is a + user's current avatar or a persona visual; other flat files need an account. """ - # Public persona images can be served without auth - if _is_public_file(asset_id): + asset = get_asset(asset_id) + if asset and (asset.get("visibility") == "public" or media_links.verify(asset_id, exp, sig)): + return _serve_asset(asset) + + # A public flat file: a login-screen avatar or a persona visual. + if not asset and _is_public_flat_file(asset_id): safe = Path(asset_id) abs_path = _upload_root() / safe if abs_path.exists() and abs_path.is_file(): mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream" return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS) - raise HTTPException(404, "Not found") + return _object_or_404(asset_id, safe.name) user = _resolve_user(authorization, homepilot_session, token_param=token) if not user: raise HTTPException(401, "Authentication required") - asset = get_asset(asset_id) if asset: if asset.get("user_id") != user["id"]: raise HTTPException(404, "Not found") - - abs_path = _upload_root() / asset["rel_path"] - if not abs_path.exists(): - raise HTTPException(404, "Not found") - - filename = asset.get("original_name") or os.path.basename(str(abs_path)) - return FileResponse( - path=str(abs_path), - media_type=asset.get("mime") or "application/octet-stream", - filename=filename, - headers=_FILE_CACHE_HEADERS, - ) + return _serve_asset(asset) # Fallback: serve from flat uploads dir (avatars, legacy files) safe = Path(asset_id) @@ -681,7 +883,7 @@ def download_file( mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream" return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS) - raise HTTPException(404, "Not found") + return _object_or_404(asset_id, safe.name) # --------------------------------------------------------------------------- @@ -696,19 +898,18 @@ def download_file_legacy( token: Optional[str] = Query(default=None, alias="token"), ): """ - Fallback for legacy /files/ paths (pre-asset era). - Serves files from the flat UPLOAD_DIR if the user is authenticated. - This preserves backward compatibility for existing chat history images. + Fallback for legacy /files/ URLs (pre-asset era) and persona visuals. + A persona's own pictures (projects//persona/appearance/…) are public; a path under + users// is served to that account only; other paths need an account, as before. Supports ?token= for tags that can't set headers. - Exception: persona appearance images (avatar_*, outfit_*, thumb_*) are public. """ # Prevent path traversal - safe = Path(subpath) + safe = Path(subpath.replace("\\", "/")) if ".." in safe.parts: raise HTTPException(400, "Invalid path") - # Public persona images can be served without auth - if _is_public_file(safe.name): + # Public without an account: a persona's own pictures, or a public flat file. + if _is_persona_visual_path(safe.parts) or (len(safe.parts) == 1 and _is_public_flat_file(safe.name)): abs_path = _upload_root() / safe if abs_path.exists() and abs_path.is_file(): mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream" @@ -719,18 +920,20 @@ def download_file_legacy( if found: mime = mimetypes.guess_type(str(found))[0] or "application/octet-stream" return FileResponse(path=str(found), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS) - raise HTTPException(404, "Not found") + return _object_or_404(subpath, safe.name) user = _resolve_user(authorization, homepilot_session, token_param=token) if not user: raise HTTPException(401, "Authentication required") + if not _owns_path(user, safe.parts): + raise HTTPException(404, "Not found") abs_path = _upload_root() / safe if not abs_path.exists() or not abs_path.is_file(): # Fallback: search project appearance dirs for the file by name found = _find_persona_file(safe.name) if "persona" in safe.parts else None if not found: - raise HTTPException(404, "Not found") + return _object_or_404(subpath, safe.name) abs_path = found mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream" diff --git a/backend/app/intent_guard.py b/backend/app/intent_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..a7d57edd3d3ab2ee82628ce2b6de4b7bde324e93 --- /dev/null +++ b/backend/app/intent_guard.py @@ -0,0 +1,28 @@ +"""When a keyword shortcut may answer instead of the model. + +Persona chats have deterministic fast paths ("show me your back" → the back view, "photo 2" → +the second photo) and prompt hints built from the same keywords. They scan for words such as +"back", "first", "front", "more" — which any long text contains. A user who pasted a work log +and asked "give a summary" got "I only have the front in my look for now. Here it is!": the +paste said "moved HomePilot back to 8002" and "the reply first shows…". + +A display command is short. Anything longer — a question with context, a pasted document, a +composed message — goes to the model, which reads all of it. +""" +from __future__ import annotations + +MAX_COMMAND_CHARS = 160 +MAX_COMMAND_WORDS = 24 +MAX_COMMAND_LINES = 2 + + +def is_short_command(message: str) -> bool: + """Whether ``message`` is short enough to be a display command a keyword shortcut may + answer: at most two lines, 160 characters and 24 words.""" + text = (message or "").strip() + if not text: + return False + lines = [line for line in text.splitlines() if line.strip()] + if len(lines) > MAX_COMMAND_LINES: + return False + return len(text) <= MAX_COMMAND_CHARS and len(text.split()) <= MAX_COMMAND_WORDS diff --git a/backend/app/inventory.py b/backend/app/inventory.py index e2022f8e9e99da928f309a91e1a4ef055c5c5bca..29d89267f08f28f1110f893fcf0978b7d28d9b78 100644 --- a/backend/app/inventory.py +++ b/backend/app/inventory.py @@ -214,16 +214,23 @@ def _db_list_project_files(project_id: str, *, user_id: str = "") -> List[Dict[s if not conn: return [] try: + # Files in the trash (deleted_at set) are not inventory; a database from before that + # column is read as it was. + try: + conn.execute("SELECT deleted_at FROM file_assets LIMIT 0") + trash = " AND deleted_at IS NULL" + except sqlite3.OperationalError: + trash = "" if user_id: cur = conn.execute( "SELECT id, kind, rel_path, mime, size_bytes, original_name " - "FROM file_assets WHERE project_id = ? AND user_id = ?", + f"FROM file_assets WHERE project_id = ? AND user_id = ?{trash}", (project_id, user_id), ) else: cur = conn.execute( "SELECT id, kind, rel_path, mime, size_bytes, original_name " - "FROM file_assets WHERE project_id = ?", + f"FROM file_assets WHERE project_id = ?{trash}", (project_id,), ) out: List[Dict[str, Any]] = [] diff --git a/backend/app/main.py b/backend/app/main.py index a60753f34d22dc55ca0be1bf035ad754d748ca18..d1f23e1b475e8e2b6922ce92615b2d4b035f202b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -126,6 +126,9 @@ from .multimodal import analyze_image, is_vision_intent, VISION_MODEL_PATTERNS # OpenAI-compatible endpoint (additive — exposes personas as /v1/chat/completions) from .openai_compat_endpoint import router as openai_compat_router +# Where scripts/download.py is — the repo's, or the copy a packaged image ships. +from .download_script import download_script + # ── Access-log noise filter ────────────────────────────────────────────────── # The Interactive runtime polls GET /v1/interactive/play/sessions/{id}/pending # once per 1.5–5 s to pick up scene-render updates. Uvicorn logs every hit at @@ -403,10 +406,18 @@ try: import sys as _expert_sys from pathlib import Path as _ExpertPath - _expert_pkg_dir = ( - _ExpertPath(__file__).resolve().parents[2] - / "expert" / "backend" / "app" / "expert" - ) + _expert_here = _ExpertPath(__file__).resolve() + _expert_candidates = [ + _expert_here.parents[2] / "expert" / "backend" / "app" / "expert", # the repo + # A packaged image has no repo around it: the Hugging Face Space ships the + # package next to this one (/app/expert). Without it the chat's default path, + # POST /v1/expert/chat, did not exist there and every chat came back HTTP 405. + _expert_here.parents[1] / "expert", + ] + if os.getenv("EXPERT_PACKAGE_DIR"): + _expert_candidates.insert(0, _ExpertPath(os.environ["EXPERT_PACKAGE_DIR"])) + _expert_pkg_dir = next((p for p in _expert_candidates if (p / "__init__.py").is_file()), + _expert_candidates[0]) _expert_init = _expert_pkg_dir / "__init__.py" if not _expert_init.is_file(): raise FileNotFoundError(f"Expert package not found at {_expert_init}") @@ -1074,9 +1085,10 @@ def _extract_photo_number(msg: str) -> int | None: if num_match: return int(num_match.group(1)) - # Match patterns like "the second one", "second photo", "the 2nd" + # Match patterns like "the second one", "second photo", "the 2nd" — as whole words, so + # "firstly" or "a thirdparty tool" is not a photo number. for word, idx in _ORDINAL_MAP.items(): - if word in m: + if _re.search(rf"\b{word}\b", m): return idx # Match patterns like "the 2nd", "the 3rd", "the 1st" @@ -1109,6 +1121,12 @@ def _is_photo_intent(msg: str) -> bool: if not m: return False + # ---- PASS-THROUGH: anything longer than a command (a pasted document, a question with + # context) goes to the LLM — its words ("back", "first", "more") are not a request. + from .intent_guard import is_short_command + if not is_short_command(m): + return False + # ---- PASS-THROUGH: let the LLM handle questions and conversation ---- # Questions starting with question words → LLM reasons 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: startup_reconcile() except Exception as e: print(f"Warning: Asset registry reconcile failed: {e}") + # Media storage: say which backend holds the bytes; reach a remote one now, off the loop, + # so the first upload does not pay for loading its client (docs/design/MEDIA_STORAGE.md). + try: + from .media_storage import warm_up as _media_storage_warm_up + _media_storage_warm_up() + except Exception as e: + print(f"Warning: media storage warm-up failed: {e}") + # Deleted media waits in the trash for MEDIA_TRASH_RETENTION_DAYS; this thread purges it. + try: + from .media_ops import start_trash_gc as _start_trash_gc + _start_trash_gc() + except Exception as e: + print(f"Warning: media trash purge not started: {e}") # Files mount _ensure_static_mount() @@ -2465,9 +2496,16 @@ async def list_models( remedy_extra: Dict[str, Any] = {} if provider == "ollama": from .model_resolver import active_runtime_url as _active_runtime_url + from .model_resolver import ollama_root as _ollama_root from .model_resolver import resolve as _resolve_model - if not base_url: - base_url = await _active_runtime_url() + # An explicit Base URL that is not an Ollama (OllaBridge's /v1, say) falls back to + # the Ollama that answers, and the response says where the models came from. + asked = base_url + base_url = await _active_runtime_url(base_url) + if asked and base_url not in (_ollama_root(asked), asked.rstrip("/")): + remedy_extra["note"] = ( + f"{asked} is not an Ollama runtime; listing the models of Ollama at {base_url}." + ) # OpenAI-compatible endpoints (an OllaBridge gateway) also say *what* each model is — # 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: logger.addHandler(handler) try: - script_path = Path(__file__).parent.parent.parent / "scripts" / "download.py" - - if not script_path.exists(): - logger.error(f"Download script not found at {script_path}") - return JSONResponse( - status_code=500, - content=_safe_err( - "Download script not found. Please ensure scripts/download.py exists.", - code="script_not_found", - ), - ) + # Only ComfyUI and Civitai installs run the script (checked below, once an Ollama pull + # has returned): requiring it up front failed every install on the Space, pulls included. + script_path = download_script() logger.info(f"Starting model installation: provider={req.provider}, model={req.model_id}") @@ -2951,6 +2981,17 @@ async def install_model(req: ModelInstallRequest) -> JSONResponse: ) # Run download script for comfyui and civitai + if not script_path.is_file(): + logger.error(f"Download script not found at {script_path}") + return JSONResponse( + status_code=500, + content=_safe_err( + f"Download script not found at {script_path}. Set HOMEPILOT_DOWNLOAD_SCRIPT " + "to the repo's scripts/download.py.", + code="script_not_found", + ), + ) + # Pass Civitai API key via environment variable (secure - not visible in process list) env = os.environ.copy() 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[ if ".." in file_ref: return None + # The folder we were handed first (as before), then the same reference as a flat key or + # a file_assets id — on this disk, or fetched from the configured storage (files.py). local_path = upload_root / file_ref if not local_path.exists(): - # Try resolving via file_assets table - try: - from .files import get_asset, _upload_root - asset = get_asset(file_ref) - if asset and asset.get("rel_path"): - local_path = _upload_root() / asset["rel_path"] - except Exception: - pass - + from .files import resolve_local_file + local_path = resolve_local_file(file_ref) or local_path if not local_path.exists(): return None @@ -3638,12 +3674,20 @@ async def _download_comfy_image(url: str, upload_root: Path) -> str: ) as client: resp = await client.get(url) resp.raise_for_status() - dest.write_bytes(resp.content) + # The flat key `filename`, written atomically through the storage interface. Every + # caller passes UPLOAD_PATH, the media storage root; another folder (a test's temp dir) + # is written as a local store of its own. + from .media_storage import LocalStorage, get_storage, local_storage + storage = get_storage() + if Path(upload_root).resolve() != local_storage().root: + storage = LocalStorage(upload_root) + storage.put(filename, resp.content) # Register in asset registry for durability try: from .asset_registry import register_comfy_output - register_comfy_output(filename=filename, dest_path=str(dest)) + register_comfy_output(filename=filename, dest_path=str(dest), + storage_backend=storage.name, size_bytes=len(resp.content)) except Exception as e: print(f"[ASSET_REGISTRY] Warning: failed to register {filename}: {e}") @@ -4299,36 +4343,45 @@ async def upload_project_item( except Exception: pass # Non-fatal: proceed with upload if dedup check fails - # Save to disk + # Save through media storage: in the user's project area when there is a user (the + # secure-asset layout), else the flat legacy key — one atomic write, not a flat write + # and a move. + import hashlib as _hashlib import mimetypes as _mt + from . import media_quota, media_validate + from .files import _user_key, insert_asset + from .media_storage import get_storage + # Before any byte is stored: what it claims to be, the owner's quota, the scanner if set. + media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type or "", filename) + if user_id: + media_quota.check(user_id, len(data)) + media_validate.scan_bytes(data, filename) save_ext = f".{ext}" if ext else ".bin" save_name = f"{uuidlib.uuid4().hex}{save_ext}" - save_path = UPLOAD_PATH / save_name - save_path.write_bytes(data) + storage_key = ( + f"{_user_key(user_id, 'project_asset', project_id=project_id)}/{save_name}" if user_id else save_name + ) + storage = get_storage() + storage.put(storage_key, data) + save_path = storage.local_path(storage_key) or (UPLOAD_PATH / storage_key) - mime = content_type or _mt.guess_type(str(save_path))[0] or "application/octet-stream" + mime = content_type or _mt.guess_type(save_name)[0] or "application/octet-stream" base = _base_url_from_request(request) # Register as secure asset if user is authenticated asset_id = "" - file_url = f"{base}/files/{save_name}" + file_url = f"{base}/files/{storage_key}" if user_id: try: - from .files import _ensure_user_dir, _upload_root, insert_asset - folder = _ensure_user_dir(user_id, "project_asset", project_id=project_id) - import shutil - dest = folder / save_name - shutil.move(str(save_path), str(dest)) - save_path = dest - rel_path = str(dest.relative_to(_upload_root())) asset_id = insert_asset( user_id=user_id, kind="project_asset", - rel_path=rel_path, + rel_path=storage_key, mime=mime, size_bytes=len(data), original_name=filename, project_id=project_id, + sha256=_hashlib.sha256(data).hexdigest(), ) file_url = f"{base}/files/{asset_id}" except Exception as asset_err: @@ -5584,65 +5637,71 @@ async def upload(request: Request, file: UploadFile = File(...), authorization: except Exception: pass + from .media_storage import get_storage + name = f"{uuidlib.uuid4().hex}{ext}" if _uid: - # Secure path: store under per-user directory and register asset - from .files import _ensure_user_dir, _upload_root, insert_asset - import mimetypes as _mt - folder = _ensure_user_dir(_uid, "upload") - name = f"{uuidlib.uuid4().hex}{ext}" - path = folder / name + # Secure path: the user's area, registered as an asset below + from .files import _user_key + key = f"{_user_key(_uid, 'upload')}/{name}" else: - # Legacy path: flat UPLOAD_PATH - name = f"{uuidlib.uuid4().hex}{ext}" - path = UPLOAD_PATH / name + # Legacy path: the flat root + key = name + storage = get_storage() written = 0 chunk_size = 1024 * 1024 # 1MB + from . import media_quota, media_validate + import hashlib as _hashlib - # Atomic write: write to .tmp first, then rename on success - tmp_path = path.with_suffix(path.suffix + ".tmp") + # Read it whole (bounded by MAX_UPLOAD_MB) and check it before any byte is stored: what it + # claims to be, the owner's quota, the scanner if one is set. Then one atomic put. + chunks: list = [] + digest = _hashlib.sha256() try: - with tmp_path.open("wb") as f: - while True: - chunk = await file.read(chunk_size) - if not chunk: - break - written += len(chunk) - if written > max_bytes: - raise HTTPException( - status_code=413, - detail=f"File too large. Max {MAX_UPLOAD_MB}MB.", - ) - f.write(chunk) - f.flush() - os.fsync(f.fileno()) - # Atomic rename - tmp_path.rename(path) + while True: + chunk = await file.read(chunk_size) + if not chunk: + break + if not chunks: + media_validate.check_image_claim(chunk[:media_validate.HEAD_BYTES], file.content_type or "", filename) + written += len(chunk) + if written > max_bytes: + raise HTTPException( + status_code=413, + detail=f"File too large. Max {MAX_UPLOAD_MB}MB.", + ) + digest.update(chunk) + chunks.append(chunk) + finally: + await file.close() + data = b"".join(chunks) + if _uid: + media_quota.check(_uid, written) + media_validate.scan_bytes(data, filename) + try: + storage.put(key, data) except OSError as e: - # Clean up partial file - tmp_path.unlink(missing_ok=True) + local = storage.local_path(key) raise HTTPException( status_code=500, - detail=f"Upload storage error: {e}. Upload dir: {path.parent}", + detail=f"Upload storage error: {e}. Upload dir: {local.parent if local else storage.name}", ) - finally: - await file.close() base = _base_url_from_request(request) if _uid: # Register as a secure asset - from .files import _upload_root, insert_asset + from .files import insert_asset import mimetypes as _mt - rel_path = str(path.relative_to(_upload_root())) - mime = _mt.guess_type(str(path))[0] or "application/octet-stream" + mime = _mt.guess_type(name)[0] or "application/octet-stream" asset_id = insert_asset( user_id=_uid, kind="upload", - rel_path=rel_path, + rel_path=key, mime=mime, size_bytes=written, original_name=filename, + sha256=digest.hexdigest(), ) return JSONResponse(status_code=201, content={"url": f"{base}/files/{asset_id}"}) else: diff --git a/backend/app/media_links.py b/backend/app/media_links.py new file mode 100644 index 0000000000000000000000000000000000000000..ceefc3afca4eb01cb595a66c3766b0fbd55095cd --- /dev/null +++ b/backend/app/media_links.py @@ -0,0 +1,86 @@ +""" +Signed links to one file — the grant for an tag or a share that is not an account +credential (docs/design/MEDIA_STORAGE.md, audit item A3). + +``/files/?exp=&sig=``: valid for that asset until ``exp``, +nothing else. The key is ``MEDIA_SIGNING_KEY`` when set, else one generated once into +``/.media_signing_key`` (mode 600) next to the database. +""" +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import time +from pathlib import Path +from typing import Dict, Optional + +from .storage import _get_db_path + +DEFAULT_TTL_S = 600 +MAX_TTL_S = 7 * 24 * 3600 +_key_cache: Optional[bytes] = None + + +def _key_file() -> Path: + return Path(_get_db_path()).parent / ".media_signing_key" + + +def signing_key() -> bytes: + global _key_cache + if _key_cache: + return _key_cache + env = (os.getenv("MEDIA_SIGNING_KEY") or "").strip() + if env: + _key_cache = env.encode("utf-8") + return _key_cache + path = _key_file() + try: + key = path.read_bytes().strip() + if key: + _key_cache = key + return key + except FileNotFoundError: + pass + path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_hex(32).encode("ascii") + try: + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(key + b"\n") + except FileExistsError: + key = path.read_bytes().strip() + _key_cache = key + return key + + +def reset_key_cache() -> None: + global _key_cache + _key_cache = None + + +def _signature(asset_id: str, exp: int) -> str: + message = f"{asset_id}|{int(exp)}".encode("utf-8") + return hmac.new(signing_key(), message, hashlib.sha256).hexdigest() + + +def sign(asset_id: str, ttl_s: int = DEFAULT_TTL_S, now: Optional[float] = None) -> Dict[str, object]: + """``{"exp": …, "sig": …, "url": "/files/?exp=…&sig=…"}`` for ``asset_id``.""" + ttl = max(1, min(int(ttl_s or DEFAULT_TTL_S), MAX_TTL_S)) + exp = int((now if now is not None else time.time()) + ttl) + sig = _signature(asset_id, exp) + return {"exp": exp, "sig": sig, "url": f"/files/{asset_id}?exp={exp}&sig={sig}"} + + +def verify(asset_id: str, exp: Optional[str], sig: Optional[str], now: Optional[float] = None) -> bool: + """True when ``sig`` is this asset's signature for ``exp`` and ``exp`` is still ahead.""" + if not asset_id or not exp or not sig: + return False + try: + exp_i = int(exp) + except (TypeError, ValueError): + return False + if exp_i < (now if now is not None else time.time()): + return False + return hmac.compare_digest(_signature(asset_id, exp_i), str(sig).strip()) diff --git a/backend/app/media_ops.py b/backend/app/media_ops.py new file mode 100644 index 0000000000000000000000000000000000000000..97224cd27db1e5f86f99faa44f94ab65d265db05 --- /dev/null +++ b/backend/app/media_ops.py @@ -0,0 +1,360 @@ +""" +Operations on HomePilot's media (docs/design/MEDIA_STORAGE.md, Phases 3 and 4): + + python -m app.media_ops verify every asset is where its row says, hashes match + python -m app.media_ops migrate [--to s3] copy assets into the configured backend, verify, flip the row + python -m app.media_ops backup database copy + every asset's bytes + a manifest + python -m app.media_ops restore put a backup's assets into the configured backend + python -m app.media_ops gc remove what has been in the trash longer than the retention + +Nothing here deletes a source during a migration unless ``--prune-source`` says so, and a +copy is only recorded after it has been read back and its SHA-256 matched. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sqlite3 +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, List, Optional + +from . import media_storage as _ms +from .media_storage import LocalStorage, MediaStorage +from .storage import _get_db_path, ensure_file_assets_columns + + +def get_storage() -> MediaStorage: + """Looked up on the module each time, so a configured (or test-substituted) backend is seen.""" + return _ms.get_storage() + + +def storage_named(name: str) -> Optional[MediaStorage]: + return _ms.storage_named(name) + +_CHUNK = 1024 * 1024 +Log = Callable[[str], None] + +ROW_COLUMNS = ("id", "user_id", "kind", "rel_path", "mime", "size_bytes", "original_name", "project_id", + "conversation_id", "storage_backend", "sha256", "visibility", "created_at") + + +def _db() -> sqlite3.Connection: + con = sqlite3.connect(_get_db_path()) + con.row_factory = sqlite3.Row + ensure_file_assets_columns(con) + return con + + +def _rows(con: sqlite3.Connection, where: str = "deleted_at IS NULL", params: tuple = ()) -> List[sqlite3.Row]: + return con.execute(f"SELECT {', '.join(ROW_COLUMNS)}, deleted_at FROM file_assets WHERE {where} ORDER BY created_at", + params).fetchall() + + +def sha256_of(stream) -> str: + digest = hashlib.sha256() + while True: + chunk = stream.read(_CHUNK) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def copy_hashed(src: MediaStorage, dst: MediaStorage, key: str) -> str: + """Copy one object, hashing it on the way; the SHA-256 of what was read.""" + digest = hashlib.sha256() + with src.open(key) as reader, dst.open_write(key) as writer: + while True: + chunk = reader.read(_CHUNK) + if not chunk: + break + digest.update(chunk) + writer.write(chunk) + return digest.hexdigest() + + +def read_back_hash(storage: MediaStorage, key: str) -> str: + with storage.open(key) as reader: + return sha256_of(reader) + + +# ── verify ───────────────────────────────────────────────────────────────────── + + +def verify(log: Log = print) -> Dict[str, int]: + """Every live asset: its backend configured, the object there, the hash as recorded.""" + counts = {"ok": 0, "missing": 0, "mismatch": 0, "unconfigured": 0, "unhashed": 0} + con = _db() + try: + for row in _rows(con): + store = storage_named(row["storage_backend"] or "local") + if store is None: + counts["unconfigured"] += 1 + log(f" ? {row['id']} {row['rel_path']}: backend {row['storage_backend']!r} is not configured") + continue + if not store.exists(row["rel_path"]): + counts["missing"] += 1 + log(f" ✗ {row['id']} {row['rel_path']}: missing from {store.name}") + continue + if not row["sha256"]: + counts["unhashed"] += 1 + counts["ok"] += 1 + continue + if read_back_hash(store, row["rel_path"]) != row["sha256"]: + counts["mismatch"] += 1 + log(f" ✗ {row['id']} {row['rel_path']}: SHA-256 differs from the recorded one") + continue + counts["ok"] += 1 + finally: + con.close() + log(f"verify: {counts}") + return counts + + +# ── migrate ──────────────────────────────────────────────────────────────────── + + +def migrate(target_name: Optional[str] = None, *, dry_run: bool = False, prune_source: bool = False, + log: Log = print) -> Dict[str, int]: + """Copy every live asset that is not in the target backend into it — read back, hash + checked — then flip its row. Sources stay unless ``prune_source``.""" + target = get_storage() if not target_name else storage_named(target_name) + if target is None: + raise RuntimeError(f"target backend {target_name!r} is not configured") + counts = {"moved": 0, "already": 0, "missing_source": 0, "unconfigured_source": 0, "failed": 0} + con = _db() + try: + for row in _rows(con, "deleted_at IS NULL AND storage_backend != ?", (target.name,)): + key, src_name = row["rel_path"], row["storage_backend"] or "local" + src = storage_named(src_name) + if src is None: + counts["unconfigured_source"] += 1 + log(f" ? {row['id']} {key}: source backend {src_name!r} is not configured") + continue + if not src.exists(key): + counts["missing_source"] += 1 + log(f" ✗ {row['id']} {key}: not in {src_name}") + continue + if dry_run: + counts["moved"] += 1 + log(f" → {row['id']} {key}: would copy {src_name} → {target.name}") + continue + try: + if target.exists(key) and read_back_hash(target, key) == (row["sha256"] or read_back_hash(src, key)): + digest = row["sha256"] or read_back_hash(src, key) + counts["already"] += 1 + else: + digest = copy_hashed(src, target, key) + if read_back_hash(target, key) != digest: + raise RuntimeError("read-back hash differs from what was written") + if row["sha256"] and row["sha256"] != digest: + raise RuntimeError("the source no longer matches its recorded hash") + counts["moved"] += 1 + con.execute("UPDATE file_assets SET storage_backend = ?, sha256 = ? WHERE id = ?", + (target.name, digest, row["id"])) + try: + con.execute("UPDATE assets SET storage_backend = ? WHERE storage_backend = ? AND storage_key = ?", + (target.name, src_name, key)) + except sqlite3.Error: + pass # the registry row for this key already exists in the target backend + con.commit() + if prune_source and src is not target: + src.delete(key) + log(f" ✓ {row['id']} {key}: {src_name} → {target.name}") + except Exception as e: + counts["failed"] += 1 + log(f" ✗ {row['id']} {key}: {e} — row left on {src_name}") + finally: + con.close() + log(f"migrate → {target.name}{' (dry run)' if dry_run else ''}: {counts}") + return counts + + +# ── backup / restore ─────────────────────────────────────────────────────────── + + +def backup(dest: Path, log: Log = print) -> Dict[str, Any]: + """``dest/homepilot.db`` (a consistent copy), ``dest/media/`` for every live asset, + ``dest/manifest.json``. Portable: it does not depend on any backend's on-disk format.""" + dest = Path(dest) + dest.mkdir(parents=True, exist_ok=True) + media = LocalStorage(dest / "media") + con = _db() + try: + db_copy = sqlite3.connect(dest / "homepilot.db") + with db_copy: + con.backup(db_copy) + db_copy.close() + manifest: Dict[str, Any] = {"created_at": datetime.now(timezone.utc).isoformat(), "assets": [], "skipped": []} + for row in _rows(con): + store = storage_named(row["storage_backend"] or "local") + key = row["rel_path"] + if store is None or not store.exists(key): + manifest["skipped"].append({"id": row["id"], "rel_path": key, "storage_backend": row["storage_backend"]}) + log(f" ✗ {row['id']} {key}: not available from {row['storage_backend']}") + continue + digest = copy_hashed(store, media, key) + entry = {c: row[c] for c in ROW_COLUMNS} + entry["sha256"] = digest + manifest["assets"].append(entry) + (dest / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + finally: + con.close() + log(f"backup → {dest}: {len(manifest['assets'])} assets, {len(manifest['skipped'])} skipped") + return manifest + + +def restore(src: Path, target_name: Optional[str] = None, log: Log = print) -> Dict[str, int]: + """Put a backup's assets into the configured backend (read back, hash checked) and make + sure each has its row, naming that backend. The database file itself is restored by + copying ``homepilot.db`` back while HomePilot is stopped — see the design doc.""" + src = Path(src) + manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8")) + media = LocalStorage(src / "media") + target = get_storage() if not target_name else storage_named(target_name) + if target is None: + raise RuntimeError(f"target backend {target_name!r} is not configured") + counts = {"restored": 0, "failed": 0} + con = _db() + try: + for entry in manifest["assets"]: + key = entry["rel_path"] + try: + digest = copy_hashed(media, target, key) + if digest != entry.get("sha256"): + raise RuntimeError("the backup copy does not match its manifest hash") + if read_back_hash(target, key) != digest: + raise RuntimeError("read-back hash differs from what was written") + con.execute( + """INSERT INTO file_assets(id, user_id, kind, rel_path, mime, size_bytes, original_name, + project_id, conversation_id, storage_backend, sha256, visibility, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET storage_backend = excluded.storage_backend, + sha256 = excluded.sha256, deleted_at = NULL""", + (entry["id"], entry["user_id"], entry["kind"], key, entry.get("mime") or "", + int(entry.get("size_bytes") or 0), entry.get("original_name") or "", entry.get("project_id") or "", + entry.get("conversation_id") or "", target.name, digest, entry.get("visibility") or "private", + entry.get("created_at") or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")), + ) + con.commit() + counts["restored"] += 1 + except Exception as e: + counts["failed"] += 1 + log(f" ✗ {entry['id']} {key}: {e}") + finally: + con.close() + log(f"restore ← {src} into {target.name}: {counts}") + return counts + + +# ── trash ────────────────────────────────────────────────────────────────────── + + +def trash_retention_days() -> float: + raw = (os.getenv("MEDIA_TRASH_RETENTION_DAYS") or "7").strip() + try: + return max(0.0, float(raw)) + except ValueError: + return 7.0 + + +def purge_trash(retention_days: Optional[float] = None, *, now: Optional[float] = None, log: Log = print) -> Dict[str, int]: + """Remove the bytes and rows of assets deleted longer ago than the retention. A row whose + backend is not configured is left — the object cannot be reached, so neither goes.""" + days = trash_retention_days() if retention_days is None else float(retention_days) + cutoff = datetime.fromtimestamp((now if now is not None else time.time()) - days * 86400, tz=timezone.utc) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + counts = {"purged": 0, "kept": 0, "failed": 0} + con = _db() + try: + rows = con.execute("SELECT id, rel_path, storage_backend FROM file_assets WHERE deleted_at IS NOT NULL AND deleted_at <= ?", + (cutoff_s,)).fetchall() + for row in rows: + store = storage_named(row["storage_backend"] or "local") + if store is None: + counts["kept"] += 1 + continue + try: + store.delete(row["rel_path"]) + con.execute("DELETE FROM file_assets WHERE id = ?", (row["id"],)) + con.commit() + counts["purged"] += 1 + except Exception as e: + counts["failed"] += 1 + log(f" ✗ {row['id']} {row['rel_path']}: {e}") + finally: + con.close() + if counts["purged"] or counts["failed"]: + log(f"trash: {counts} (retention {days:g} days)") + return counts + + +_gc_thread: Optional[threading.Thread] = None + + +def start_trash_gc(interval_s: float = 6 * 3600, first_delay_s: float = 60.0) -> None: + """A daemon thread that purges the trash shortly after startup and every ``interval_s``.""" + global _gc_thread + if _gc_thread and _gc_thread.is_alive(): + return + + def _loop() -> None: + time.sleep(first_delay_s) + while True: + try: + purge_trash() + except Exception as e: # never let housekeeping take the thread down + print(f"[MEDIA] trash purge failed: {e}") + time.sleep(interval_s) + + _gc_thread = threading.Thread(target=_loop, name="media-trash-gc", daemon=True) + _gc_thread.start() + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(prog="python -m app.media_ops", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("verify") + p = sub.add_parser("migrate") + p.add_argument("--to", dest="target", default=None, help="backend name (default: the configured one)") + p.add_argument("--dry-run", action="store_true") + p.add_argument("--prune-source", action="store_true", help="delete each source object after its copy verified") + p = sub.add_parser("backup") + p.add_argument("dest") + p = sub.add_parser("restore") + p.add_argument("src") + p.add_argument("--to", dest="target", default=None) + p = sub.add_parser("gc") + p.add_argument("--retention-days", type=float, default=None) + args = parser.parse_args(argv) + + if args.command == "verify": + counts = verify() + return 1 if counts["missing"] or counts["mismatch"] else 0 + if args.command == "migrate": + counts = migrate(args.target, dry_run=args.dry_run, prune_source=args.prune_source) + return 1 if counts["failed"] else 0 + if args.command == "backup": + manifest = backup(Path(args.dest)) + return 1 if manifest["skipped"] else 0 + if args.command == "restore": + counts = restore(Path(args.src), args.target) + return 1 if counts["failed"] else 0 + if args.command == "gc": + purge_trash(args.retention_days) + return 0 + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/media_quota.py b/backend/app/media_quota.py new file mode 100644 index 0000000000000000000000000000000000000000..e2ee6a4dc8940b5d4913333a36b2098e88fe8c7f --- /dev/null +++ b/backend/app/media_quota.py @@ -0,0 +1,60 @@ +""" +Per-user media quota (docs/design/MEDIA_STORAGE.md, Phase 4). + +Off by default: ``MEDIA_QUOTA_MB_PER_USER=0`` (unlimited) keeps a local HomePilot exactly as +it was. With a limit, an upload that would take a user past it is refused with 413 before any +bytes are stored; files in the trash no longer count. +""" +from __future__ import annotations + +import os +import sqlite3 +from typing import Dict + +from fastapi import HTTPException + +from .storage import _get_db_path, ensure_file_assets_columns + + +def quota_bytes() -> int: + """The per-user limit in bytes, 0 for none. Read at call time so a restart is not needed.""" + raw = (os.getenv("MEDIA_QUOTA_MB_PER_USER") or "0").strip() + try: + mb = float(raw) + except ValueError: + mb = 0.0 + return int(mb * 1024 * 1024) if mb > 0 else 0 + + +def usage_bytes(user_id: str) -> int: + if not user_id: + return 0 + con = sqlite3.connect(_get_db_path()) + try: + ensure_file_assets_columns(con) + row = con.execute( + "SELECT COALESCE(SUM(size_bytes), 0) FROM file_assets WHERE user_id = ? AND deleted_at IS NULL", + (user_id,), + ).fetchone() + return int(row[0] or 0) + finally: + con.close() + + +def usage(user_id: str) -> Dict[str, int]: + return {"used_bytes": usage_bytes(user_id), "quota_bytes": quota_bytes()} + + +def check(user_id: str, add_bytes: int) -> None: + """Raise 413 when storing ``add_bytes`` more would take ``user_id`` past the quota.""" + limit = quota_bytes() + if not limit or not user_id: + return + used = usage_bytes(user_id) + if used + max(0, int(add_bytes or 0)) > limit: + mb = 1024 * 1024 + raise HTTPException( + status_code=413, + detail=f"Storage quota reached: {used / mb:.0f} of {limit / mb:.0f} MB used. " + "Delete some files, or ask the operator to raise MEDIA_QUOTA_MB_PER_USER.", + ) diff --git a/backend/app/media_storage/__init__.py b/backend/app/media_storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cf50fe0b8c641d27d6732d85917f4958ed6060e9 --- /dev/null +++ b/backend/app/media_storage/__init__.py @@ -0,0 +1,155 @@ +""" +Where HomePilot's media bytes live. + +One interface (``MediaStorage``) for uploads, generated images and videos, avatars and the +other persistent media, keyed by the relative path the registries already store — so every +existing asset resolves unchanged and nothing is moved. ``local`` (the default) is today's +layout under UPLOAD_DIR and needs no service; ``s3`` is any S3-compatible object store +(MEDIA_STORAGE_BACKEND=s3 plus S3_ENDPOINT_URL / S3_BUCKET / credentials). The backend is a +deployment setting, never a caller's decision. + + from .media_storage import get_storage + get_storage().put("users//images/.png", data) + +Reads go through ``storage_for_read(key)``: the configured backend, else — when that is not +local — this machine's disk, where media from before a switch still is. Design and the phase +plan: docs/design/MEDIA_STORAGE.md. +""" +from __future__ import annotations + +import os +import threading +from pathlib import Path +from typing import Optional + +from .. import config as _config +from .base import MediaStorage, StorageKeyError, StoredObject, normalize_key +from .local import LocalStorage +from .s3 import S3Settings, S3Storage + +__all__ = [ + "MediaStorage", "LocalStorage", "S3Settings", "S3Storage", "StorageKeyError", "StoredObject", + "AVAILABLE_BACKENDS", "backend_name", "get_storage", "local_storage", "normalize_key", + "reset_storage", "storage_for_read", "storage_named", "upload_root", "warm_up", +] + +DEFAULT_BACKEND = "local" +AVAILABLE_BACKENDS = ("local", "s3") + +_lock = threading.Lock() +_current: Optional[MediaStorage] = None +_current_signature: Optional[tuple] = None +_local: Optional[LocalStorage] = None + + +def upload_root() -> Path: + """UPLOAD_DIR, or ``backend/data/uploads`` when it is relative — the rule files.py and + asset_registry.py each carried a copy of. Read at call time, so a repointed + ``app.config.UPLOAD_DIR`` (tests, DATA_DIR) is honoured.""" + p = Path(_config.UPLOAD_DIR) + if not p.is_absolute(): + p = Path(__file__).resolve().parents[2] / "data" / "uploads" + return p + + +def backend_name() -> str: + return (os.getenv("MEDIA_STORAGE_BACKEND") or DEFAULT_BACKEND).strip().lower() or DEFAULT_BACKEND + + +def local_storage() -> LocalStorage: + """This machine's disk under the upload root — the default backend, and where media + from before a switch to another backend still is.""" + global _local + root = upload_root() + with _lock: + if _local is None or _local.root != root.resolve(): + _local = LocalStorage(root) + return _local + + +def get_storage() -> MediaStorage: + """The configured storage — built once, rebuilt when the backend or its settings change. + Building it touches no network; the first operation does.""" + global _current, _current_signature + name = backend_name() + s3_settings = S3Settings.from_env() if name == "s3" else None + signature = s3_settings.signature() if s3_settings else (name, str(upload_root())) + with _lock: + if _current is None or _current_signature != signature: + if name == "local": + _current = LocalStorage(upload_root()) + elif s3_settings is not None: + _current = S3Storage(s3_settings) + else: + raise RuntimeError( + f"MEDIA_STORAGE_BACKEND={name!r} is not a backend this HomePilot has: " + f"one of {', '.join(AVAILABLE_BACKENDS)}." + ) + _current_signature = signature + return _current + + +def storage_named(name: str) -> Optional[MediaStorage]: + """The storage called ``name`` as this HomePilot is configured: this disk for 'local', the + configured backend when that is its name — else None: the bytes are in a store this + HomePilot is not set up to reach (switched back to local after writing to S3).""" + name = (name or "local").strip().lower() + if name == "local": + return local_storage() + storage = get_storage() + return storage if storage.name == name else None + + +def storage_for_read(key: str) -> MediaStorage: + """For a key with no registry row (path-based legacy URLs): the configured backend when it + has the object (or is local), else this machine's disk — so switching to S3 never hides + the media written before it, and a remote store that is down still leaves the local ones + readable. Registered assets do not need this: their row names the backend.""" + storage = get_storage() + if storage.name == "local": + return storage + try: + if storage.exists(key): + return storage + except StorageKeyError: + raise + except Exception: + pass # unreachable or misconfigured: fall through to the disk for this read + local = local_storage() + return local if local.exists(key) else storage + + +def warm_up() -> None: + """At startup: say which backend is in use and, when it is remote, load its client library + and reach the bucket on a background thread — so the first upload does not pay for it + (boto3 took 14 s to load from a venv on a Windows share; the second upload took 0.2 s).""" + try: + storage = get_storage() + except Exception as e: + print(f"[media-storage] not available: {e}") + return + if isinstance(storage, S3Storage): + s = storage.settings + print(f"[media-storage] backend=s3 endpoint={s.endpoint_url or 'aws'} bucket={s.bucket}" + + (f" prefix={s.prefix}" if s.prefix else "") + " (media from before the switch is read from the disk)") + else: + print(f"[media-storage] backend={storage.name} root={upload_root()}") + return + + def _warm() -> None: + try: + storage.exists(".warm-up") # builds the client and makes sure the bucket is there + print("[media-storage] s3 ready") + except Exception as e: + print(f"[media-storage] s3 not reachable yet: {e}") + + threading.Thread(target=_warm, name="media-storage-warm-up", daemon=True).start() + + +def reset_storage() -> None: + """Forget the built storages (tests that change UPLOAD_DIR or the backend).""" + global _current, _current_signature, _local + with _lock: + _current = None + _current_signature = None + _local = None diff --git a/backend/app/media_storage/base.py b/backend/app/media_storage/base.py new file mode 100644 index 0000000000000000000000000000000000000000..ac6344c4f1fef2d3b2707794a352f1b1b61725ae --- /dev/null +++ b/backend/app/media_storage/base.py @@ -0,0 +1,89 @@ +""" +The MediaStorage interface: where HomePilot keeps media bytes. + +A key is a relative POSIX path under the storage root — the same string that +``file_assets.rel_path`` and ``assets.storage_key`` already hold ("users//images/.png", +"avatar__1a2b.png"), so every existing asset resolves unchanged. Which backend serves a +key is a deployment setting (``MEDIA_STORAGE_BACKEND``), never a caller's decision. Phase 1 +ships ``local``; another backend is another subclass of this, run through the same contract +tests (backend/tests/test_media_storage.py). Design: docs/design/MEDIA_STORAGE.md. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, ContextManager, Optional, Union + +Bytes = Union[bytes, bytearray, memoryview] + + +class StorageKeyError(ValueError): + """A key that would leave the storage root, or names nothing.""" + + +@dataclass(frozen=True) +class StoredObject: + backend: str + key: str + size_bytes: int + + +def normalize_key(key: str) -> str: + """The canonical form of a key, or StorageKeyError. + + Backslashes are separators (keys written on Windows), repeated and trailing slashes + collapse, and nothing may climb out: no absolute paths, drive letters, "..", NUL. + """ + raw = str(key or "").replace("\\", "/") + if not raw.strip() or "\x00" in raw: + raise StorageKeyError("empty key") + if raw.startswith("/") or (len(raw) > 1 and raw[1] == ":"): + raise StorageKeyError(f"an absolute path is not a key: {key!r}") + parts = [p for p in raw.split("/") if p not in ("", ".")] + if not parts or any(p == ".." for p in parts): + raise StorageKeyError(f"key leaves the storage root: {key!r}") + return "/".join(parts) + + +class MediaStorage(ABC): + """Bytes by key. Writes are atomic: a reader never sees a half-written object.""" + + name: str = "abstract" + + @abstractmethod + def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject: + """Store bytes (or a readable stream) at ``key``. FileExistsError when it is + there and ``overwrite`` is False.""" + + @abstractmethod + def open_write(self, key: str, *, overwrite: bool = True) -> ContextManager[BinaryIO]: + """A writable stream for ``key``, committed when the block ends normally and + discarded entirely — nothing at ``key``, no leftovers — when it raises.""" + + @abstractmethod + def open(self, key: str) -> BinaryIO: + """A readable stream; FileNotFoundError when there is no such object.""" + + def read(self, key: str) -> bytes: + with self.open(key) as f: + return f.read() + + @abstractmethod + def exists(self, key: str) -> bool: ... + + @abstractmethod + def size(self, key: str) -> int: + """Size in bytes; FileNotFoundError when there is no such object.""" + + @abstractmethod + def delete(self, key: str) -> bool: + """Remove the object. True when something was removed, False when it was not there.""" + + @abstractmethod + def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject: ... + + def local_path(self, key: str) -> Optional[Path]: + """The object's path on this machine's filesystem, when the backend has one (a + FileResponse can serve it directly); None for remote storage.""" + return None diff --git a/backend/app/media_storage/local.py b/backend/app/media_storage/local.py new file mode 100644 index 0000000000000000000000000000000000000000..b37ea269ea98639081444a538c13d78ad46f1eb6 --- /dev/null +++ b/backend/app/media_storage/local.py @@ -0,0 +1,111 @@ +""" +LocalStorage — today's layout under UPLOAD_DIR, behind the MediaStorage interface. + +Nothing moves: a key is the path relative to the root that the registries already store. +What is new is the discipline — every write lands atomically (temp file in the same +directory, then ``os.replace``), and no key can reach outside the root. +""" +from __future__ import annotations + +import os +import shutil +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import BinaryIO, Iterator, Union + +from .base import Bytes, MediaStorage, StorageKeyError, StoredObject, normalize_key + +_COPY_CHUNK = 1024 * 1024 + + +class LocalStorage(MediaStorage): + name = "local" + + def __init__(self, root: Union[str, Path]) -> None: + self.root = Path(root).resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> Path: + path = self.root / normalize_key(key) + resolved = path.resolve() + if resolved != self.root and self.root not in resolved.parents: + raise StorageKeyError(f"key leaves the storage root: {key!r}") + return path + + # ── writing ──────────────────────────────────────────────────────────────── + + @contextmanager + def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]: + path = self._path(key) + if not overwrite and path.exists(): + raise FileExistsError(normalize_key(key)) + path.parent.mkdir(parents=True, exist_ok=True) + # Same directory as the target, so the final rename is atomic on every filesystem. + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".part", dir=str(path.parent)) + tmp = Path(tmp_name) + handle = os.fdopen(fd, "wb") + try: + yield handle + handle.flush() + os.fsync(handle.fileno()) + handle.close() + os.replace(tmp, path) + except BaseException: + try: + handle.close() + except Exception: + pass + tmp.unlink(missing_ok=True) + raise + + def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject: + with self.open_write(key, overwrite=overwrite) as out: + if hasattr(data, "read"): + shutil.copyfileobj(data, out, _COPY_CHUNK) # type: ignore[arg-type] + else: + out.write(data) # type: ignore[arg-type] + return StoredObject(self.name, normalize_key(key), self._path(key).stat().st_size) + + def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject: + with self.open(src_key) as src: + return self.put(dst_key, src, overwrite=overwrite) + + # ── reading ──────────────────────────────────────────────────────────────── + + def open(self, key: str) -> BinaryIO: + path = self._path(key) + if not path.is_file(): + raise FileNotFoundError(normalize_key(key)) + return path.open("rb") + + def exists(self, key: str) -> bool: + return self._path(key).is_file() + + def size(self, key: str) -> int: + path = self._path(key) + if not path.is_file(): + raise FileNotFoundError(normalize_key(key)) + return path.stat().st_size + + def local_path(self, key: str) -> Path: + return self._path(key) + + # ── removing ─────────────────────────────────────────────────────────────── + + def delete(self, key: str) -> bool: + path = self._path(key) + if not path.is_file(): + return False + path.unlink() + # Empty folders the object leaves behind go too — never the root itself. + parent = path.parent + while parent != self.root and self.root in parent.parents: + try: + if any(parent.iterdir()): + break + parent.rmdir() + except OSError: + break + parent = parent.parent + return True diff --git a/backend/app/media_storage/s3.py b/backend/app/media_storage/s3.py new file mode 100644 index 0000000000000000000000000000000000000000..5453db82998814b2089798fb2529df14ecd9a10e --- /dev/null +++ b/backend/app/media_storage/s3.py @@ -0,0 +1,265 @@ +""" +S3Storage — media bytes in any S3-compatible object store, behind MediaStorage. + +S3 is the protocol, not a vendor: MinIO or AIStor Free, Garage, SeaweedFS, RustFS, AWS, a +Hugging Face bucket's S3 layer — anything that answers SigV4 PUT/GET/HEAD/DELETE/COPY. +boto3 is imported only here, when this backend is selected, so a local HomePilot needs +neither the library nor a server. + +Keys are the same relative paths as the local backend (an optional S3_PREFIX in front), so +the registries do not change when the backend does. A PUT is atomic on every S3 server — an +object appears only once its upload completed — which is the ``open_write`` contract: a +stream is spooled locally and uploaded when its block ends, dropped when it raises. +""" +from __future__ import annotations + +import mimetypes +import os +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, BinaryIO, Iterator, Mapping, Optional, Union + +from .base import Bytes, MediaStorage, StoredObject, normalize_key + +_MISSING_CODES = {"404", "NoSuchKey", "NotFound"} +_SPOOL_MAX = 8 * 1024 * 1024 + + +def normalize_prefix(prefix: str) -> str: + """"media/" for "media", "/media//" and so on; "" for none.""" + clean = "/".join(p for p in (prefix or "").replace("\\", "/").split("/") if p and p != ".") + return f"{clean}/" if clean else "" + + +@dataclass(frozen=True) +class S3Settings: + bucket: str + endpoint_url: Optional[str] = None # None: AWS; "http://127.0.0.1:9400": a self-hosted server + region: str = "us-east-1" + access_key: Optional[str] = None # None: boto3's own credential chain (env, profile, role) + secret_key: Optional[str] = None + addressing_style: str = "path" # path for self-hosted servers; virtual for AWS-style hosts + prefix: str = "" # key prefix inside the bucket, "" or "name/" + create_bucket: bool = True # make the bucket on first use when it is missing + verify_tls: bool = True + + def __post_init__(self) -> None: + # "media", "/media//" and "media/" are the same prefix; a frozen dataclass sets it so. + object.__setattr__(self, "prefix", normalize_prefix(self.prefix)) + object.__setattr__(self, "bucket", (self.bucket or "").strip()) + + @classmethod + def from_env(cls, env: Mapping[str, str] = os.environ) -> "S3Settings": + def get(name: str, default: str = "") -> str: + return (env.get(name) or default).strip() + + def flag(name: str, default: str = "1") -> bool: + return get(name, default).lower() not in ("0", "false", "no", "off") + + return cls( + bucket=get("S3_BUCKET", "homepilot-media"), + endpoint_url=get("S3_ENDPOINT_URL") or None, + region=get("S3_REGION", "us-east-1") or "us-east-1", + access_key=get("S3_ACCESS_KEY_ID") or get("AWS_ACCESS_KEY_ID") or None, + secret_key=get("S3_SECRET_ACCESS_KEY") or get("AWS_SECRET_ACCESS_KEY") or None, + addressing_style=(get("S3_ADDRESSING_STYLE", "path").lower() or "path"), + prefix=normalize_prefix(get("S3_PREFIX")), + create_bucket=flag("S3_CREATE_BUCKET"), + verify_tls=flag("S3_VERIFY_TLS"), + ) + + def signature(self) -> tuple: + """What distinguishes one configured store from another (credentials excluded).""" + return ("s3", self.endpoint_url or "", self.bucket, self.prefix, self.region, self.addressing_style) + + +def _is_missing(error: BaseException) -> bool: + response = getattr(error, "response", None) or {} + code = str((response.get("Error") or {}).get("Code") or "") + status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode") + return code in _MISSING_CODES or status == 404 + + +def _content_type(key: str) -> str: + return mimetypes.guess_type(key)[0] or "application/octet-stream" + + +def _build_client(settings: S3Settings) -> Any: + try: + import boto3 + from botocore.config import Config + except ImportError as e: # pragma: no cover - depends on the install + raise RuntimeError( + "MEDIA_STORAGE_BACKEND=s3 needs boto3: in backend/, `uv pip install -e '.[s3]'` " + "(or `pip install 'boto3>=1.34'`)." + ) from e + config = Config( + signature_version="s3v4", + s3={"addressing_style": settings.addressing_style}, + retries={"max_attempts": 3, "mode": "standard"}, + connect_timeout=5, + read_timeout=60, + ) + return boto3.client( + "s3", + endpoint_url=settings.endpoint_url, + region_name=settings.region, + aws_access_key_id=settings.access_key, + aws_secret_access_key=settings.secret_key, + config=config, + verify=settings.verify_tls, + ) + + +class _Body: + """A GET body as a plain binary stream (boto3's StreamingBody is not a context manager).""" + + def __init__(self, body: Any) -> None: + self._body = body + + def read(self, size: int = -1) -> bytes: + return self._body.read() if size is None or size < 0 else self._body.read(size) + + def close(self) -> None: + close = getattr(self._body, "close", None) + if close: + close() + + def __enter__(self) -> "_Body": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def __iter__(self) -> Iterator[bytes]: + while True: + chunk = self.read(1024 * 1024) + if not chunk: + return + yield chunk + + +class S3Storage(MediaStorage): + name = "s3" + + def __init__(self, settings: S3Settings, *, client: Any = None) -> None: + if not settings.bucket: + raise ValueError("S3_BUCKET is required for MEDIA_STORAGE_BACKEND=s3") + self.settings = settings + self._client = client # injected in tests; built lazily otherwise + self._bucket_ready = False + + # ── plumbing ─────────────────────────────────────────────────────────────── + + @property + def client(self) -> Any: + if self._client is None: + self._client = _build_client(self.settings) + return self._client + + @property + def bucket(self) -> str: + return self.settings.bucket + + def _ensure_bucket(self) -> None: + if self._bucket_ready: + return + try: + self.client.head_bucket(Bucket=self.bucket) + except Exception as e: + if not (_is_missing(e) and self.settings.create_bucket): + raise + kwargs: dict = {"Bucket": self.bucket} + if not self.settings.endpoint_url and self.settings.region != "us-east-1": + kwargs["CreateBucketConfiguration"] = {"LocationConstraint": self.settings.region} + self.client.create_bucket(**kwargs) + self._bucket_ready = True + + def _key(self, key: str) -> str: + return self.settings.prefix + normalize_key(key) + + def _head(self, object_key: str) -> Optional[dict]: + try: + return self.client.head_object(Bucket=self.bucket, Key=object_key) + except Exception as e: + if _is_missing(e): + return None + raise + + # ── writing ──────────────────────────────────────────────────────────────── + + def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject: + self._ensure_bucket() + object_key = self._key(key) + if not overwrite and self._head(object_key) is not None: + raise FileExistsError(normalize_key(key)) + content_type = _content_type(key) + if hasattr(data, "read"): + self.client.upload_fileobj(data, self.bucket, object_key, ExtraArgs={"ContentType": content_type}) + size = int((self._head(object_key) or {}).get("ContentLength") or 0) + else: + payload = bytes(data) # type: ignore[arg-type] + self.client.put_object(Bucket=self.bucket, Key=object_key, Body=payload, ContentType=content_type) + size = len(payload) + return StoredObject(self.name, normalize_key(key), size) + + @contextmanager + def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]: + self._ensure_bucket() + object_key = self._key(key) + if not overwrite and self._head(object_key) is not None: + raise FileExistsError(normalize_key(key)) + spool = tempfile.SpooledTemporaryFile(max_size=_SPOOL_MAX) + try: + yield spool # type: ignore[misc] + spool.seek(0) + self.client.upload_fileobj(spool, self.bucket, object_key, ExtraArgs={"ContentType": _content_type(key)}) + finally: + spool.close() + + def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject: + self._ensure_bucket() + src, dst = self._key(src_key), self._key(dst_key) + if self._head(src) is None: + raise FileNotFoundError(normalize_key(src_key)) + if not overwrite and self._head(dst) is not None: + raise FileExistsError(normalize_key(dst_key)) + self.client.copy_object( + Bucket=self.bucket, Key=dst, CopySource={"Bucket": self.bucket, "Key": src}, + ContentType=_content_type(dst_key), MetadataDirective="REPLACE", + ) + return StoredObject(self.name, normalize_key(dst_key), int((self._head(dst) or {}).get("ContentLength") or 0)) + + # ── reading ──────────────────────────────────────────────────────────────── + + def open(self, key: str) -> BinaryIO: + self._ensure_bucket() + try: + response = self.client.get_object(Bucket=self.bucket, Key=self._key(key)) + except Exception as e: + if _is_missing(e): + raise FileNotFoundError(normalize_key(key)) from None + raise + return _Body(response["Body"]) # type: ignore[return-value] + + def exists(self, key: str) -> bool: + self._ensure_bucket() + return self._head(self._key(key)) is not None + + def size(self, key: str) -> int: + self._ensure_bucket() + head = self._head(self._key(key)) + if head is None: + raise FileNotFoundError(normalize_key(key)) + return int(head.get("ContentLength") or 0) + + # ── removing ─────────────────────────────────────────────────────────────── + + def delete(self, key: str) -> bool: + self._ensure_bucket() + object_key = self._key(key) + if self._head(object_key) is None: + return False + self.client.delete_object(Bucket=self.bucket, Key=object_key) + return True diff --git a/backend/app/media_validate.py b/backend/app/media_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..06f85da91519ef5a78b3a607596197eb2f26572b --- /dev/null +++ b/backend/app/media_validate.py @@ -0,0 +1,81 @@ +""" +What an upload must be before it is stored (docs/design/MEDIA_STORAGE.md, Phase 4). + +- A file presented as an image (by MIME type or extension) must start like one: PNG, JPEG, + GIF, WebP or BMP. Anything else posing as an image is refused with 400 — before any bytes + reach storage. +- ``MEDIA_SCAN_COMMAND`` (unset by default) names a scanner, e.g. ``clamdscan --no-summary``: + it runs on a temporary copy with the path appended, and a non-zero exit refuses the file. + That is the quarantine: nothing is stored until the scanner has said yes. +""" +from __future__ import annotations + +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from fastapi import HTTPException + +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"} +_SIGNATURES = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), + (b"BM", "image/bmp"), +) +HEAD_BYTES = 16 + + +def sniff_image(head: bytes) -> Optional[str]: + """The image type the first bytes say, or None.""" + head = bytes(head or b"") + for magic, mime in _SIGNATURES: + if head.startswith(magic): + return mime + if head[:4] == b"RIFF" and head[8:12] == b"WEBP": + return "image/webp" + return None + + +def claims_image(declared_mime: str, filename: str) -> bool: + mime = (declared_mime or "").split(";", 1)[0].strip().lower() + ext = Path(filename or "").suffix.lower() + return mime.startswith("image/") or ext in IMAGE_EXTS + + +def check_image_claim(head: bytes, declared_mime: str, filename: str) -> None: + """400 when the file says it is an image and its bytes say it is not.""" + if claims_image(declared_mime, filename) and sniff_image(head) is None: + raise HTTPException( + status_code=400, + detail=f"{filename or 'The file'} is not the image it claims to be; PNG, JPEG, GIF, WebP and BMP are accepted.", + ) + + +def scan_command() -> list: + raw = (os.getenv("MEDIA_SCAN_COMMAND") or "").strip() + return shlex.split(raw) if raw else [] + + +def scan_bytes(data: bytes, filename: str = "") -> None: + """Run the configured scanner on a temporary copy; 400 when it objects. No-op when none is set.""" + command = scan_command() + if not command: + return + suffix = Path(filename or "").suffix[:16] + with tempfile.NamedTemporaryFile(prefix="hp-scan-", suffix=suffix, delete=False) as tmp: + tmp.write(data) + path = tmp.name + try: + result = subprocess.run(command + [path], capture_output=True, text=True, timeout=120) + except (OSError, subprocess.TimeoutExpired) as e: + os.unlink(path) + raise HTTPException(status_code=503, detail=f"The upload scanner could not run: {e}") from e + os.unlink(path) + if result.returncode != 0: + print(f"[MEDIA] scanner refused {filename!r}: exit {result.returncode} {result.stdout.strip()[:200]}") + raise HTTPException(status_code=400, detail=f"{filename or 'The file'} was refused by the upload scanner.") diff --git a/backend/app/model_advisor/hardware.py b/backend/app/model_advisor/hardware.py index 22a793002a5724516e5fd6d4c1aaa7b4d292dcc5..e70cf9d39542d584c85a29bd0db6e13a2af4634b 100644 --- a/backend/app/model_advisor/hardware.py +++ b/backend/app/model_advisor/hardware.py @@ -81,7 +81,14 @@ def _nvidia() -> Optional[Dict[str, Any]]: "note": "" if bw else "Unlisted GPU — speed estimates are unavailable; fit is still exact."} -def _total_ram_gb() -> float: +# The memory limit of the container HomePilot runs in (Docker, Kubernetes, a Hugging Face +# Space) — cgroup v2, then v1. psutil reports the host's RAM: a 16 GB Space read 132.9 GB, +# and FitLab called a 20B model a fit for it. +CGROUP_ROOT = Path("/sys/fs/cgroup") +_CGROUP_LIMIT_FILES = ("memory.max", "memory/memory.limit_in_bytes") + + +def _host_ram_gb() -> float: try: import psutil return psutil.virtual_memory().total / 1e9 @@ -89,6 +96,30 @@ def _total_ram_gb() -> float: return 0.0 +def _cgroup_limit_gb(root: Optional[Path] = None) -> Optional[float]: + """The memory limit of this process's cgroup, in GB; None when there is none.""" + for name in _CGROUP_LIMIT_FILES: + try: + raw = ((root or CGROUP_ROOT) / name).read_text(encoding="utf-8").strip() + except OSError: + continue + if not raw.isdigit(): # "max": no limit at this level + continue + limit = int(raw) + if 0 < limit < 1 << 60: # v1 writes a near-2^63 sentinel when unlimited + return limit / 1e9 + return None + + +def _total_ram_gb() -> float: + """Memory this HomePilot can use: the host's RAM, or the container's limit when smaller.""" + host = _host_ram_gb() + limit = _cgroup_limit_gb() + if limit is not None and (not host or limit < host): + return limit + return host + + def _apple() -> Optional[Dict[str, Any]]: if platform.system() != "Darwin" or platform.machine() != "arm64": return None @@ -108,10 +139,17 @@ def detect() -> Dict[str, Any]: if hw: return {**hw, "detected": True} ram = _total_ram_gb() - return {"kind": "cpu", "name": platform.processor() or platform.machine() or "CPU", - "vram_gb": 0.0, "ram_gb": round(ram, 1), "bandwidth_gbs": CPU_BANDWIDTH_GBS, - "profile_id": "cpu-only", "detected": True, - "note": "No GPU found — chat models are sized for system RAM; image and video need a GPU."} + host = _host_ram_gb() + cpu = {"kind": "cpu", "name": platform.processor() or platform.machine() or "CPU", + "vram_gb": 0.0, "ram_gb": round(ram, 1), "bandwidth_gbs": CPU_BANDWIDTH_GBS, + "profile_id": "cpu-only", "detected": True, + "note": "No GPU found — chat models are sized for system RAM; image and video need a GPU."} + if host and ram < host - 0.5: + # In a container: its limit is what counts, and the page says so. + cpu.update(host_ram_gb=round(host, 1), ram_source="container", + note=f"No GPU found — chat models are sized for this container's {ram:.0f} GB memory " + f"limit (the host has {host:.0f} GB); image and video need a GPU.") + return cpu def with_override(hw: Dict[str, Any], vram_gb: Optional[float]) -> Dict[str, Any]: diff --git a/backend/app/model_resolver.py b/backend/app/model_resolver.py index cd838c9618ecc0b953a2ceeca9e33c07724ea608..5ee30d77786a658aaf8d7f57c4399cfb2cad73eb 100644 --- a/backend/app/model_resolver.py +++ b/backend/app/model_resolver.py @@ -37,6 +37,7 @@ import dataclasses import logging import os import re +import subprocess import time from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional, Tuple @@ -50,8 +51,14 @@ log = logging.getLogger("homepilot.models") Role = Literal["chat", "fast", "vision", "embed"] ROLES: Tuple[str, ...] = ("chat", "fast", "vision", "embed") -PROBE_TIMEOUT_S = float(os.getenv("MODEL_DISCOVERY_TIMEOUT_S", "1.5")) +# 3 s: Ollama answers /api/tags in tens of milliseconds, but not while it loads a model or the +# machine is short of memory (ComfyUI loading a checkpoint) — 1.5 s reported it missing then. +# The candidates are probed in parallel, so a down Ollama still costs one timeout, not several. +PROBE_TIMEOUT_S = float(os.getenv("MODEL_DISCOVERY_TIMEOUT_S", "3")) CACHE_TTL_S = float(os.getenv("MODEL_DISCOVERY_TTL_S", "30")) +# "Nothing answered" is remembered briefly, so a hiccup clears itself on the next look instead +# of holding the "Ollama unavailable" banner for the full cache lifetime. +NEGATIVE_TTL_S = float(os.getenv("MODEL_DISCOVERY_NEGATIVE_TTL_S", "5")) # ── Model classification (single source of truth; llm.py re-exports) ────────── VISION_ONLY_HINTS = ( @@ -224,6 +231,29 @@ def is_docker() -> bool: return os.path.exists("/.dockerenv") or "docker" in _read_text("/proc/1/cgroup") +_WSL_MODE: Dict[str, str] = {} + + +def wsl_networking_mode() -> str: + """``mirrored`` or ``nat`` (WSL 2), ``""`` outside WSL or when it cannot be told. In + mirrored mode WSL shares Windows' loopback, so Ollama on Windows' 127.0.0.1 is reachable as + 127.0.0.1 here and the "enable mirrored networking" advice does not apply.""" + if "mode" in _WSL_MODE: + return _WSL_MODE["mode"] + mode = "" + if is_wsl(): + try: + out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=2) + mode = (out.stdout or "").strip().lower() + except (OSError, subprocess.SubprocessError): + mode = "" + if mode not in ("mirrored", "nat"): + # Mirrored mode puts the host's loopback alias 10.255.255.254 on lo. + mode = "mirrored" if "10.255.255.254" in _read_text("/proc/net/fib_trie") else "nat" + _WSL_MODE["mode"] = mode + return mode + + def _default_gateway_from_proc() -> str: """Default route gateway from ``/proc/net/route`` (little-endian hex).""" text = _read_text("/proc/net/route") @@ -279,7 +309,9 @@ def candidate_urls(explicit: Optional[str] = None) -> List[Tuple[str, str]]: add("configured", OLLAMA_BASE_URL) add("loopback", "http://127.0.0.1:11434") add("loopback", "http://localhost:11434") - if is_wsl(): + if is_wsl() and wsl_networking_mode() != "mirrored": + # NAT mode only: with mirrored networking Windows' Ollama is 127.0.0.1 here, and the + # gateway / nameserver addresses are the LAN router, which never runs Ollama. for ip in wsl_host_ips(): add("wsl-host", f"http://{ip}:11434") if is_docker(): @@ -379,14 +411,21 @@ def invalidate(reason: str = "") -> None: log.info("model discovery cache invalidated: %s", reason) +def _fresh(ts: float, runtimes: List[Runtime], now: float) -> bool: + """A cached probe result still holds: ``CACHE_TTL_S`` when something answered, + ``NEGATIVE_TTL_S`` when nothing did (a hiccup must not stick).""" + ttl = CACHE_TTL_S if any(r.reachable for r in runtimes) else NEGATIVE_TTL_S + return now - ts < ttl + + async def discover(force: bool = False) -> List[Runtime]: - """All candidate runtimes with their probe results, cached ``CACHE_TTL_S``.""" + """All candidate runtimes with their probe results, cached (see :func:`_fresh`).""" now = time.monotonic() - if not force and _cache["runtimes"] and now - _cache["ts"] < CACHE_TTL_S: + if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now): return list(_cache["runtimes"]) async with _get_lock(): now = time.monotonic() - if not force and _cache["runtimes"] and now - _cache["ts"] < CACHE_TTL_S: + if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now): return list(_cache["runtimes"]) cands = candidate_urls() 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: key = url.rstrip("/") now = time.monotonic() hit = _url_cache.get(key) - if hit and not force and now - hit[0] < CACHE_TTL_S: + if hit and not force and _fresh(hit[0], [hit[1]], now): return hit[1] rt = await probe(key, "explicit") _url_cache[key] = (time.monotonic(), rt) @@ -429,10 +468,35 @@ def pick_runtime(runtimes: List[Runtime]) -> Optional[Runtime]: return reachable[0] +def ollama_root(url: str) -> str: + """The Ollama root a URL points at: Ollama serves its own API (``/api/tags``) and the + OpenAI-compatible one (``/v1``) from the same root, so ``http://host:11434/v1`` — what an + OpenAI-style "Base URL" field holds — means ``http://host:11434``.""" + u = (url or "").strip().rstrip("/") + for suffix in ("/v1", "/api"): + if u.lower().endswith(suffix): + u = u[: -len(suffix)] + return u + + async def active_runtime(base_url: Optional[str] = None, force: bool = False) -> Tuple[Optional[Runtime], List[Runtime]]: + """The Ollama to use. An explicit ``base_url`` wins when it is an Ollama (or answers like one, + a proxy that lists no tags). When it does not answer as Ollama at all — an OpenAI-compatible + gateway such as OllaBridge (404 on ``/api/tags``), or nothing — the usual places are searched + instead of reporting Ollama missing while it runs on its own port.""" if base_url: - rt = await discover_url(base_url, force=force) - return (rt if rt.reachable else None), [rt] + root = ollama_root(base_url) + rt = await discover_url(root, force=force) + if rt.reachable: + return rt, [rt] + if os.getenv("OLLAMA_DISCOVERY", "auto").strip().lower() in ("explicit", "strict"): + return None, [rt] + runtimes = [r for r in await discover(force=force) if r.url != rt.url] + found = pick_runtime(runtimes) + if found is not None: + log.info("model discovery: %s is not an Ollama runtime (%s); using Ollama at %s", + base_url, rt.error or "no /api/tags", found.url) + return found, [rt, *runtimes] runtimes = await discover(force=force) return pick_runtime(runtimes), runtimes @@ -543,6 +607,21 @@ def build_remedy(role: str, runtime: Optional[Runtime], runtimes: List[Runtime], } probed = [r.url for r in runtimes] wsl_hosts = [r for r in runtimes if r.label == "wsl-host"] + if is_wsl() and wsl_networking_mode() == "mirrored": + # WSL shares Windows' loopback: a Windows Ollama would have answered on 127.0.0.1, so + # it is stopped, starting, or too busy to answer — not unreachable. + return { + "kind": "start", + "message": ( + "Ollama did not answer. WSL uses mirrored networking, so an Ollama running on Windows " + "is reachable here: it is probably stopped, still starting, or busy loading a model. " + "Start the Ollama app on Windows (if `ollama serve` says the port is already in use, " + "it is running), wait a few seconds, then press Check again." + ), + "command": "ollama serve", + "probed": probed, + "suggested": suggested, + } if is_wsl() and wsl_hosts: return { "kind": "wsl_bridge", @@ -596,6 +675,9 @@ async def resolve(role: str, configured: str = "", *, base_url: Optional[str] = model, source = "", "none" urls = ", ".join(r.url for r in runtimes) or (base_url or OLLAMA_BASE_URL) reason = f"No Ollama runtime is reachable (tried {urls})." + elif base_url and runtime.url != ollama_root(base_url): + reason = (f"{base_url} is not an Ollama runtime (an OpenAI-compatible gateway such as " + f"OllaBridge?); found Ollama at {runtime.url}. " + reason) res = Resolution( role=role, configured=cfg, diff --git a/backend/app/projects.py b/backend/app/projects.py index 7fd935b5c601a3102659c9fc2b7a539252e12a36..396dabab54d707c000dcee7ad29b2accb6d052b6 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -1150,7 +1150,11 @@ You have access to the project's context. When relevant context from the knowled # 5b. Hybrid intent detection — inject dynamic hint for photo-related messages # so the LLM gets precise guidance on what to do, without bypassing its reasoning. - if project_data and project_data.get("project_type") == "persona": + # Short messages only: a pasted document's "back" or "first" is not a request to turn + # around or show photo 1 (intent_guard.py). + from .intent_guard import is_short_command as _is_short_command + if (project_data and project_data.get("project_type") == "persona" + and _is_short_command(payload.get("message") or "")): import re as _hint_re _user_msg = (payload.get("message") or "").lower().strip() diff --git a/backend/app/storage.py b/backend/app/storage.py index 2b135db7df2f6afc11d58d72b88b42bc1cc5c9fd..a4bbd4c4921bac044d611befa93dc4c8120dd6eb 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -277,11 +277,43 @@ def init_db(): cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user ON file_assets(user_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user_kind ON file_assets(user_id, kind)") cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_project ON file_assets(project_id)") + ensure_file_assets_columns(con) con.commit() con.close() +# Columns file_assets gained after its first shape (docs/design/MEDIA_STORAGE.md). Each is added +# idempotently with a default that is true for every row written before it existed. +_FILE_ASSET_COLUMNS = ( + ("storage_backend", "TEXT NOT NULL DEFAULT 'local'"), # which media backend holds the bytes (Phase 3a) + ("sha256", "TEXT NOT NULL DEFAULT ''"), # of the bytes as written; '' when unknown (Phase 3) + ("visibility", "TEXT NOT NULL DEFAULT 'private'"), # 'private' | 'public' (Phase 4) + ("deleted_at", "TEXT"), # soft deletion; bytes go when the trash is purged (Phase 4) +) + + +def ensure_file_assets_columns(con: sqlite3.Connection) -> list: + """Add whichever of the later file_assets columns this database lacks. Returns their names.""" + cur = con.cursor() + present = {row[1] for row in cur.execute("PRAGMA table_info(file_assets)").fetchall()} + if not present: + return [] + added = [] + for name, spec in _FILE_ASSET_COLUMNS: + if name not in present: + cur.execute(f"ALTER TABLE file_assets ADD COLUMN {name} {spec}") + added.append(name) + if added: + con.commit() + return added + + +def ensure_file_assets_backend_column(con: sqlite3.Connection) -> bool: + """Phase 3a's entry point, kept: True when storage_backend was just added.""" + return "storage_backend" in ensure_file_assets_columns(con) + + def ensure_conversation_owner(conversation_id: str, user_id: str) -> None: """Set owner on first use (idempotent).""" if not conversation_id or not user_id: diff --git a/backend/app/studio/bundles.py b/backend/app/studio/bundles.py index 6feae0cf8c8338c54ae02495b61ab50289e059af..a934e07f79bb749369c5aac638ed202d0507f71d 100644 --- a/backend/app/studio/bundles.py +++ b/backend/app/studio/bundles.py @@ -300,7 +300,8 @@ def bundle_install_status(bundle: EssayVideoBundle) -> Dict[str, Any]: # ── Installation (reinstall-safe: download.py skips files on disk) ────────── def _download_script() -> Path: - return Path(__file__).resolve().parents[3] / "scripts" / "download.py" + from ..download_script import download_script # the repo's, or a packaged image's copy + return download_script() def install_plan(bundle: EssayVideoBundle) -> List[List[str]]: diff --git a/backend/app/users.py b/backend/app/users.py index c535b5762b5c53fdbb57ab889b7d2761290e5c23..44a8e6f7004a8dff861e47f63ee405f94913b9ef 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -1131,19 +1131,13 @@ async def upload_avatar( if len(contents) > 5 * 1024 * 1024: raise HTTPException(400, "Avatar image must be under 5MB") - # Determine upload directory (reuse existing upload path logic) - upload_dir = UPLOAD_DIR - if not os.path.isabs(upload_dir): - import pathlib - _backend_dir = pathlib.Path(__file__).resolve().parents[1] - upload_dir = str(_backend_dir / "data" / "uploads") - os.makedirs(upload_dir, exist_ok=True) - - # Save with unique name - filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}{ext}" - filepath = os.path.join(upload_dir, filename) - with open(filepath, "wb") as f: - f.write(contents) + # Save with a unique name. The flat key keeps today's /files/ URL — and its + # public-by-prefix serving (docs/design/MEDIA_STORAGE.md, audit). + from .media_storage import get_storage + storage = get_storage() + # A random name: the picture is public on the login screen, the account id is not its business. + filename = f"avatar_{uuid.uuid4().hex[:16]}{ext}" + storage.put(filename, contents) # Build URL (relative to /files/ static mount) avatar_url = f"/files/{filename}" @@ -1151,10 +1145,8 @@ async def upload_avatar( # Delete old avatar file if it exists (cleanup) old_url = user.get("avatar_url", "") if old_url and old_url.startswith("/files/"): - old_path = os.path.join(upload_dir, old_url.replace("/files/", "", 1)) try: - if os.path.exists(old_path): - os.remove(old_path) + storage.delete(old_url.replace("/files/", "", 1)) except Exception: pass # Non-fatal diff --git a/backend/requirements.txt b/backend/requirements.txt index 569875cb816b2bd53790c43b94de8a969331ce25..d5a145bc15f6862a3d64278fa0edfee3b94dee6f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,3 +24,6 @@ faster-whisper==1.2.1 # PyAV 19 dropped an argument faster-whisper 1.2.1 passes; see requirements/speech-cpu.txt. av==18.1.0 ctranslate2==4.8.2 +# Media in an S3-compatible object store (MEDIA_STORAGE_BACKEND=s3; docs/design/MEDIA_STORAGE.md). +# Imported only when that backend is selected; CI and the packaged images install this file. +boto3>=1.34 diff --git a/deploy/huggingface-space/hf_wrapper.py b/deploy/huggingface-space/hf_wrapper.py index 628b54699fa6e387d7200baa7e0d67065f2a6321..c9c085d81d1f762033a39a64672d62d0c5a8f159 100644 --- a/deploy/huggingface-space/hf_wrapper.py +++ b/deploy/huggingface-space/hf_wrapper.py @@ -18,6 +18,7 @@ import uvicorn from fastapi import Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.routing import Match # Ensure the app module is importable. sys.path.insert(0, "/app") @@ -155,9 +156,48 @@ def frontend_catchall(full_path: str, request: Request): return FileResponse(FRONTEND_INDEX) +@app.api_route("/{full_path:path}", methods=["POST", "PUT", "PATCH", "DELETE"], include_in_schema=False) +def api_catchall(full_path: str): + """An API route that is not there is a 404. Without this, a POST to a path only the GET + catch-all above knew came back "405 Method Not Allowed" — which is how a missing Expert + package showed up in the chat as `HTTP 405` after "Hello".""" + return JSONResponse({"detail": "Not Found", "path": f"/{full_path}"}, status_code=404) + + +# ── Route check ────────────────────────────────────────── +# What the UI and external clients call. A missing one is logged at boot, instead of being +# found by a user as an HTTP error (backend/tests/test_required_routes.py checks the same). + +REQUIRED_ROUTES = ( + ("GET", "/health"), + ("POST", "/chat"), + ("POST", "/v1/chat/completions"), + ("GET", "/v1/models"), + ("POST", "/models/install"), + ("GET", "/models/install/progress"), + ("POST", "/v1/expert/chat"), +) + + +def missing_routes(application=app): + """Required routes the app does not serve — asked of the router's own matching, so routers + FastAPI includes lazily count too, and the two catch-alls above do not.""" + catchalls = {frontend_catchall, api_catchall} + missing = [] + for method, path in REQUIRED_ROUTES: + scope = {"type": "http", "method": method, "path": path, "root_path": "", + "query_string": b"", "headers": [], "path_params": {}, "app": application} + if not any(getattr(r, "endpoint", None) not in catchalls and r.matches(scope)[0] == Match.FULL + for r in application.router.routes): + missing.append(f"{method} {path}") + return missing + + # ── Run ────────────────────────────────────────────────── if __name__ == "__main__": + for _route in missing_routes(): + print(f"[routes] ⚠ missing: {_route}", flush=True) port = int(os.environ.get("PORT", "7860")) uvicorn.run( app, diff --git a/expert/backend/app/expert/__init__.py b/expert/backend/app/expert/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9088d9ce5827bc64ddd385bfe4676c5e2473cc6a --- /dev/null +++ b/expert/backend/app/expert/__init__.py @@ -0,0 +1,5 @@ +# expert — multi-provider AI intelligence add-on for HomePilot +# Additive module: no existing files modified except 2-line patch in main.py +from .routes import router + +__all__ = ["router"] diff --git a/expert/backend/app/expert/capability.py b/expert/backend/app/expert/capability.py new file mode 100644 index 0000000000000000000000000000000000000000..4f2be2ab9c3ccc05b92734b8d54be218c382cb4e --- /dev/null +++ b/expert/backend/app/expert/capability.py @@ -0,0 +1,275 @@ +"""What the connected models can carry — the gate for Deep research and Heavy's escalation. + +A deep-agent run (plan, tool calls over many rounds, long synthesis) needs a strong, +tool-calling model; a 3B local model loops and hallucinates calls. So the strength of the +models on the user's connection is measured, not assumed: + +* Ollama: ``/api/tags`` says each model's parameter count. +* OpenAI-compatible gateways (OllaBridge and friends): ``/models`` lists ids; the names carry + the hints — a size (``gemma4-31b``, ``gpt-oss-120b``) or a frontier family (``claude``, + ``gemini``, ``gpt``, ``glm-5``, ``kimi``, ``deepseek-v4``). + +Tiers: ``high`` (frontier family, or ≥ EXPERT_DEEP_MIN_PARAMS_B parameters), ``medium`` +(7B and up), ``low``. Deep research is offered when the best model on the connection is +``high`` (``EXPERT_DEEP_RESEARCH=auto``); ``always`` / ``off`` override. +""" +from __future__ import annotations + +import json +import re +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional + +from .config import EXPERT_CAPABILITY_TTL_S, EXPERT_DEEP_MIN_PARAMS_B, EXPERT_DEEP_RESEARCH, EXPERT_LOCAL_MODEL +from . import providers +from . import state_store +from . import target as target_mod +from .target import ChatTarget + +TIER_RANK = {"low": 0, "medium": 1, "high": 2} + +# Frontier families: strong and tool-calling whatever the size suffix says. +_FRONTIER = re.compile( + r"(claude|openai|gpt-?[45o]|gpt-oss|\bo[134]\b|gemini|grok|kimi|glm-?[45]|deepseek-?v[3-9]|" + r"mistral-large|command-?a|llama-?4|qwen3?-?(?:235|max|coder-?480)|minimax|muse)", + re.I, +) +# Families that call tools reliably once they are big enough. +_TOOL_FAMILIES = re.compile( + r"(qwen2\.5|qwen3|llama-?3\.[1-9]|llama-?4|mistral|mixtral|gpt-oss|command|hermes|granite|" + r"phi-?4|deepseek-?v[3-9]|glm|gemma-?[34]|kimi|claude|openai|gpt|gemini|grok|muse)", + re.I, +) +# Reasoning distills that answer well but do not call tools (Ollama rejects the field). +_NO_TOOLS = re.compile(r"deepseek-r1", re.I) +_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*b\b", re.I) + +_CACHE: Dict[str, Dict[str, Any]] = {} +_CACHE_TTL_S = 30.0 + + +@dataclass +class ModelProfile: + name: str + params_b: Optional[float] + tier: str + tools_likely: bool + reason: str + + @property + def rank(self) -> int: + return TIER_RANK.get(self.tier, 0) + + def as_dict(self) -> Dict[str, Any]: + d = asdict(self) + d["rank"] = self.rank + return d + + +def params_from_name(name: str) -> Optional[float]: + """``internal-qwen3-8-27b`` → 27, ``gpt-oss-120b`` → 120, ``deepseek-r1:latest`` → None.""" + hits = [float(m.group(1)) for m in _SIZE.finditer(name or "")] + return max(hits) if hits else None + + +def profile(name: str, params_b: Optional[float] = None) -> ModelProfile: + params = params_b if params_b is not None else params_from_name(name) + frontier = bool(_FRONTIER.search(name or "")) + no_tools = bool(_NO_TOOLS.search(name or "")) + tools_likely = (frontier or bool(_TOOL_FAMILIES.search(name or ""))) and not no_tools + if frontier and (params is None or params >= EXPERT_DEEP_MIN_PARAMS_B or not _SIZE.search(name or "")): + return ModelProfile(name, params, "high", tools_likely, "frontier family") + if params is not None and params >= EXPERT_DEEP_MIN_PARAMS_B: + return ModelProfile(name, params, "high", tools_likely, f"{params:g}B parameters") + if params is not None and params >= 7: + return ModelProfile(name, params, "medium", tools_likely, f"{params:g}B parameters") + if params is None: + tier = "medium" if tools_likely else "low" + return ModelProfile(name, None, tier, tools_likely, "size unknown") + return ModelProfile(name, params, "low", tools_likely, f"{params:g}B parameters") + + +def best_of(profiles: List[ModelProfile]) -> Optional[ModelProfile]: + if not profiles: + return None + return max(profiles, key=lambda p: (p.rank, 1 if p.tools_likely else 0, p.params_b or 0.0)) + + +def deep_research_enabled(best: Optional[ModelProfile], verified: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """``verified``: the probe's findings for ``best`` (see :func:`probe`); a model known to + reject tools cannot research, whatever its size.""" + mode = EXPERT_DEEP_RESEARCH + if mode == "always": + return {"enabled": True, "reason": "EXPERT_DEEP_RESEARCH=always", "model": best.name if best else None} + if mode == "off": + return {"enabled": False, "reason": "Deep research is turned off (EXPERT_DEEP_RESEARCH=off).", "model": None} + if best is None: + return {"enabled": False, "reason": "No model answered on this connection.", "model": None} + if best.tier == "high": + if verified and verified.get("tools") == "no": + # The harness runs the searches for a model that cannot call tools (deep.py). + return {"enabled": True, "model": best.name, + "reason": f"{best.name} is a high-end model ({best.reason}); it cannot call tools, so HomePilot runs the searches for it."} + return {"enabled": True, "reason": f"{best.name} is a high-end model ({best.reason}).", "model": best.name} + return { + "enabled": False, + "reason": ( + f"Deep research needs a high-end model (a frontier family or ≥ {EXPERT_DEEP_MIN_PARAMS_B:g}B); " + f"the strongest here is {best.name} ({best.reason})." + ), + "model": None, + } + + +def _connection_key(target: Optional[ChatTarget]) -> str: + if target is None: + return "ollama|" + return f"{target.provider}|{target.base_url}" + + +async def list_profiles(target: Optional[ChatTarget]) -> List[ModelProfile]: + """Every model on the connection ``target`` names (the default Ollama when None).""" + if target is None or target.is_ollama: + rows = await providers.ollama_models_detail(target.base_url if target and target.base_url else None) + return [profile(r["name"], r.get("params_b")) for r in rows if r.get("name")] + names = await providers.openai_compat_models(target.base_url, target.api_key) + return [profile(n) for n in names] + + +# ── Probes: measured facts, cached in the state store ──────────────────────── + +PING_TOOL = [{ + "type": "function", + "function": { + "name": "ping", + "description": "Reports that tool calling works. Call it with the given word.", + "parameters": {"type": "object", "properties": {"word": {"type": "string"}}, "required": ["word"]}, + }, +}] + + +def probe_key(target: Optional[ChatTarget], model: Optional[str]) -> str: + from .tool_loop import capability_key # the loop's learned facts use the same key + return capability_key(target, "local", model) + + +def verified_for(target: Optional[ChatTarget], model: Optional[str]) -> Optional[Dict[str, Any]]: + """The probe's findings for this connection and model, when they are on record. A fact the + tool loop learned by itself (a refusal, a successful call) counts as well.""" + key = probe_key(target, model) + found = state_store.kv_get(f"probe:{key}") + if found: + return found + learned = state_store.kv_get(f"tools:{key}") + if learned is not None and isinstance(learned.get("ok"), bool): + return {"tools": "yes" if learned["ok"] else "no", "json": "unknown", "source": learned.get("source", "observed"), + "probed_at": learned.get("at")} + return None + + +async def probe(target: Optional[ChatTarget], model: Optional[str], *, force: bool = False) -> Dict[str, Any]: + """Two short requests, once per model: does it return a structured tool call, and does it + follow a tiny JSON instruction. Findings are kept for EXPERT_CAPABILITY_TTL_S.""" + from .router import dispatch + from .providers import ToolsUnsupported + from .tool_loop import remember_tools + + key = probe_key(target, model) + if not force: + cached = state_store.kv_get(f"probe:{key}") + if cached: + return cached + result: Dict[str, Any] = {"tools": "unclear", "json": "no", "source": "probe", "probed_at": time.time(), "errors": []} + with target_mod.using(target): + try: + raw = await dispatch( + [{"role": "system", "content": "You have one tool, ping. When asked, call it; do not answer in prose."}, + {"role": "user", "content": "Call ping with the word \"pong\"."}], + "local", model=model, temperature=0.0, max_tokens=64, tools=PING_TOOL, + ) + calls = (((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("tool_calls") or [] + result["tools"] = "yes" if calls else "unclear" + except ToolsUnsupported as exc: + result["tools"] = "no" + result["errors"].append(str(exc)[:200]) + except Exception as exc: # noqa: BLE001 + result["tools"] = "error" + result["errors"].append(f"{type(exc).__name__}: {exc}"[:200]) + try: + raw = await dispatch( + [{"role": "user", "content": 'Reply with exactly this JSON and nothing else: {"ok": true}'}], + "local", model=model, temperature=0.0, max_tokens=32, + ) + text = str((((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "") + m = re.search(r"\{.*\}", text, flags=re.S) + result["json"] = "yes" if m and json.loads(m.group(0)).get("ok") is True else "no" + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"{type(exc).__name__}: {exc}"[:200]) + if result["tools"] in ("yes", "no"): + remember_tools(key, result["tools"] == "yes", source="probe") + if result["tools"] != "error": + state_store.kv_set(f"probe:{key}", result, ttl_s=EXPERT_CAPABILITY_TTL_S) + return result + + +async def assess(target: Optional[ChatTarget], selected_model: Optional[str] = None, *, run_probe: bool = False) -> Dict[str, Any]: + """The selected model's profile, the best one on the connection, and the Deep research gate. + ``run_probe`` measures the selected model's tool calling once (cached) instead of guessing.""" + key = _connection_key(target) + now = time.monotonic() + cached = _CACHE.get(key) + if cached and now - cached["ts"] < _CACHE_TTL_S: + profiles: List[ModelProfile] = cached["profiles"] + else: + profiles = await list_profiles(target) + _CACHE[key] = {"ts": now, "profiles": profiles} + + selected_name = selected_model or (target.model if target else "") or EXPERT_LOCAL_MODEL + by_name = {p.name: p for p in profiles} + selected = by_name.get(selected_name) + if selected is None: + stem = selected_name.split(":")[0] + selected = next((p for p in profiles if p.name.split(":")[0] == stem), None) or profile(selected_name) + best = best_of(profiles) or selected + + verified = verified_for(target, selected.name) + if run_probe and verified is None: + try: + verified = await probe(target, selected.name) + except Exception: # noqa: BLE001 + verified = None + if verified and verified.get("tools") in ("yes", "no"): + selected = ModelProfile(selected.name, selected.params_b, selected.tier, verified["tools"] == "yes", selected.reason) + # The gate judges the model the user chose: it is the model for every mode, Deep research + # included. ``best`` stays in the answer as information. + gate = deep_research_enabled(selected, verified) + return { + "connection": "ollama" if (target is None or target.is_ollama) else (target.provider or "openai_compat"), + "selected": selected.as_dict(), + "best": best.as_dict() if best else None, + "tier": selected.tier, + "verified": verified, + "models": [p.as_dict() for p in sorted(profiles, key=lambda p: (-p.rank, -(p.params_b or 0)))][:40], + "deep_research": gate, + } + + +def stronger(best: Optional[Dict[str, Any]], selected: Dict[str, Any]) -> bool: + """Whether ``best`` is a meaningful step up from ``selected`` (Heavy's escalation, Deep + research's model choice): a higher tier, or — below the high tier — the same tier with at + least twice the known parameters. A high-end model the user picked is never swapped for + another high-end one.""" + if not best or best.get("name") == selected.get("name"): + return False + best_rank, sel_rank = best.get("rank", 0), selected.get("rank", 0) + if best_rank > sel_rank: + return True + if best_rank < sel_rank or sel_rank >= TIER_RANK["high"]: + return False + bp, sp = best.get("params_b"), selected.get("params_b") + return bool(bp and sp and bp >= 2 * sp) + + +def reset_cache() -> None: + _CACHE.clear() diff --git a/expert/backend/app/expert/compaction.py b/expert/backend/app/expert/compaction.py new file mode 100644 index 0000000000000000000000000000000000000000..7e425a5054f431e692b175a0cb42e96674d12b9f --- /dev/null +++ b/expert/backend/app/expert/compaction.py @@ -0,0 +1,168 @@ +"""Compaction: turns older than the window become a running note instead of vanishing. + +What ChatGPT and Claude do when a thread outgrows the context: keep the recent turns +verbatim, and carry the rest as a short memory note — facts, decisions, preferences, open +questions — refreshed incrementally. One small model call per overflow, cached per session. +The note travels like grounding does: a context variable that ``build_messages`` appends to +the system prompt of every pipeline step. +""" +from __future__ import annotations + +import hashlib +import logging +import re +import time +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from .config import ( + EXPERT_COMPACTION_ENABLED, + EXPERT_COMPACTION_MAX_INPUT_CHARS, + EXPERT_COMPACTION_MAX_TOKENS, +) + +logger = logging.getLogger("expert.compaction") + +_CURRENT: ContextVar[str] = ContextVar("expert_compaction_note", default="") +_CACHE: Dict[str, Dict[str, Any]] = {} +_CACHE_MAX = 500 + +SUMMARY_SYSTEM = ( + "You maintain a compact memory note of a conversation. Update the note with the new turns: " + "keep facts, decisions, preferences, names, numbers, open questions and the user's goals; " + "drop chit-chat. Write in the third person, at most 250 words, no preamble." +) + + +def apply(system_prompt: str) -> str: + """``system_prompt`` with this request's note appended (unchanged when there is none).""" + note = _CURRENT.get() + if not note: + return system_prompt + return f"{system_prompt}\n\nSummary of the earlier conversation (turns not shown):\n{note}" + + +@contextmanager +def using(note: Optional[str]) -> Iterator[str]: + token = _CURRENT.set((note or "").strip()) + try: + yield _CURRENT.get() + finally: + _CURRENT.reset(token) + + +def _digest(turns: List[Dict[str, Any]]) -> str: + h = hashlib.sha1() + for t in turns: + h.update(f"{t.get('role')}\x1f{t.get('content')}\x1e".encode("utf-8", "replace")) + return h.hexdigest() + + +def split(history: Optional[List[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """``(older, window)``: the window exactly as the pipelines will see it, and the clean turns + before it.""" + from .router import clean_turns, history_window + + clean = clean_turns(history) + window = history_window(clean) + older = clean[: len(clean) - len(window)] if window else clean + return older, window + + +def render(turns: List[Dict[str, Any]], max_chars: int) -> str: + """The turns as a transcript, newest kept when the budget cuts.""" + lines = [f"{'User' if t.get('role') == 'user' else 'Assistant'}: {t.get('content', '')}" for t in turns] + text = "\n".join(lines) + if len(text) > max_chars: + text = "…" + text[-max_chars:] + return text + + +def _clean(summary: str) -> str: + summary = re.sub(r".*?", "", summary or "", flags=re.S) + return summary.strip() + + +async def summarize(previous: str, transcript: str, provider: str, model: Optional[str], temperature: float) -> str: + from .router import dispatch + + messages = [ + {"role": "system", "content": SUMMARY_SYSTEM}, + {"role": "user", "content": f"Current note:\n{previous or '(none yet)'}\n\nNew turns:\n{transcript}\n\nUpdated note:"}, + ] + raw = await dispatch(messages, provider, model=model, temperature=temperature, max_tokens=EXPERT_COMPACTION_MAX_TOKENS) + try: + return _clean(raw["choices"][0]["message"]["content"] or "") + except (KeyError, IndexError, TypeError): + return "" + + +def _remember(session_id: str, key: str, count: int, note: str) -> None: + if len(_CACHE) >= _CACHE_MAX: + oldest = min(_CACHE.items(), key=lambda kv: kv[1]["ts"])[0] + _CACHE.pop(oldest, None) + _CACHE[session_id] = {"key": key, "count": count, "note": note, "ts": time.monotonic()} + + +def cached_note(session_id: Optional[str]) -> Optional[str]: + entry = _CACHE.get(session_id or "") + return entry["note"] if entry else None + + +def needs_summary(history: Optional[List[Dict[str, Any]]], session_id: Optional[str]) -> int: + """How many older turns :func:`prepare` would summarize with a model call now (0: nothing + overflowed, or the session's note already covers them). No model call — the stream uses it + to show "Compacting the conversation…" before the summary starts.""" + if not EXPERT_COMPACTION_ENABLED: + return 0 + older, _window = split(history) + if not older: + return 0 + key = _digest(older) + cached = _CACHE.get(session_id or "") + if cached and cached["key"] == key: + return 0 + if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]: + return len(older) - cached["count"] + return len(older) + + +async def prepare( + history: Optional[List[Dict[str, Any]]], + *, + session_id: Optional[str], + provider: str, + model: Optional[str] = None, + temperature: float = 0.2, +) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str]]: + """``(window, note, notice)``: the turns to send verbatim, the note for the ones before + them (None when nothing overflowed), and a user-facing notice when summarizing failed. + + Incremental per session: a thread whose overflow grew by two turns summarizes those two + on top of the previous note, not the whole history again.""" + older, window = split(history) + if not EXPERT_COMPACTION_ENABLED or not older: + return window, None, None + key = _digest(older) + cached = _CACHE.get(session_id or "") + if cached and cached["key"] == key: + return window, cached["note"], None + previous, new_turns = "", older + if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]: + previous, new_turns = cached["note"], older[cached["count"]:] + transcript = render(new_turns, EXPERT_COMPACTION_MAX_INPUT_CHARS) + try: + note = await summarize(previous, transcript, provider, model, temperature) + except Exception as exc: # noqa: BLE001 + logger.warning("compaction: summarizing %d older turns failed: %s", len(new_turns), exc) + notice = ( + f"The earlier part of this conversation could not be summarized ({type(exc).__name__}); " + f"only the last {len(window)} turns are in context." + ) + return window, previous or None, notice + if not note: + return window, previous or None, None + if session_id: + _remember(session_id, key, len(older), note) + return window, note, None diff --git a/expert/backend/app/expert/config.py b/expert/backend/app/expert/config.py new file mode 100644 index 0000000000000000000000000000000000000000..0ce3ff8a4e99bb82cf365b021b5e1167e18bdd5e --- /dev/null +++ b/expert/backend/app/expert/config.py @@ -0,0 +1,190 @@ +# expert/config.py +# All Expert module env vars — purely additive, no conflict with existing config.py +from __future__ import annotations + +import os + +# ── xAI Grok ──────────────────────────────────────────────────────────────── +GROK_API_KEY: str = os.getenv("GROK_API_KEY", "").strip() +GROK_BASE_URL: str = os.getenv("GROK_BASE_URL", "https://api.x.ai/v1").rstrip("/") +GROK_MODEL: str = os.getenv("GROK_MODEL", "grok-3").strip() + +# ── Groq (ultra-fast open-model inference, free tier available) ────────────── +GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "").strip() +GROQ_BASE_URL: str = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/") +GROQ_MODEL: str = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip() + +# ── Google Gemini ──────────────────────────────────────────────────────────── +GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "").strip() +GEMINI_BASE_URL: str = os.getenv( + "GEMINI_BASE_URL", + "https://generativelanguage.googleapis.com/v1beta/openai" +).rstrip("/") +GEMINI_MODEL: str = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip() + +# ── Local Ollama (free, on-premises) ───────────────────────────────────────── +# Reuses existing OLLAMA_BASE_URL from main config if not overridden +EXPERT_OLLAMA_URL: str = os.getenv( + "EXPERT_OLLAMA_URL", + os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") +).rstrip("/") +# Default chosen so out-of-the-box Expert runs on a 12 GB consumer GPU. +# Operators with larger VRAM (or a cloud deploy) can point this at +# ``deepseek-r1:32b`` or similar for top-tier reasoning quality. +# If the configured model isn't pulled, chat_local falls back to any +# installed model via EXPERT_LOCAL_AUTO_FALLBACK — Expert never hard-fails +# on "model not found" as long as Ollama has at least one model. +EXPERT_LOCAL_MODEL: str = os.getenv("EXPERT_LOCAL_MODEL", "deepseek-r1:latest").strip() +EXPERT_LOCAL_FAST_MODEL: str = os.getenv("EXPERT_LOCAL_FAST_MODEL", "llama3.2:3b").strip() + +# ── Routing thresholds ──────────────────────────────────────────────────────── +# Complexity scores (0-10) that determine which tier to use +EXPERT_LOCAL_THRESHOLD: int = int(os.getenv("EXPERT_LOCAL_THRESHOLD", "3")) # ≤3 → local fast +EXPERT_GROQ_THRESHOLD: int = int(os.getenv("EXPERT_GROQ_THRESHOLD", "6")) # ≤6 → Groq 70B +# >6 → Grok / Gemini / best available cloud + +# ── Expert system identity ──────────────────────────────────────────────────── +# "Expert" is the name of this pipeline (Fast / Think / Heavy), not a persona. The old +# prompt opened "You are Expert, an advanced AI assistant integrated into HomePilot", and +# models took it literally: a plain "hello" came back as "Hello! I am Expert, your HomePilot +# assistant. How can I help you today?". The assistant should sound like a capable colleague, +# not announce a product name. +EXPERT_SYSTEM_PROMPT: str = os.getenv( + "EXPERT_SYSTEM_PROMPT", + "You are a knowledgeable, professional assistant. Talk the way a thoughtful colleague " + "would: natural, warm and direct.\n" + "\n" + "- Reply in the language the user writes in.\n" + "- Fit the length to the request. A greeting or a quick question gets a short, friendly " + "reply; a complex problem gets a clear, well-structured answer that reasons it through.\n" + "- Do not introduce yourself, give yourself a name or describe what you are unless the " + "user asks.\n" + "- Skip filler and formula: no \"Great question!\", no \"As an AI…\", no stock sign-off " + "at the end of every answer.\n" + "- Be accurate. If something is uncertain or depends on information you do not have, say " + "so briefly instead of guessing. Never invent facts, figures or sources.\n" + "- Use Markdown (lists, headings, code blocks) only when it makes the answer easier to " + "read." +) + +# ── Streaming ──────────────────────────────────────────────────────────────── +EXPERT_STREAM_CHUNK_SIZE: int = int(os.getenv("EXPERT_STREAM_CHUNK_SIZE", "64")) +EXPERT_MAX_TOKENS: int = int(os.getenv("EXPERT_MAX_TOKENS", "2048")) +EXPERT_TEMPERATURE: float = float(os.getenv("EXPERT_TEMPERATURE", "0.7")) +# Conversation window handed to every pipeline call — fast, think and heavy alike: the newest +# turns that fit both budgets. The chat sends its last 8 turns; API callers may send any number. +EXPERT_HISTORY_MAX_TURNS: int = int(os.getenv("EXPERT_HISTORY_MAX_TURNS", "12")) +EXPERT_HISTORY_MAX_CHARS: int = int(os.getenv("EXPERT_HISTORY_MAX_CHARS", "16000")) +# Compaction: turns older than the window are summarized into a running note instead of being +# dropped (compaction.py). One short model call per overflow, cached per session. +EXPERT_COMPACTION_ENABLED: bool = os.getenv("EXPERT_COMPACTION_ENABLED", "1").lower() in ("1", "true", "yes") +EXPERT_COMPACTION_MAX_TOKENS: int = int(os.getenv("EXPERT_COMPACTION_MAX_TOKENS", "400")) +EXPERT_COMPACTION_MAX_INPUT_CHARS: int = int(os.getenv("EXPERT_COMPACTION_MAX_INPUT_CHARS", "24000")) + +# ── Tools (tools.py, tool_loop.py) ─────────────────────────────────────────── +# The per-turn tool loop: the model may call a curated set of HomePilot's MCP tools (web search, +# page fetch, workspace and notes search) while answering, ChatGPT-style. Servers speak JSON-RPC +# tools/list + tools/call on /rpc — HomePilot's own agentic servers by default; Context Forge's +# /rpc speaks the same protocol and can be added for federated tools. +EXPERT_TOOLS_ENABLED: bool = os.getenv("EXPERT_TOOLS_ENABLED", "1").lower() in ("1", "true", "yes") +EXPERT_TOOL_SERVERS: str = os.getenv( + "EXPERT_TOOL_SERVERS", + "http://127.0.0.1:9105/rpc,http://127.0.0.1:9102/rpc,http://127.0.0.1:9101/rpc", +) +# Native tool names offered to the model (comma-separated; '*' for every tool the servers list). +EXPERT_TOOL_ALLOWLIST: str = os.getenv( + "EXPERT_TOOL_ALLOWLIST", + "hp.web.search,hp.web.fetch,hp.search_workspace,hp.get_document,hp.personal.search", +) +EXPERT_TOOL_MAX_ROUNDS: int = int(os.getenv("EXPERT_TOOL_MAX_ROUNDS", "4")) +EXPERT_TOOL_MAX_CALLS_PER_ROUND: int = int(os.getenv("EXPERT_TOOL_MAX_CALLS_PER_ROUND", "4")) +EXPERT_TOOL_TIMEOUT_S: float = float(os.getenv("EXPERT_TOOL_TIMEOUT_S", "45")) +EXPERT_TOOL_RESULT_MAX_CHARS: int = int(os.getenv("EXPERT_TOOL_RESULT_MAX_CHARS", "6000")) + +# ── Heavy: escalate to the strongest model on the same connection (routes.py) ─ +# Off by default: the model chosen in the chat is the model for every mode. Set to 1 to let +# Heavy move to a clearly stronger model of the same connection, with a notice. +EXPERT_HEAVY_ESCALATE: bool = os.getenv("EXPERT_HEAVY_ESCALATE", "0").lower() in ("1", "true", "yes") + +# ── Effort profiles (policies.py): what each mode may spend, not what it must ─ +# A mode sets the ceiling; the request's complexity decides how much of it is used. A greeting +# in Heavy is one generation; a hard question gets the full pipeline. +EXPERT_FAST_TEMPERATURE: float = float(os.getenv("EXPERT_FAST_TEMPERATURE", "0.7")) +EXPERT_FAST_MAX_TOKENS: int = int(os.getenv("EXPERT_FAST_MAX_TOKENS", "1024")) +EXPERT_FAST_TOOL_ROUNDS: int = int(os.getenv("EXPERT_FAST_TOOL_ROUNDS", "2")) +EXPERT_THINK_TEMPERATURE: float = float(os.getenv("EXPERT_THINK_TEMPERATURE", "0.4")) +EXPERT_THINK_MAX_TOKENS: int = int(os.getenv("EXPERT_THINK_MAX_TOKENS", "2048")) +EXPERT_THINK_TOOL_ROUNDS: int = int(os.getenv("EXPERT_THINK_TOOL_ROUNDS", "4")) +EXPERT_THINK_PLAN_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_THINK_PLAN_MIN_COMPLEXITY", "4")) +EXPERT_HEAVY_TEMPERATURE: float = float(os.getenv("EXPERT_HEAVY_TEMPERATURE", "0.3")) +EXPERT_HEAVY_MAX_TOKENS: int = int(os.getenv("EXPERT_HEAVY_MAX_TOKENS", "3072")) +EXPERT_HEAVY_TOOL_ROUNDS: int = int(os.getenv("EXPERT_HEAVY_TOOL_ROUNDS", "4")) +EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY", "5")) +EXPERT_DEEP_TEMPERATURE: float = float(os.getenv("EXPERT_DEEP_TEMPERATURE", "0.3")) + +# ── Durable state (state_store.py): capability facts, Deep research jobs ───── +# Default: /expert_state.sqlite. +EXPERT_STATE_DB: str = os.getenv("EXPERT_STATE_DB", "").strip() +EXPERT_CAPABILITY_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_TTL_S", str(7 * 24 * 3600))) +# A learned refusal ("this connection rejects the tools field") is re-checked sooner than a +# success: gateways get fixed, and re-learning costs one rejected request plus a retry. +EXPERT_CAPABILITY_NEGATIVE_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_NEGATIVE_TTL_S", "3600")) +# `: keep-alive` SSE comment after this many silent seconds on /stream (0 disables): a long model +# call or a slow tool no longer looks like a dead connection to proxies, browsers, or the reader. +EXPERT_SSE_HEARTBEAT_S: float = float(os.getenv("EXPERT_SSE_HEARTBEAT_S", "10")) + +# ── Adaptive pacing and retries for OpenAI-compatible endpoints (providers.py) ─ +# One gateway is never flooded: at most MAX_INFLIGHT requests at a time, and a gap between +# request starts, per base URL. The gap starts at MIN_INTERVAL_S, doubles on every rate limit +# (up to MAX_INTERVAL_S, and every caller waits out the cooldown) and shrinks back 15 % per +# success — Heavy's agents and Deep research's sub-agents adapt to the gateway's real limit. +# Requests beyond MAX_INFLIGHT wait in a queue, a person's ahead of background work (Deep +# research). A 429, a body that says "rate limit", or a 502/503/504 is retried with exponential +# backoff and jitter (about 2 s, 4 s, 8 s, 16 s, 20 s…; Retry-After is a floor), always before +# the first token, for up to WAIT_S seconds in a chat turn and BACKGROUND_WAIT_S in a background +# job (RETRIES caps the tries). Meanwhile the chat shows "The model provider is busy; retrying…". +EXPERT_PROVIDER_MAX_INFLIGHT: int = int(os.getenv("EXPERT_PROVIDER_MAX_INFLIGHT", "2")) +EXPERT_PROVIDER_MIN_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MIN_INTERVAL_S", "1.0")) +EXPERT_PROVIDER_MAX_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MAX_INTERVAL_S", "15")) +EXPERT_PROVIDER_RETRIES: int = int(os.getenv("EXPERT_PROVIDER_RETRIES", "8")) +EXPERT_PROVIDER_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_WAIT_S", "90")) +EXPERT_PROVIDER_BACKGROUND_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_BACKGROUND_WAIT_S", "300")) +# The usage meter's rolling window. On a rate limit the endpoint's cap is learned as the +# successes this window took; new requests then wait their turn to stay under it, and the cap +# grows by one after three quiet windows (GET /v1/expert/usage shows it all). +EXPERT_PROVIDER_WINDOW_S: float = float(os.getenv("EXPERT_PROVIDER_WINDOW_S", "60")) + +# ── Deep research (deep.py, capability.py) ─────────────────────────────────── +# auto: offered when the connection has a high-end model (capability.py); always / off override. +EXPERT_DEEP_RESEARCH: str = os.getenv("EXPERT_DEEP_RESEARCH", "auto").strip().lower() or "auto" +EXPERT_DEEP_MIN_PARAMS_B: float = float(os.getenv("EXPERT_DEEP_MIN_PARAMS_B", "20")) +EXPERT_DEEP_TIME_BUDGET_S: float = float(os.getenv("EXPERT_DEEP_TIME_BUDGET_S", "300")) +EXPERT_DEEP_MAX_ITEMS: int = int(os.getenv("EXPERT_DEEP_MAX_ITEMS", "5")) +EXPERT_DEEP_ROUNDS_PER_ITEM: int = int(os.getenv("EXPERT_DEEP_ROUNDS_PER_ITEM", "3")) +EXPERT_DEEP_JOB_TTL_S: float = float(os.getenv("EXPERT_DEEP_JOB_TTL_S", "3600")) +# 120s accommodates CPU-only Ollama setups with mid-size thinking models +# (e.g. deepseek-r1 / phi4) where a single analysis pass can easily exceed +# 45s. Think mode chains three such calls, so keeping the per-call timeout +# tight caused ``[expert chat] TimeoutError → 502`` on local dev boxes even +# when the model was making progress. Override via env for prod deploys +# where faster providers enforce a shorter SLA. +EXPERT_PROVIDER_TIMEOUT_S: float = float(os.getenv("EXPERT_PROVIDER_TIMEOUT_S", "120")) + + +def available_expert_providers() -> list[str]: + """Return which expert providers are configured (have API keys or local URL).""" + providers = [] + # Local is always available if Ollama is reachable (no key needed) + providers.append("local") + if GROQ_API_KEY: + providers.append("groq") + if GROK_API_KEY: + providers.append("grok") + if GEMINI_API_KEY: + providers.append("gemini") + # Claude / OpenAI reuse existing HomePilot keys — check them + if os.getenv("ANTHROPIC_API_KEY", "").strip(): + providers.append("claude") + if os.getenv("OPENAI_API_KEY", "").strip(): + providers.append("openai") + return providers diff --git a/expert/backend/app/expert/deep.py b/expert/backend/app/expert/deep.py new file mode 100644 index 0000000000000000000000000000000000000000..3370e695840f56544195dd90a447fd0938639ce4 --- /dev/null +++ b/expert/backend/app/expert/deep.py @@ -0,0 +1,481 @@ +"""Deep research — a bounded deep-agent run: plan, delegate, gather with tools, write. + +The harness primitives behind ChatGPT's and Claude's "deep research" modes, kept small and +local-first, with no extra dependency: + +* a **plan** the model writes first (a few sub-questions), shown to the user as it runs; +* one **sub-agent per plan item**, each with a clean context and the per-turn tool loop + (web search, page fetch, workspace and notes, the project's documents), reporting findings + with [n] citations; +* a shared **source book** that renumbers every sub-agent's citations into one list; +* a **writer** pass that turns findings and sources into a cited report, streamed; +* a **time budget** and per-item call budget that end the run gracefully; +* a **durable job**: every event is numbered and written to the state store with the job's + snapshot, so a client can reconnect after a refresh (``?after=``) and a finished + report survives a restart. A job interrupted by a restart is marked so, never left running. + +Gate: ``capability.py`` — offered only when the connection has a high-end model. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .config import ( + EXPERT_DEEP_JOB_TTL_S, + EXPERT_DEEP_MAX_ITEMS, + EXPERT_DEEP_ROUNDS_PER_ITEM, + EXPERT_DEEP_TEMPERATURE, + EXPERT_DEEP_TIME_BUDGET_S, + EXPERT_MAX_TOKENS, + EXPERT_SYSTEM_PROMPT, +) +from .policies import is_trivial +from .providers import mark_background +from .router import dispatch, step_messages, stream_provider_events +from .tool_loop import SourceBook, ThinkFilter, capability_key, stream_with_tools, tools_known +from . import compaction +from . import grounding +from . import state_store +from . import target as target_mod + +logger = logging.getLogger("expert.deep") + +PLANNER_SYSTEM = ( + "You are the lead researcher. Break the user's request into the few sub-questions whose " + "answers, together, settle it. Each must be self-contained and searchable. Return JSON only: " + '{{"questions": ["...", "..."]}} with 2 to {max_items} items.' +) +RESEARCHER_SYSTEM = ( + "You are a research sub-agent. Answer the sub-question with facts found with your tools: " + "search, read the best pages, and report findings with the [n] numbers of the results you " + "relied on. Be concrete — names, numbers, dates. Say when sources disagree or when nothing " + "reliable was found. Keep it under 300 words." +) +WRITER_SYSTEM = ( + "You are the writer. From the findings and the numbered sources, write the report the user " + "asked for: a short answer first, then sections with headings, specific facts, and [n] " + "citations right after the sentences they support. Note gaps and disagreements plainly. " + "Never invent a source or a number." +) + +FINAL_STATES = ("done", "failed", "cancelled") + + +def _with_operator(system: str, operator_prompt: Optional[str]) -> str: + """A role prompt plus the operator's context (a project's instructions) when given.""" + if operator_prompt and operator_prompt.strip(): + return f"{system}\n\nContext from the operator:\n{operator_prompt.strip()}" + return system + + +@dataclass +class PlanItem: + id: str + title: str + status: str = "pending" # pending | running | done | skipped + finding: str = "" + sources: List[int] = field(default_factory=list) + + def as_dict(self) -> Dict[str, Any]: + return {"id": self.id, "title": self.title, "status": self.status, "sources": list(self.sources), + "summary": self.finding[:300]} + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PlanItem": + return cls(id=str(d.get("id") or ""), title=str(d.get("title") or ""), status=str(d.get("status") or "pending"), + finding=str(d.get("summary") or d.get("finding") or ""), sources=list(d.get("sources") or [])) + + +@dataclass +class DeepJob: + id: str + query: str + provider: str + model: Optional[str] + session_id: Optional[str] = None + status: str = "queued" # queued | planning | researching | writing | done | failed | cancelled + plan: List[PlanItem] = field(default_factory=list) + report: str = "" + sources: List[Dict[str, Any]] = field(default_factory=list) + notices: List[str] = field(default_factory=list) + error: str = "" + created: float = field(default_factory=time.time) + finished: Optional[float] = None + seq: int = 0 + events: List[Dict[str, Any]] = field(default_factory=list) + subscribers: List["asyncio.Queue[Dict[str, Any]]"] = field(default_factory=list) + task: Optional["asyncio.Task[None]"] = None + + # ── events: numbered, fanned out, written down ────────────────────────── + def emit(self, event: Dict[str, Any]) -> None: + self.seq += 1 + event = {**event, "seq": self.seq} + self.events.append(event) + state_store.event_append(self.id, self.seq, event) + if event["type"] != "token": + self.persist() + for q in list(self.subscribers): + q.put_nowait(event) + + def subscribe(self, after: int = 0) -> "asyncio.Queue[Dict[str, Any]]": + q: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue() + for e in self.events: + if int(e.get("seq", 0)) > after: + q.put_nowait(e) + self.subscribers.append(q) + return q + + def unsubscribe(self, q: "asyncio.Queue[Dict[str, Any]]") -> None: + if q in self.subscribers: + self.subscribers.remove(q) + + @property + def finished_state(self) -> bool: + return self.status in FINAL_STATES + + def cancel(self) -> None: + if self.task and not self.task.done(): + self.task.cancel() + + def snapshot(self) -> Dict[str, Any]: + end = self.finished or time.time() + return { + "job_id": self.id, "status": self.status, "query": self.query, "model": self.model, + "provider": self.provider, "session_id": self.session_id, + "plan": [p.as_dict() for p in self.plan], "report": self.report, "sources": self.sources, + "notices": list(self.notices), "error": self.error, "elapsed_s": round(end - self.created, 1), + "seq": self.seq, "created": self.created, "finished": self.finished, + } + + def persist(self) -> None: + state_store.job_save(self.id, self.status, self.snapshot(), created=self.created) + + @classmethod + def from_snapshot(cls, snap: Dict[str, Any], events: List[Dict[str, Any]]) -> "DeepJob": + job = cls( + id=str(snap.get("job_id") or ""), query=str(snap.get("query") or ""), + provider=str(snap.get("provider") or "local"), model=snap.get("model"), session_id=snap.get("session_id"), + status=str(snap.get("status") or "failed"), + plan=[PlanItem.from_dict(p) for p in snap.get("plan") or []], + report=str(snap.get("report") or ""), sources=list(snap.get("sources") or []), + notices=list(snap.get("notices") or []), error=str(snap.get("error") or ""), + created=float(snap.get("created") or time.time()), finished=snap.get("finished"), + ) + job.events = list(events) + job.seq = max([int(e.get("seq", 0)) for e in events] + [int(snap.get("seq") or 0)]) + return job + + +JOBS: Dict[str, DeepJob] = {} +_RECOVERED = False + + +def recover_interrupted() -> int: + """Jobs the store says were running when the process last stopped: there is no task + behind them any more, so they are closed as failed, with the reason on record.""" + global _RECOVERED + if _RECOVERED: + return 0 + _RECOVERED = True + count = 0 + for job_id in state_store.jobs_unfinished(): + row = state_store.job_load(job_id) + if not row: + continue + job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0)) + job.status = "failed" + job.error = "Interrupted: HomePilot restarted while this research was running." + job.finished = time.time() + job.seq += 1 + event = {"type": "error", "error": job.error, "seq": job.seq} + job.events.append(event) + state_store.event_append(job.id, job.seq, event) + job.persist() + count += 1 + if count: + logger.info("deep research: closed %d job(s) interrupted by a restart", count) + return count + + +def get_job(job_id: str) -> Optional[DeepJob]: + """A live job, or a finished one read back from the store.""" + recover_interrupted() + job = JOBS.get(job_id) + if job is not None: + return job + row = state_store.job_load(job_id) + if not row: + return None + job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0)) + JOBS[job_id] = job + return job + + +def _prune() -> None: + now = time.time() + for key, job in list(JOBS.items()): + if job.finished and now - job.finished > EXPERT_DEEP_JOB_TTL_S: + JOBS.pop(key, None) + state_store.jobs_prune(EXPERT_DEEP_JOB_TTL_S * 24) + + +def parse_plan(text: str, query: str, max_items: int) -> List[str]: + """The planner's sub-questions: JSON when it obeyed, a bullet or numbered list when it did + not, the request itself when nothing usable came back.""" + text = re.sub(r".*?", "", text or "", flags=re.S) + questions: List[str] = [] + m = re.search(r"\{.*\}", text, flags=re.S) + if m: + try: + data = json.loads(m.group(0)) + qs = data.get("questions") if isinstance(data, dict) else None + if isinstance(qs, list): + questions = [str(q).strip() for q in qs if str(q).strip()] + except json.JSONDecodeError: + pass + if not questions: + for line in text.splitlines(): + if re.match(r"^\s*(?:[-*•]|\d+[.)])\s+\S", line): + questions.append(re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line).strip().strip('"')) + questions = [q for q in questions if len(q) > 3][:max_items] + return questions or [query] + + +def _short(exc: Exception) -> str: + """One line of an error for a notice.""" + return " ".join(str(exc).split())[:160] or type(exc).__name__ + + +def _findings_report(plan: List["PlanItem"], sources: List[Dict[str, Any]]) -> str: + """The report when the writer cannot run: each researched question with its finding, and + the numbered sources the findings cite.""" + blocks = [f"### {p.title}\n\n{p.finding.strip()}" for p in plan if p.finding and p.finding.strip()] + out = "\n\n".join(blocks) + if sources: + out += "\n\n**Sources**\n\n" + "\n".join(f"[{s['n']}] {s.get('title') or s['url']} — {s['url']}" for s in sources) + return out + + +def renumber(text: str, mapping: Dict[int, int]) -> str: + """A sub-agent's local [n] citations rewritten to the job-wide numbers.""" + return re.sub(r"\[(\d+)\]", lambda m: f"[{mapping.get(int(m.group(1)), int(m.group(1)))}]", text or "") + + +def start( + query: str, + *, + provider: str, + model: Optional[str], + history: List[Dict[str, Any]], + chosen: Optional[target_mod.ChatTarget], + timezone: Optional[str], + context: Optional[str], + note: Optional[str], + notices: Optional[List[str]] = None, + registry: Any = None, + operator_prompt: Optional[str] = None, + session_id: Optional[str] = None, +) -> DeepJob: + """Create the job and run it in the background; the caller returns its id at once.""" + recover_interrupted() + _prune() + job = DeepJob(id=uuid.uuid4().hex[:12], query=query, provider=provider, model=model, session_id=session_id) + job.notices.extend(notices or []) + JOBS[job.id] = job + job.persist() + + async def runner() -> None: + with target_mod.using(chosen), grounding.using(timezone, context), compaction.using(note): + await _run(job, list(history or []), registry=registry, operator_prompt=operator_prompt) + + job.task = asyncio.create_task(runner()) + return job + + +async def _run(job: DeepJob, history: List[Dict[str, Any]], *, registry: Any = None, + operator_prompt: Optional[str] = None) -> None: + started = time.monotonic() + budget = EXPERT_DEEP_TIME_BUDGET_S + kw = dict(model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=EXPERT_MAX_TOKENS) + book = SourceBook() + # A research job is background work: on a busy endpoint, a person's chat goes first. + mark_background() + try: + if is_trivial(job.query): + # A greeting needs no plan, no searches and no report: one reply, and the reason. + # (It also spares a shared endpoint the five to eight calls a research job makes.) + job.status = "writing" + job.emit({"type": "status", "status": "writing"}) + job.notices.append("A short message needs no research; it was answered directly.") + parts: List[str] = [] + thinking = ThinkFilter() + async for ev in stream_provider_events( + step_messages(job.query, system_prompt=_with_operator(EXPERT_SYSTEM_PROMPT, operator_prompt), history=history), + job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=512, + ): + if "delta" in ev: + clean = thinking.feed(ev["delta"]) + if clean: + parts.append(clean) + job.emit({"type": "token", "content": clean}) + tail = thinking.flush() + if tail: + parts.append(tail) + job.emit({"type": "token", "content": tail}) + job.report = "".join(parts) + job.status = "done" + job.finished = time.time() + job.emit({ + "type": "done", "report": job.report, "sources": [], "notices": list(job.notices), + "plan": [], "elapsed_s": round(time.monotonic() - started, 1), + }) + return + + # 1. Plan + job.status = "planning" + job.emit({"type": "status", "status": "planning"}) + try: + raw = await dispatch( + step_messages( + f"Request:\n{job.query}\n\nReturn the JSON plan.", + system_prompt=_with_operator(PLANNER_SYSTEM.format(max_items=EXPERT_DEEP_MAX_ITEMS), operator_prompt), + history=history, + ), + job.provider, model=job.model, temperature=0.2, max_tokens=600, + ) + plan_text = raw["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError, TypeError): + plan_text = "" + except Exception as exc: # noqa: BLE001 - no plan is not no research + logger.warning("Deep research %s: planner failed, researching the request as one question: %s", job.id, exc) + job.notices.append(f"The planner could not run ({_short(exc)}); the request was researched as one question.") + plan_text = "" + 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))] + job.emit({"type": "plan", "items": [p.as_dict() for p in job.plan]}) + + # 2. Research, one sub-agent per item, within the time budget. Every item starts with a + # web search run by HomePilot (the question as the query), so the findings are + # grounded whether or not the model can call tools itself; a capable model may search + # further on its own. + job.status = "researching" + job.emit({"type": "status", "status": "researching"}) + if tools_known(capability_key(target_mod.current(), job.provider, job.model)) is False: + job.notices.append("This model cannot call tools; HomePilot ran the web searches for it.") + out_of_time = False + for item in job.plan: + if time.monotonic() - started > budget: + if not out_of_time: + out_of_time = True + job.notices.append(f"Time budget of {budget:g}s reached; the remaining items were skipped.") + item.status = "skipped" + job.emit({"type": "item", "id": item.id, "status": "skipped", "title": item.title}) + continue + item.status = "running" + job.emit({"type": "item", "id": item.id, "status": "running", "title": item.title}) + messages = step_messages( + f"Overall request: {job.query}\n\nSub-question: {item.title}", + system_prompt=_with_operator(RESEARCHER_SYSTEM, operator_prompt), history=history, + ) + finding, local_sources = "", [] + try: + async for ev in stream_with_tools(messages, job.provider, max_rounds=EXPERT_DEEP_ROUNDS_PER_ITEM, + registry=registry, forced=[("hp_web_search", {"query": item.title})], **kw): + if ev["type"] in ("tool_start", "tool_end", "wait"): + job.emit({**ev, "item": item.id}) + elif ev["type"] == "notice": + if ev["text"] not in job.notices: + job.notices.append(ev["text"]) + job.emit({**ev, "item": item.id}) + elif ev["type"] == "done": + finding, local_sources = ev.get("content") or "", ev.get("sources") or [] + except Exception as exc: # noqa: BLE001 - one question down is not the report down + logger.warning("Deep research %s: item %s failed: %s", job.id, item.id, exc) + item.status = "failed" + item.finding = "" + note = f"“{item.title}” could not be researched ({_short(exc)})." + if note not in job.notices: + job.notices.append(note) + job.emit({"type": "item", "id": item.id, "status": "failed", "title": item.title, + "summary": f"Could not be researched: {_short(exc)}"}) + continue + mapping: Dict[int, int] = {} + for s in local_sources: + book.add([s]) + numbered = book.numbered([s]) + if numbered: + mapping[int(s.get("n", 0))] = numbered[0]["n"] + item.finding = renumber(finding, mapping) + item.sources = sorted(set(mapping.values())) + item.status = "done" + job.sources = list(book.items) + job.emit({"type": "item", "id": item.id, "status": "done", "title": item.title, + "summary": item.finding[:300], "sources": item.sources}) + if book.items: + job.emit({"type": "sources", "sources": list(book.items)}) + + # 3. Write + job.status = "writing" + job.emit({"type": "status", "status": "writing"}) + findings = "\n\n".join( + f"## {p.title}\n{p.finding or ('(could not be researched)' if p.status == 'failed' else '(not researched: out of time)')}" + for p in job.plan + ) + source_lines = "\n".join( + f"[{s['n']}] {s.get('title') or s['url']} — {s['url']}" for s in book.items + ) or "(no sources were found; say so and answer from general knowledge, marked as such)" + writer_messages = step_messages( + f"Request:\n{job.query}\n\nFindings:\n{findings}\n\nSources:\n{source_lines}\n\nWrite the report.", + system_prompt=_with_operator(WRITER_SYSTEM, operator_prompt), history=history, + ) + parts: List[str] = [] + thinking = ThinkFilter() + try: + async for ev in stream_provider_events( + writer_messages, job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, + max_tokens=max(EXPERT_MAX_TOKENS, 3000), + ): + if "delta" in ev: + clean = thinking.feed(ev["delta"]) + if clean: + parts.append(clean) + job.emit({"type": "token", "content": clean}) + elif ev.get("notice") and ev["notice"] not in job.notices: + job.notices.append(ev["notice"]) + except Exception as exc: # noqa: BLE001 - the findings are the report's substance + researched = [p for p in job.plan if p.finding] + if not researched and not parts: + raise + logger.warning("Deep research %s: writer failed, returning the findings: %s", job.id, exc) + job.notices.append(f"The writer could not finish ({_short(exc)}); the findings are shown as gathered.") + fallback = _findings_report(job.plan, book.items) + text = ("\n\n---\n\n" if parts else "") + fallback + parts.append(text) + job.emit({"type": "token", "content": text}) + tail = thinking.flush() + if tail: + parts.append(tail) + job.emit({"type": "token", "content": tail}) + job.report = "".join(parts) + job.status = "done" + job.finished = time.time() + job.emit({ + "type": "done", "report": job.report, "sources": job.sources, "notices": list(job.notices), + "plan": [p.as_dict() for p in job.plan], "elapsed_s": round(time.monotonic() - started, 1), + }) + except asyncio.CancelledError: + job.status = "cancelled" + job.finished = time.time() + job.emit({"type": "cancelled"}) + except Exception as exc: # noqa: BLE001 + logger.error("Deep research %s failed: %s", job.id, exc, exc_info=True) + job.status = "failed" + job.error = f"{type(exc).__name__}: {exc}" + job.finished = time.time() + job.emit({"type": "error", "error": job.error}) diff --git a/expert/backend/app/expert/eval_bench.py b/expert/backend/app/expert/eval_bench.py new file mode 100644 index 0000000000000000000000000000000000000000..8f719d919c254efab85fb69a5bfe063099c2bcff --- /dev/null +++ b/expert/backend/app/expert/eval_bench.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + + +@dataclass +class EvalCase: + query: str + expected_keywords: List[str] + + +@dataclass +class EvalOutcome: + score: float + passed: bool + + +def score_output(text: str, expected_keywords: List[str]) -> float: + if not expected_keywords: + return 1.0 + hits = sum(1 for k in expected_keywords if k.lower() in text.lower()) + return hits / len(expected_keywords) + + +def regression_gate(outcomes: List[EvalOutcome], min_avg: float = 0.75) -> bool: + if not outcomes: + return False + avg = sum(o.score for o in outcomes) / len(outcomes) + return avg >= min_avg and all(o.passed for o in outcomes) diff --git a/expert/backend/app/expert/grounding.py b/expert/backend/app/expert/grounding.py new file mode 100644 index 0000000000000000000000000000000000000000..fc7e6a768a7ba43f31e96d34789a40098897b11a --- /dev/null +++ b/expert/backend/app/expert/grounding.py @@ -0,0 +1,65 @@ +"""What one Expert request is grounded in: today's date, and — when the chat searched the web — +the numbered sources HomePilot found (``backend/app/web_search.py``). + +Expert's three pipelines build their own prompts (fast: ``router.build_messages``; think: +``thinking._msg``; heavy: ``heavy._msg``), and think and heavy never see the history. So the +grounding rides on the system prompt itself, through every step, the way ``target.py`` carries +the chosen model: a context variable set for the request, applied with :func:`apply`. + +Without the date a model answers "which day is today?" from its training data; without sources +it recites old news as current. Both happened in Fast mode before this. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from datetime import datetime +from typing import Iterator, Optional + +_CURRENT: ContextVar[str] = ContextVar("expert_grounding", default="") + +#: The most a request may add to the prompt (a web-results block is ~5-8 kB). +MAX_CONTEXT_CHARS = 24_000 + + +def date_line(timezone: Optional[str] = None, now: Optional[datetime] = None) -> str: + """Today's date and time, in the user's zone when the browser sent one.""" + moment = (now or datetime.now()).astimezone() + zone = "" + if timezone: + try: + from zoneinfo import ZoneInfo + + moment = moment.astimezone(ZoneInfo(timezone)) + zone = timezone + except Exception: # noqa: BLE001 — an unknown name falls back to the server's zone + zone = "" + zone = zone or (moment.tzname() or "") + stamp = moment.strftime("%A, %B %d, %Y, %H:%M").replace(" 0", " ") + return ( + f"Current date and time: {stamp}{f' ({zone})' if zone else ''}. Use it for anything that " + "depends on today's date. Your own knowledge stops at your training cutoff: for news, prices, " + "scores or anything else that may have changed since, say you cannot check live information " + "unless web results are provided, and never invent recent events." + ) + + +@contextmanager +def using(timezone: Optional[str] = None, context: Optional[str] = None) -> Iterator[str]: + """Ground every prompt built inside the block in today's date and ``context``.""" + parts = [date_line(timezone)] + extra = (context or "").strip() + if extra: + parts.append(extra[:MAX_CONTEXT_CHARS]) + token = _CURRENT.set("\n\n".join(parts)) + try: + yield _CURRENT.get() + finally: + _CURRENT.reset(token) + + +def apply(system_prompt: str) -> str: + """``system_prompt`` with this request's grounding appended (unchanged outside a request).""" + extra = _CURRENT.get() + return f"{system_prompt}\n\n{extra}" if extra else system_prompt diff --git a/expert/backend/app/expert/heavy.py b/expert/backend/app/expert/heavy.py new file mode 100644 index 0000000000000000000000000000000000000000..0fb033067156a84c9753b2695c5075a1ad15d712 --- /dev/null +++ b/expert/backend/app/expert/heavy.py @@ -0,0 +1,487 @@ +# expert/heavy.py +# Multi-agent "heavy" pipeline for the most complex queries. +# +# Runs 4 specialized agents sequentially, each with a distinct role: +# +# Agent 1 — RESEARCHER: Deep information gathering + context building +# Agent 2 — REASONER: Logical inference and chain-of-thought over research +# Agent 3 — SYNTHESIZER: Condense into a clear, well-structured answer +# Agent 4 — VALIDATOR: Final sanity check + quality gate +# +# Each agent can use a different provider (e.g. Groq for fast research, +# Grok for deep reasoning) if configured via HEAVY_*_PROVIDER env vars. +# Falls back to the caller's selected provider for all agents. +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, AsyncIterator, Dict, List, Optional, Tuple + +from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT +from .router import ( + dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName, +) +from .tool_loop import ThinkFilter, run_with_tools, stream_with_tools, strip_think + +logger = logging.getLogger("expert.heavy") + +# ───────────────────────────────────────────────────────────────────────────── +# Per-agent provider overrides (optional — falls back to caller's provider) +# ───────────────────────────────────────────────────────────────────────────── + +def _agent_provider(agent: str, default: ProviderName) -> ProviderName: + env_key = f"HEAVY_{agent.upper()}_PROVIDER" + val = os.getenv(env_key, "").strip() + return val if val else default # type: ignore[return-value] + + +# ───────────────────────────────────────────────────────────────────────────── +# Agent system prompts +# ───────────────────────────────────────────────────────────────────────────── + +_RESEARCHER_SYSTEM = ( + "You are a deep research agent. Your role is to gather all relevant " + "context, background knowledge, related concepts, and potential edge cases " + "for a given topic. Be exhaustive and precise. Cite what you know." +) + +_REASONER_SYSTEM = ( + "You are a logical reasoning agent. Given a research brief, your job is to " + "reason step-by-step to conclusions. Use deductive and inductive logic. " + "Identify cause-and-effect relationships. Expose hidden assumptions." +) + +_SYNTHESIZER_SYSTEM = ( + "You are a synthesis agent. Given detailed research and reasoning, produce " + "a clear, well-structured, concise final answer. Use headers if helpful. " + "Prioritize clarity and actionability. Eliminate redundancy." +) + +_VALIDATOR_SYSTEM = ( + "You are a validation agent. Your job is to review a synthesized answer " + "for accuracy, completeness, and logical consistency. " + "Reply with JSON only, in this shape: " + '{"verdict": "validated" | "corrected" | "needs_review", ' + '"issues": [{"severity": "high" | "medium" | "low", "text": "..."}], ' + '"corrected_answer": "the full corrected answer, only when verdict is corrected" | null, ' + '"method": "model review", "limitations": "what you could not verify"}. ' + "Use \"corrected\" only when you can supply the complete corrected answer; use " + "\"needs_review\" when something important is doubtful but you cannot fix it; " + "use \"validated\" when the answer holds." +) + +VERDICTS = ("validated", "corrected", "needs_review", "reviewed") + +# ───────────────────────────────────────────────────────────────────────────── +# Agent prompts +# ───────────────────────────────────────────────────────────────────────────── + +_RESEARCH_PROMPT = "Research this topic comprehensively:\n\n{input}" + +_REASON_PROMPT = ( + "Using this research brief, reason step-by-step toward an answer:\n\n" + "Research:\n{research}\n\nOriginal question: {input}" +) + +_REASON_DIRECT_PROMPT = ( + "Reason step-by-step toward an answer. Use your tools when a fact, a figure or a current " + "detail matters.\n\nQuestion: {input}" +) + +_SYNTHESIZE_PROMPT = ( + "Synthesize a final answer from this reasoning:\n\n" + "Question: {input}\n\n" + "Reasoning:\n{reasoning}" +) + +_VALIDATE_PROMPT = ( + "Validate this answer for the given question:\n\n" + "Question: {input}\n\n" + "Answer:\n{synthesis}" +) + + +def _role(system: str, operator: Optional[str]) -> str: + """An agent's role prompt, plus the operator's (persona or user) instructions when given.""" + if operator and operator.strip(): + return f"{system}\n\nOperator instructions for this assistant:\n{operator.strip()}" + return system + + +_NOTES = { + "validated": "The validator confirmed the answer.", + "corrected": "The validator found issues and corrected the answer.", + "needs_review": "The validator raised doubts it could not resolve; read the answer with care.", + "reviewed": "The validator reviewed the answer without a clear verdict.", +} + + +def _issues(raw: Any) -> List[Dict[str, str]]: + out: List[Dict[str, str]] = [] + for item in raw if isinstance(raw, list) else []: + if isinstance(item, str) and item.strip(): + out.append({"severity": "medium", "text": item.strip()}) + elif isinstance(item, dict) and str(item.get("text") or item.get("issue") or "").strip(): + severity = str(item.get("severity") or "medium").lower() + out.append({"severity": severity if severity in ("high", "medium", "low") else "medium", + "text": str(item.get("text") or item.get("issue")).strip()}) + return out[:12] + + +def verdict_detail(validation: str, synthesis: str) -> Dict[str, Any]: + """The validator's structured result: ``verdict`` (validated / corrected / needs_review / + reviewed), ``final_answer``, ``note``, ``issues``, ``method``, ``limitations``. + + JSON first (what the prompt asks for); the older prose shape as a fallback. A correction + without text, or a "validated" with a high-severity issue, is a disagreement: needs_review.""" + text = (validation or "").strip() + data: Optional[Dict[str, Any]] = None + m = re.search(r"\{.*\}", text, flags=re.S) + if m: + try: + parsed = json.loads(m.group(0)) + data = parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + data = None + if data is not None: + verdict = str(data.get("verdict") or "").strip().lower().replace(" ", "_") + if verdict not in VERDICTS: + verdict = "reviewed" + issues = _issues(data.get("issues")) + corrected = str(data.get("corrected_answer") or "").strip() + method = str(data.get("method") or "model review").strip() + limitations = str(data.get("limitations") or "").strip() + if verdict == "corrected" and not corrected: + verdict = "needs_review" + if verdict == "validated" and any(i["severity"] == "high" for i in issues): + verdict = "needs_review" + final = corrected if verdict == "corrected" else synthesis + return {"verdict": verdict, "final_answer": final, "note": _NOTES[verdict], "issues": issues, + "method": method, "limitations": limitations, "structured": True} + + upper = text.upper() + at = upper.find("CORRECTION:") + if 0 <= at <= 40: + corrected = text[at + len("CORRECTION:"):].lstrip(" *#:-—\n").strip() + if corrected: + return {"verdict": "corrected", "final_answer": corrected, "note": _NOTES["corrected"], "issues": [], + "method": "model review", "limitations": "", "structured": False} + if "VALIDATED" in upper[:160]: + return {"verdict": "validated", "final_answer": synthesis, "note": _NOTES["validated"], "issues": [], + "method": "model review", "limitations": "", "structured": False} + return {"verdict": "reviewed", "final_answer": synthesis, "note": _NOTES["reviewed"], "issues": [], + "method": "model review", "limitations": "", "structured": False} + + +def verdict_of(validation: str, synthesis: str) -> Tuple[str, str, str]: + """``(verdict, final_answer, note)`` — the short form of :func:`verdict_detail`.""" + d = verdict_detail(validation, synthesis) + return d["verdict"], d["final_answer"], d["note"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Sync heavy pipeline +# ───────────────────────────────────────────────────────────────────────────── + +async def heavy( + query: str, + provider: ProviderName = "auto", + *, + history: Optional[List[Dict[str, str]]] = None, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + system_prompt: Optional[str] = None, + forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None, + research: bool = True, + registry: Any = None, + tool_rounds: Optional[int] = None, +) -> Dict[str, Any]: + """ + Run the heavy pipeline: research (when ``research``; else the reasoner gets the tools) → + reason → synthesize → validate. + Returns final answer + all intermediate agent outputs, the validator's structured + verdict, and the tool trace and sources. ``system_prompt``: operator instructions added + to every agent's role prompt; ``registry``: the request's tool set. + """ + + # Each agent keeps its own role prompt and gets the recent conversation ahead of its + # brief (see step_messages). + def _msg(system: str, user: str) -> List[Dict[str, str]]: + return step_messages(user, system_prompt=_role(system, system_prompt), history=history) + + kw = dict(model=model, temperature=temperature, max_tokens=max_tokens) + # A model that cannot call tools still gets grounded: HomePilot searches the question for it. + assist = [("hp_web_search", {"query": query})] + loop_kw = dict(call=dispatch, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist) + notices: List[str] = [] + fallback_applied = False + research_text = "" + tooled: Dict[str, Any] = {} + + researched = research + if research: + # Agent 1: Research — with the tool loop (web search, workspace, notes) + logger.debug("Heavy agent 1: researcher") + p_research = _agent_provider("researcher", provider) + try: + tooled = await run_with_tools( + _msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw + ) + except Exception as exc: # noqa: BLE001 - one agent down is not the turn down + researched = False + logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc) + notices.append(_agent_down("research agent", exc)) + else: + research_text = tooled.get("content") or "" + notices.extend(tooled.get("notices", [])) + fallback_applied = bool(tooled.get("fallback_applied")) + + # Agent 2: Reason (with the tools itself when no research agent ran) + logger.debug("Heavy agent 2: reasoner") + p_reason = _agent_provider("reasoner", provider) + if researched: + raw_reason = await dispatch( + _msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)), + p_reason, **kw + ) + reasoning = _extract(raw_reason) + notices.extend(_collect_notice(raw_reason)) + fallback_applied = fallback_applied or _fallback(raw_reason) + else: + tooled = await run_with_tools( + _msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw + ) + reasoning = tooled.get("content") or "" + notices.extend(tooled.get("notices", [])) + fallback_applied = fallback_applied or bool(tooled.get("fallback_applied")) + + # Agent 3: Synthesize + logger.debug("Heavy agent 3: synthesizer") + p_synth = _agent_provider("synthesizer", provider) + raw_synth = await dispatch( + _msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)), + p_synth, **kw + ) + synthesis = _extract(raw_synth) + notices.extend(_collect_notice(raw_synth)) + fallback_applied = fallback_applied or _fallback(raw_synth) + + # Agent 4: Validate + logger.debug("Heavy agent 4: validator") + p_valid = _agent_provider("validator", provider) + validation_skipped = "" + try: + raw_valid = await dispatch( + _msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)), + p_valid, **kw + ) + except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal + raw_valid, validation = {}, "" + validation_skipped = _agent_down("validator", exc) + logger.warning("Heavy validator failed; answer returned unverified: %s", exc) + notices.append(validation_skipped) + else: + validation = _extract(raw_valid) + notices.extend(_collect_notice(raw_valid)) + fallback_applied = fallback_applied or _fallback(raw_valid) + + synthesis, validation = strip_think(synthesis), strip_think(validation) + detail = verdict_detail(validation, synthesis) + if validation_skipped: + detail["note"] = "Not validated: the validator could not run. Read the answer with care." + detail["limitations"] = validation_skipped + + return { + "final_answer": detail["final_answer"], + "agents": { + **({"research": research_text} if research else {}), + "reasoning": reasoning, + "synthesis": synthesis, + "validation": validation, + }, + "verdict": detail["verdict"], + "verdict_note": detail["note"], + "verdict_detail": {k: detail[k] for k in ("verdict", "note", "issues", "method", "limitations", "structured")}, + "researched": research, + "provider": raw_valid.get("provider", provider), + "pipeline": "heavy", + "fallback_applied": fallback_applied, + "notices": _dedupe_notices(notices), + "tool_trace": tooled.get("tool_trace", []), + "sources": tooled.get("sources", []), + } + + +# ───────────────────────────────────────────────────────────────────────────── +# Streaming heavy pipeline +# ───────────────────────────────────────────────────────────────────────────── + +async def stream_heavy( + query: str, + provider: ProviderName = "auto", + *, + history: Optional[List[Dict[str, str]]] = None, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + system_prompt: Optional[str] = None, + forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None, + research: bool = True, + registry: Any = None, + tool_rounds: Optional[int] = None, +) -> AsyncIterator[Dict[str, Any]]: + """ + Stream the heavy pipeline agent by agent (the research agent only when ``research``; + otherwise the reasoner has the tools). + Each agent's output is streamed live; the UI renders each in a collapsible panel. + Besides step_start/token/step_end the stream carries the tool events, then ``verdict`` + (the validator's structured call), ``final`` (the answer after validation), ``done``. + """ + + def _msg(system: str, user: str) -> List[Dict[str, str]]: + return step_messages(user, system_prompt=_role(system, system_prompt), history=history) + + kw = dict(model=model, temperature=temperature, max_tokens=max_tokens) + assist = [("hp_web_search", {"query": query})] + loop_kw = dict(stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist) + research_text = "" + + researched = research + if research: + # ── Agent 1: Research — with the tool loop ─────────────────────────── + p_research = _agent_provider("researcher", provider) + yield {"type": "step_start", "step": "research", "label": "🔬 Researching deeply…", + "provider": p_research, "role": "work"} + try: + async for ev in stream_with_tools( + _msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw + ): + if ev["type"] == "token": + yield {"type": "token", "step": "research", "content": ev["content"]} + elif ev["type"] == "done": + research_text = ev.get("content") or "" + else: + yield {**ev, "step": "research"} + except Exception as exc: # noqa: BLE001 - one agent down is not the turn down + researched = False + logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc) + yield {"type": "notice", "text": _agent_down("research agent", exc)} + yield {"type": "step_end", "step": "research"} + + # ── Agent 2: Reason (with the tools itself when no research agent ran) ─── + p_reason = _agent_provider("reasoner", provider) + yield {"type": "step_start", "step": "reasoning", "label": "🧠 Reasoning step-by-step…", + "provider": p_reason, "role": "work"} + reason_chunks: List[str] = [] + if researched: + async for chunk in dispatch_stream( + _msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)), p_reason, **kw + ): + reason_chunks.append(chunk) + yield {"type": "token", "step": "reasoning", "content": chunk} + else: + async for ev in stream_with_tools( + _msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw + ): + if ev["type"] == "token": + reason_chunks.append(ev["content"]) + yield {"type": "token", "step": "reasoning", "content": ev["content"]} + elif ev["type"] == "done": + reason_chunks = [ev.get("content") or ""] + else: + yield {**ev, "step": "reasoning"} + reasoning = "".join(reason_chunks) + yield {"type": "step_end", "step": "reasoning"} + + # ── Agent 3: Synthesize ────────────────────────────────────────────────── + p_synth = _agent_provider("synthesizer", provider) + yield {"type": "step_start", "step": "synthesis", "label": "✍️ Synthesizing answer…", + "provider": p_synth, "role": "answer"} + synth_chunks: List[str] = [] + thinking = ThinkFilter() # the synthesis is the reply: no reasoning tags in it + try: + async for chunk in dispatch_stream( + _msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)), + p_synth, **kw + ): + clean = thinking.feed(chunk) + if clean: + synth_chunks.append(clean) + yield {"type": "token", "step": "synthesis", "content": clean} + except Exception as exc: # noqa: BLE001 - the reasoning already answers the question + if not reasoning.strip() or synth_chunks: + raise + logger.warning("Heavy synthesizer failed; answering from the reasoning: %s", exc) + yield {"type": "notice", "text": _agent_down("synthesizer", exc)} + fallback = strip_think(reasoning) + synth_chunks.append(fallback) + yield {"type": "token", "step": "synthesis", "content": fallback} + tail = thinking.flush() + if tail: + synth_chunks.append(tail) + yield {"type": "token", "step": "synthesis", "content": tail} + synthesis = "".join(synth_chunks) + yield {"type": "step_end", "step": "synthesis"} + + # ── Agent 4: Validate ──────────────────────────────────────────────────── + p_valid = _agent_provider("validator", provider) + yield {"type": "step_start", "step": "validation", "label": "✅ Validating answer…", + "provider": p_valid, "role": "work"} + validation_chunks: List[str] = [] + validation_skipped = "" + try: + async for chunk in dispatch_stream( + _msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)), + p_valid, **kw + ): + validation_chunks.append(chunk) + yield {"type": "token", "step": "validation", "content": chunk} + except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal + validation_skipped = _agent_down("validator", exc) + logger.warning("Heavy validator failed; answer returned unverified: %s", exc) + yield {"type": "notice", "text": validation_skipped} + yield {"type": "step_end", "step": "validation"} + + # The verdict decides what the reply is: the synthesis, or the validator's correction. + detail = verdict_detail(strip_think("".join(validation_chunks)), synthesis) + if validation_skipped: + detail["note"] = "Not validated: the validator could not run. Read the answer with care." + detail["limitations"] = validation_skipped + yield {"type": "verdict", "verdict": detail["verdict"], "note": detail["note"], + "detail": {k: detail[k] for k in ("issues", "method", "limitations", "structured")}} + yield {"type": "final", "content": detail["final_answer"]} + yield {"type": "done"} + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper +# ───────────────────────────────────────────────────────────────────────────── + +def _extract(raw: Dict[str, Any]) -> str: + try: + return raw["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError): + return "" + + +def _agent_down(agent: str, exc: Exception) -> str: + """The notice for an agent that could not run (a provider rate limit past its retries, an + outage): the turn goes on without it, and the reader is told what is missing.""" + reason = " ".join(str(exc).split())[:160] or type(exc).__name__ + return f"Heavy's {agent} could not run ({reason}); the answer was produced without it." + + +def _fallback(raw: Dict[str, Any]) -> bool: + return bool(extract_dispatch_meta(raw).get("fallback_applied")) + + +def _collect_notice(raw: Dict[str, Any]) -> List[str]: + notice = extract_dispatch_meta(raw).get("notice") + return [notice] if isinstance(notice, str) and notice else [] + + +def _dedupe_notices(notices: List[str]) -> List[str]: + return list(dict.fromkeys(notices)) diff --git a/expert/backend/app/expert/mcp_catalog.py b/expert/backend/app/expert/mcp_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..3eccd139a4bba36e9a6121061d9a71187cf44e90 --- /dev/null +++ b/expert/backend/app/expert/mcp_catalog.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + + +@dataclass(frozen=True) +class MCPServerSpec: + key: str + priority: str + purpose: str + required_apis: List[str] + + +ESSENTIAL_MCP_SERVERS: List[MCPServerSpec] = [ + MCPServerSpec( + key="mcp-web-search", + priority="P0", + purpose="Grounded web retrieval and citation candidates.", + required_apis=["search(query, top_k, recency_days)", "fetch(url)", "extract(content)"], + ), + MCPServerSpec( + key="mcp-doc-retrieval", + priority="P0", + purpose="Private knowledge retrieval and RAG.", + required_apis=["index(document_id, text, metadata)", "query(text, top_k, filters)", "delete(document_id)"], + ), + MCPServerSpec( + key="mcp-code-sandbox", + priority="P0", + purpose="Safe code execution and deterministic computation.", + required_apis=["run(language, code, timeout_s, memory_mb)", "status(run_id)", "result(run_id)"], + ), + MCPServerSpec( + key="mcp-citation-provenance", + priority="P0", + purpose="Claim to source lineage and citation integrity checks.", + required_apis=["attach_claim(claim_text, source_refs)", "verify_citations(response_text)", "get_lineage(response_id)"], + ), + MCPServerSpec( + key="mcp-memory-store", + priority="P0", + purpose="Durable memory service with privacy controls.", + required_apis=["append(session_id, role, content)", "recall(session_id, limit)", "profile_upsert(user_id, key, value)", "forget(scope_id)"], + ), + MCPServerSpec( + key="mcp-safety-policy", + priority="P1", + purpose="Input and output policy checks plus risk scoring.", + required_apis=["check_input(text, profile)", "check_output(text, profile)", "risk_score(context)"], + ), + MCPServerSpec( + key="mcp-eval-runner", + priority="P1", + purpose="Automated quality, safety, and cost evaluation suites.", + required_apis=["run_suite(suite_id, model_profile)", "report(run_id)", "regression_gate(run_id, baseline_id)"], + ), + MCPServerSpec( + key="mcp-observability", + priority="P1", + purpose="Central export for metrics, traces, and event logs.", + required_apis=["emit_metric(name, value, tags)", "emit_trace(trace_id, spans)", "emit_event(event_type, payload)"], + ), + MCPServerSpec( + key="mcp-cost-router", + priority="P2", + purpose="Budget-aware route recommendations and cost recording.", + required_apis=["recommend_route(query_meta, budget_state)", "record_cost(provider, tokens, latency_ms, success)", "monthly_budget_status(org_id)"], + ), + MCPServerSpec( + key="mcp-job-orchestrator", + priority="P2", + purpose="Long-running simulation and research job queue orchestration.", + required_apis=["submit(job_type, payload, priority)", "status(job_id)", "cancel(job_id)", "result(job_id)"], + ), +] + + +TOOL_MAPPING: Dict[str, str] = { + "web_search": "mcp-web-search", + "retrieval": "mcp-doc-retrieval", + "code_exec": "mcp-code-sandbox", + "model_compare": "mcp-eval-runner", +} + +# MCP Context Forge tool identifiers used by the Expert gateway. +# Default pattern keeps names predictable and can be overridden by env. +FORGE_TOOL_MAPPING: Dict[str, str] = { + "mcp-web-search": "mcp_web_search", + "mcp-doc-retrieval": "mcp_doc_retrieval", + "mcp-code-sandbox": "mcp_code_sandbox", + "mcp-citation-provenance": "mcp_citation_provenance", + "mcp-memory-store": "mcp_memory_store", + "mcp-safety-policy": "mcp_safety_policy", + "mcp-eval-runner": "mcp_eval_runner", + "mcp-observability": "mcp_observability", + "mcp-cost-router": "mcp_cost_router", + "mcp-job-orchestrator": "mcp_job_orchestrator", +} + + +def mcp_key_to_env(key: str) -> str: + normalized = key.upper().replace("-", "_") + return f"EXPERT_{normalized}_URL" + + +def p0_required_server_keys() -> List[str]: + return [s.key for s in ESSENTIAL_MCP_SERVERS if s.priority == "P0"] + + +def mcp_key_to_forge_tool_env(key: str) -> str: + normalized = key.upper().replace("-", "_") + return f"EXPERT_{normalized}_FORGE_TOOL_ID" diff --git a/expert/backend/app/expert/mcp_gateway.py b/expert/backend/app/expert/mcp_gateway.py new file mode 100644 index 0000000000000000000000000000000000000000..d0d492c7ef434a2e9d1d91822612ccc5b39d5e79 --- /dev/null +++ b/expert/backend/app/expert/mcp_gateway.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import httpx +try: + from app.agentic.client import ContextForgeClient # type: ignore +except Exception: # pragma: no cover - optional dependency in isolated expert test env + ContextForgeClient = None # type: ignore + +from .mcp_catalog import ESSENTIAL_MCP_SERVERS, FORGE_TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env + + +@dataclass +class MCPServerStatus: + key: str + priority: str + configured: bool + endpoint: str + forge_tool_id: str + + +class MCPGateway: + """Thin HTTP gateway for Expert MCP servers. + + The gateway is intentionally generic and additive so servers can be swapped + without changing Expert routes/orchestration contracts. + """ + + def __init__(self, timeout_s: float = 15.0, env: Optional[Dict[str, str]] = None) -> None: + self.timeout_s = timeout_s + self.env = env or dict(os.environ) + self.orchestrator = (self.env.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower() + self.forge_base_url = (self.env.get("CONTEXT_FORGE_URL", "http://localhost:4444") or "").strip().rstrip("/") + self.forge_auth_user = (self.env.get("CONTEXT_FORGE_AUTH_USER", "admin") or "admin").strip() + self.forge_auth_pass = (self.env.get("CONTEXT_FORGE_AUTH_PASS", "changeme") or "changeme").strip() + self.forge_token = (self.env.get("CONTEXT_FORGE_TOKEN", "") or "").strip() + self._endpoint_map = self._build_endpoint_map() + self._forge_tool_map = self._build_forge_tool_map() + + def _build_endpoint_map(self) -> Dict[str, str]: + mapping: Dict[str, str] = {} + for spec in ESSENTIAL_MCP_SERVERS: + endpoint = (self.env.get(mcp_key_to_env(spec.key), "") or "").strip() + if endpoint: + mapping[spec.key] = endpoint.rstrip("/") + return mapping + + def endpoint_for(self, key: str) -> str: + return self._endpoint_map.get(key, "") + + def forge_tool_for(self, key: str) -> str: + return self._forge_tool_map.get(key, "") + + def _build_forge_tool_map(self) -> Dict[str, str]: + mapping: Dict[str, str] = {} + for spec in ESSENTIAL_MCP_SERVERS: + env_key = mcp_key_to_forge_tool_env(spec.key) + tool_id = (self.env.get(env_key, "") or "").strip() or FORGE_TOOL_MAPPING.get(spec.key, "") + if tool_id: + mapping[spec.key] = tool_id + return mapping + + def status(self) -> list[MCPServerStatus]: + status: list[MCPServerStatus] = [] + for spec in ESSENTIAL_MCP_SERVERS: + endpoint = self.endpoint_for(spec.key) + status.append( + MCPServerStatus( + key=spec.key, + priority=spec.priority, + configured=bool(endpoint or self.forge_tool_for(spec.key)), + endpoint=endpoint, + forge_tool_id=self.forge_tool_for(spec.key), + ) + ) + return status + + def _forge_client(self) -> ContextForgeClient: + if ContextForgeClient is None: + raise RuntimeError("ContextForgeClient is unavailable; ensure backend/app is on PYTHONPATH") + return ContextForgeClient( + base_url=self.forge_base_url, + token=self.forge_token, + auth_user=self.forge_auth_user, + auth_pass=self.forge_auth_pass, + ) + + async def _invoke_via_forge(self, server_key: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if not self.forge_base_url: + return { + "ok": False, + "error": "context_forge_not_configured", + "server": server_key, + } + tool_id = self.forge_tool_for(server_key) + if not tool_id: + return { + "ok": False, + "error": f"forge_tool_not_configured:{server_key}", + "server": server_key, + } + try: + client = self._forge_client() + except Exception as exc: + return { + "ok": False, + "error": str(exc), + "server": server_key, + "tool_id": tool_id, + "orchestrator": "context_forge", + } + + result = await client.invoke_tool(tool_id=tool_id, args=payload, timeout=self.timeout_s) + if isinstance(result, dict) and result.get("error"): + return { + "ok": False, + "error": str(result["error"]), + "server": server_key, + "tool_id": tool_id, + "orchestrator": "context_forge", + } + return { + "ok": True, + "server": server_key, + "tool_id": tool_id, + "orchestrator": "context_forge", + "data": result, + } + + async def invoke(self, server_key: str, method: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if self.orchestrator == "context_forge": + return await self._invoke_via_forge(server_key=server_key, payload={"method": method, "payload": payload}) + + endpoint = self.endpoint_for(server_key) + if not endpoint: + return { + "ok": False, + "error": f"server_not_configured:{server_key}", + "server": server_key, + "method": method, + } + + url = f"{endpoint.rstrip('/')}/{method.lstrip('/')}" + try: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() if resp.text else {} + return {"ok": True, "server": server_key, "method": method, "data": data, "orchestrator": "direct"} + except Exception as exc: + return { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "server": server_key, + "method": method, + "url": url, + "orchestrator": "direct", + } + + # P0 typed wrappers (preprod essentials) + async def web_search_search(self, query: str, top_k: int = 5, recency_days: int = 7) -> Dict[str, Any]: + return await self.invoke( + "mcp-web-search", + "search", + {"query": query, "top_k": top_k, "recency_days": recency_days}, + ) + + async def doc_retrieval_query(self, text: str, top_k: int = 5, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return await self.invoke( + "mcp-doc-retrieval", + "query", + {"text": text, "top_k": top_k, "filters": filters or {}}, + ) + + async def code_sandbox_run(self, language: str, code: str, timeout_s: int = 15, memory_mb: int = 256) -> Dict[str, Any]: + return await self.invoke( + "mcp-code-sandbox", + "run", + {"language": language, "code": code, "timeout_s": timeout_s, "memory_mb": memory_mb}, + ) + + async def citation_verify(self, response_text: str) -> Dict[str, Any]: + return await self.invoke( + "mcp-citation-provenance", + "verify_citations", + {"response_text": response_text}, + ) + + async def memory_append(self, session_id: str, role: str, content: str) -> Dict[str, Any]: + return await self.invoke( + "mcp-memory-store", + "append", + {"session_id": session_id, "role": role, "content": content}, + ) diff --git a/expert/backend/app/expert/mcp_tools.py b/expert/backend/app/expert/mcp_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..6d3ce9bbce3b42e7b10b5f362d934cdb10edc086 --- /dev/null +++ b/expert/backend/app/expert/mcp_tools.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import httpx + + +@dataclass +class ToolResult: + tool: str + ok: bool + content: str + meta: Dict[str, Any] + + +@dataclass +class ToolCall: + tool: str + input: str + + +class MCPToolClient: + """Production-oriented MCP tool runner with explicit call budget.""" + + def __init__(self, endpoints: Dict[str, str], timeout_s: float = 15.0) -> None: + self.endpoints = endpoints + self.timeout_s = timeout_s + + async def run_many(self, calls: List[ToolCall], budget: int = 3) -> List[ToolResult]: + out: List[ToolResult] = [] + remaining = budget + for call in calls: + if remaining <= 0: + out.append(ToolResult(call.tool, False, "Skipped (budget exhausted)", {"budget": budget})) + continue + + endpoint = self.endpoints.get(call.tool) + if not endpoint: + out.append(ToolResult(call.tool, False, "No MCP endpoint configured", {})) + continue + + remaining -= 1 + out.append(await self._invoke(call.tool, endpoint, call.input)) + return out + + async def _invoke(self, tool: str, endpoint: str, payload: str) -> ToolResult: + try: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + resp = await client.post(endpoint, json={"input": payload}) + resp.raise_for_status() + data = resp.json() if resp.text else {} + content = str(data.get("content", data)) + return ToolResult(tool, True, content, {"endpoint": endpoint}) + except Exception as exc: + return ToolResult(tool, False, f"{type(exc).__name__}: {exc}", {"endpoint": endpoint}) diff --git a/expert/backend/app/expert/observability.py b/expert/backend/app/expert/observability.py new file mode 100644 index 0000000000000000000000000000000000000000..53e10b98449d552556e72efb46d603f26634af0e --- /dev/null +++ b/expert/backend/app/expert/observability.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + + +@dataclass +class MetricEvent: + ok: bool + latency_ms: int + + +class SLOMonitor: + def __init__(self) -> None: + self._events: List[MetricEvent] = [] + + def record(self, ok: bool, latency_ms: int) -> None: + self._events.append(MetricEvent(ok=ok, latency_ms=latency_ms)) + if len(self._events) > 5000: + self._events = self._events[-5000:] + + def snapshot(self) -> dict: + if not self._events: + return {"error_rate": 0.0, "p95_ms": 0, "count": 0} + + errs = sum(1 for e in self._events if not e.ok) + lats = sorted(e.latency_ms for e in self._events) + idx = min(len(lats) - 1, int(len(lats) * 0.95)) + return { + "error_rate": errs / len(self._events), + "p95_ms": lats[idx], + "count": len(self._events), + } diff --git a/expert/backend/app/expert/persistent_memory.py b/expert/backend/app/expert/persistent_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..9d1259476f488b9640dd5e72e8c98a9bc99a5711 --- /dev/null +++ b/expert/backend/app/expert/persistent_memory.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import List + + +_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +_PHONE_RE = re.compile(r"\+?[0-9][0-9\- ]{7,}[0-9]") + + +@dataclass +class MemoryRow: + role: str + content: str + ts: int + + +class SqliteMemoryStore: + def __init__(self, db_path: str) -> None: + self.db_path = db_path + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _conn(self): + return sqlite3.connect(self.db_path) + + def _init_db(self) -> None: + with self._conn() as c: + c.execute( + """ + CREATE TABLE IF NOT EXISTS expert_memory( + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + ts INTEGER NOT NULL + ) + """ + ) + c.execute("CREATE INDEX IF NOT EXISTS idx_expert_memory_session_ts ON expert_memory(session_id, ts)") + + def append(self, session_id: str, role: str, content: str) -> None: + safe = redact_pii(content) + with self._conn() as c: + c.execute( + "INSERT INTO expert_memory(session_id, role, content, ts) VALUES (?, ?, ?, ?)", + (session_id, role, safe, int(time.time())), + ) + + def recall(self, session_id: str, limit: int = 8) -> List[MemoryRow]: + with self._conn() as c: + rows = c.execute( + "SELECT role, content, ts FROM expert_memory WHERE session_id = ? ORDER BY ts DESC LIMIT ?", + (session_id, limit), + ).fetchall() + return [MemoryRow(role=r[0], content=r[1], ts=r[2]) for r in reversed(rows)] + + +def redact_pii(text: str) -> str: + text = _EMAIL_RE.sub("[REDACTED_EMAIL]", text) + text = _PHONE_RE.sub("[REDACTED_PHONE]", text) + return text diff --git a/expert/backend/app/expert/policies.py b/expert/backend/app/expert/policies.py new file mode 100644 index 0000000000000000000000000000000000000000..6510a6c1a3ce695443a27c8c7730ba5b3cd80264 --- /dev/null +++ b/expert/backend/app/expert/policies.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass +from typing import Literal + +from .config import ( + EXPERT_DEEP_TEMPERATURE, + EXPERT_FAST_MAX_TOKENS, + EXPERT_FAST_TEMPERATURE, + EXPERT_FAST_TOOL_ROUNDS, + EXPERT_HEAVY_MAX_TOKENS, + EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY, + EXPERT_HEAVY_TEMPERATURE, + EXPERT_HEAVY_TOOL_ROUNDS, + EXPERT_THINK_MAX_TOKENS, + EXPERT_THINK_PLAN_MIN_COMPLEXITY, + EXPERT_THINK_TEMPERATURE, + EXPERT_THINK_TOOL_ROUNDS, +) + + +ThinkingMode = Literal["auto", "fast", "think", "heavy"] + + +@dataclass(frozen=True) +class ModeResolution: + mode_used: ThinkingMode + strategy: Literal["single-pass", "expert-thinking", "heavy-multi-pass"] + + +def resolve_thinking_mode(mode: ThinkingMode, complexity: int) -> ModeResolution: + """Deterministically resolve auto/explicit mode into concrete pipeline strategy.""" + if mode == "auto": + if complexity >= 8: + mode = "heavy" + elif complexity >= 5: + mode = "think" + else: + mode = "fast" + + strategy = { + "fast": "single-pass", + "think": "expert-thinking", + "heavy": "heavy-multi-pass", + }[mode] + + return ModeResolution(mode_used=mode, strategy=strategy) + + +# ───────────────────────────────────────────────────────────────────────────── +# Effort: the mode is a ceiling; the request decides how much of it is spent +# ───────────────────────────────────────────────────────────────────────────── + +EffortLevel = Literal["direct", "standard", "full"] + +_GREETING = re.compile( + r"^(hi|hello|hey|yo|hola|ciao|salve|bonjour|hallo|thanks|thank you|thx|ok|okay|test|ping|" + r"good (morning|afternoon|evening)|how are you)\b[\s!.?,]*$", + re.I, +) + + +def is_trivial(query: str) -> bool: + """A greeting, an acknowledgement, or a few words with no question: the kind of turn that + needs one generation whatever mode is selected (the same rule the composer uses).""" + t = (query or "").strip() + if not t or _GREETING.match(t): + return True + words = t.split() + return len(words) <= 3 and "?" not in t and len(t) <= 24 + + +@dataclass(frozen=True) +class EffortProfile: + mode: str + level: EffortLevel # direct = one generation · standard = the mode without its optional stages · full + temperature: float + max_tokens: int + tool_rounds: int + plan: bool # think: analyze → plan before solving + research: bool # heavy: the research agent runs + reason: str # one line for the trace + analyze: bool = False # think: a visible analysis step before the answer (always with plan) + + def as_dict(self) -> dict: + return asdict(self) + + +def effort_for(mode: str, complexity: int, *, trivial: bool = False, forced_tools: bool = False) -> EffortProfile: + """What this request may spend in ``mode``. Fast is always one generation. Expert plans + only above a complexity threshold. Heavy researches only when the question is complex or + the user asked for a search; a trivial message is answered directly in every mode.""" + if mode == "fast": + return EffortProfile("fast", "direct", EXPERT_FAST_TEMPERATURE, EXPERT_FAST_MAX_TOKENS, + EXPERT_FAST_TOOL_ROUNDS, False, False, "one generation, tools on demand") + if mode == "think": + if trivial: + return EffortProfile("think", "direct", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS, + EXPERT_THINK_TOOL_ROUNDS, False, False, "a short message needs no plan") + if complexity < EXPERT_THINK_PLAN_MIN_COMPLEXITY: + # The reasoning stays visible: a short analysis, then the answer from it (two calls). + return EffortProfile( + "think", "standard", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS, EXPERT_THINK_TOOL_ROUNDS, + False, False, + f"complexity {complexity} is below {EXPERT_THINK_PLAN_MIN_COMPLEXITY}: analyzed, then answered without a separate plan", + analyze=True, + ) + return EffortProfile("think", "full", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS, + EXPERT_THINK_TOOL_ROUNDS, True, False, f"complexity {complexity}: analyze, plan, solve", + analyze=True) + if mode == "heavy": + if trivial: + return EffortProfile("heavy", "direct", EXPERT_HEAVY_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS, + EXPERT_HEAVY_TOOL_ROUNDS, False, False, "a short message needs no verification") + research = forced_tools or complexity >= EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY + if forced_tools: + why = "a web search was asked for: research, reason, synthesize, validate" + elif research: + why = f"complexity {complexity}: research, reason, synthesize, validate" + else: + why = f"complexity {complexity} is below {EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY}: reason, synthesize, validate" + return EffortProfile("heavy", "full" if research else "standard", EXPERT_HEAVY_TEMPERATURE, + EXPERT_HEAVY_MAX_TOKENS, EXPERT_HEAVY_TOOL_ROUNDS, False, research, why) + return EffortProfile("deep", "full", EXPERT_DEEP_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS, + EXPERT_THINK_TOOL_ROUNDS, True, True, "deep research runs its own plan") diff --git a/expert/backend/app/expert/project_context.py b/expert/backend/app/expert/project_context.py new file mode 100644 index 0000000000000000000000000000000000000000..df5eaa1a728a8a980d8df163df79e87f106dcd90 --- /dev/null +++ b/expert/backend/app/expert/project_context.py @@ -0,0 +1,107 @@ +"""A project's context for Expert: its instructions in the system prompt, its documents as a +tool. + +When Expert runs inside HomePilot (the usual case: the package is mounted as ``app.expert``), +the project's documents are searched through the backend's own retrieval — the same +``query_project_knowledge`` the standard chat uses — so Expert in a project sees what the +standard chat sees. As a standalone service that retrieval is out of reach, and the tool is +simply not offered; the instructions still apply. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable, Dict, List, Optional + +from .config import EXPERT_SYSTEM_PROMPT +from .tools import LocalTool, ToolOutcome, ToolSpec + +logger = logging.getLogger("expert.project") + +PROJECT_TOOL = "project_search" +PROJECT_TOOL_NATIVE = "hp.project.search" + + +def project_system_prompt(base: Optional[str], project: Optional[Dict[str, Any]]) -> Optional[str]: + """The system prompt with the project folded in; ``base`` (or the Expert default) alone + when there is no project.""" + if not project or not (project.get("name") or project.get("instructions")): + return base + name = str(project.get("name") or "this project").strip() + lines = [base or EXPERT_SYSTEM_PROMPT, "", f"You are working inside the project “{name}”."] + instructions = str(project.get("instructions") or "").strip() + if instructions: + lines += ["Project instructions:", instructions] + if int(project.get("document_count") or 0) > 0: + lines.append( + f"The project has documents; the {PROJECT_TOOL} tool searches them. Prefer what they say over " + "general knowledge and name the document you used." + ) + return "\n".join(lines) + + +def _retrieval() -> Optional[Callable[..., List[Dict[str, Any]]]]: + """The backend's project retrieval, when this Expert runs inside HomePilot.""" + try: + from ..vectordb import query_project_knowledge # type: ignore[import-not-found] + return query_project_knowledge + except Exception: # noqa: BLE001 — standalone Expert, or RAG disabled + return None + + +def render_passages(docs: List[Dict[str, Any]]) -> str: + """Retrieved chunks grouped per document, numbered, the way the standard chat renders them.""" + grouped: Dict[str, List[str]] = {} + for doc in docs or []: + if not isinstance(doc, dict): + continue + source = str((doc.get("metadata") or {}).get("source") or "Unknown") + grouped.setdefault(source, []).append(str(doc.get("content") or "")) + if not grouped: + return "No matching passages in the project's documents." + parts = [] + for i, (source, chunks) in enumerate(grouped.items(), 1): + parts.append(f"[Document {i}: {source}]\n" + "\n...\n".join(c.strip() for c in chunks if c.strip())) + return "\n\n".join(parts) + + +def project_tool(project: Optional[Dict[str, Any]], retrieval: Optional[Callable[..., Any]] = None) -> Optional[LocalTool]: + """The ``project_search`` tool for ``project``, or None when the project has no documents or + retrieval is not reachable from this process.""" + if not project or not project.get("id") or int(project.get("document_count") or 0) <= 0: + return None + search = retrieval or _retrieval() + if search is None: + return None + project_id = str(project["id"]) + name = str(project.get("name") or "the project") + + async def handler(arguments: Dict[str, Any]) -> ToolOutcome: + query = str(arguments.get("query") or "").strip() + if not query: + return ToolOutcome(name=PROJECT_TOOL, ok=False, error="query is required") + limit = max(1, min(int(arguments.get("limit") or 5), 10)) + docs = await asyncio.to_thread(search, project_id, query, limit) + docs = list(docs or []) + sources = {str((d.get("metadata") or {}).get("source") or "Unknown") for d in docs if isinstance(d, dict)} + text = render_passages(docs) + return ToolOutcome( + name=PROJECT_TOOL, ok=True, text=text, + data={"project_id": project_id, "passages": len(docs), "documents": sorted(sources)}, + ) + + spec = ToolSpec( + name=PROJECT_TOOL, + native=PROJECT_TOOL_NATIVE, + description=f"Search the documents of the project “{name}” for passages relevant to a query.", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "What to look for in the project's documents"}, + "limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 10}, + }, + "required": ["query"], + }, + server="local", + ) + return LocalTool(spec=spec, handler=handler) diff --git a/expert/backend/app/expert/providers.py b/expert/backend/app/expert/providers.py new file mode 100644 index 0000000000000000000000000000000000000000..3464f2cafd4faa6cf09fa3cec1e3697db21eb53c --- /dev/null +++ b/expert/backend/app/expert/providers.py @@ -0,0 +1,1282 @@ +# expert/providers.py +# All new LLM provider adapters for the Expert module. +# Pattern mirrors backend/app/llm.py — returns OpenAI-compatible dicts. +# Streaming variants yield raw SSE chunks for the /expert/stream endpoint. +from __future__ import annotations + +import asyncio +import contextlib +import contextvars +import heapq +import itertools +import json +import logging +import os +import random +import time +from collections import deque +from typing import Any, AsyncIterator, Deque, Dict, List, Optional, Tuple + +import httpx + +from .config import ( + GROK_API_KEY, GROK_BASE_URL, GROK_MODEL, + GROQ_API_KEY, GROQ_BASE_URL, GROQ_MODEL, + GEMINI_API_KEY, GEMINI_BASE_URL, GEMINI_MODEL, + EXPERT_OLLAMA_URL, EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL, + EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, + EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S, + EXPERT_PROVIDER_RETRIES, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S, + EXPERT_PROVIDER_WINDOW_S, +) + +logger = logging.getLogger("expert.providers") + +_TIMEOUT = httpx.Timeout(timeout=120.0, connect=15.0) +_STREAM_TIMEOUT = httpx.Timeout(timeout=300.0, connect=15.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# Pacing and retries — one gateway is never flooded, and a rate limit is waited out +# ───────────────────────────────────────────────────────────────────────────── + +# Who is waiting: 0 = a person in the chat, 1 = background work (a Deep research job). When the +# endpoint is scarce, interactive requests are served first and may wait less long. +INTERACTIVE, BACKGROUND = 0, 1 +_PRIORITY: contextvars.ContextVar[int] = contextvars.ContextVar("expert_request_priority", default=INTERACTIVE) + + +def mark_background() -> None: + """Make the current task's model calls background work (call at the start of a job's task; + a task's context is its own, so the setting never leaks into a chat request).""" + _PRIORITY.set(BACKGROUND) + + +class _PriorityGate: + """A semaphore whose waiters are served by priority, then first come first served — the + request queue in front of one endpoint. Cancellation-safe: a waiter that goes away leaves + the queue, and a slot granted to a request that was cancelled passes to the next one.""" + + def __init__(self, capacity: int) -> None: + self.capacity = max(1, int(capacity)) + self.inflight = 0 + self._waiters: List[Tuple[int, int, "asyncio.Future[None]"]] = [] + self._seq = itertools.count() + + def waiting(self) -> int: + return sum(1 for _, _, fut in self._waiters if not fut.done()) + + async def acquire(self, priority: int) -> None: + if self.inflight < self.capacity and not self.waiting(): + self.inflight += 1 + return + fut: "asyncio.Future[None]" = asyncio.get_running_loop().create_future() + heapq.heappush(self._waiters, (priority, next(self._seq), fut)) + try: + await fut + except asyncio.CancelledError: + if fut.done() and not fut.cancelled(): + self.release() # granted, then cancelled: hand the slot on + raise + + def release(self) -> None: + while self._waiters: + _, _, fut = heapq.heappop(self._waiters) + if not fut.done(): + fut.set_result(None) # the slot passes straight to the next waiter + return + self.inflight = max(0, self.inflight - 1) + + +class _EndpointPacer: + """Adaptive client-side throttling, per base URL (the pattern behind the AWS SDKs' + "adaptive" retry mode and Google SRE's client-side throttling): + + * a **queue** in front of every endpoint: at most ``max_inflight`` requests at a time, the + rest wait in line — interactive requests ahead of background work — and a gap between + request starts; + * the gap starts at ``min_interval`` and **doubles on every rate limit** (up to + ``max_interval``), and every other caller to that endpoint waits out the cooldown too — + so one 429 slows the whole app down instead of each request hammering on its own; + * every success **shrinks the gap back** by 15 %, so a quiet endpoint is fast again soon. + + Heavy's agents and Deep research's sub-agents then reach a shared gateway as a steady + trickle that adapts to its real limit, instead of a burst that trips it.""" + + def __init__(self, max_inflight: int, min_interval: float, max_interval: float, window: float = 60.0) -> None: + self.max_inflight = max(1, int(max_inflight)) + self.min_interval = max(0.0, float(min_interval)) + self.max_interval = max(self.min_interval, float(max_interval)) + self.window = max(0.01, float(window)) + self._slots: Dict[str, _PriorityGate] = {} + self._next_start: Dict[str, float] = {} + self._interval: Dict[str, float] = {} + # The meter, per endpoint: request starts (reserved, oldest first), successes and rate + # limits in the rolling window, and the learned cap — requests per window the endpoint + # takes before it says "rate limit" — with when it was last lowered or raised. + self._starts: Dict[str, Deque[float]] = {} + self._ok: Dict[str, Deque[float]] = {} + self._limited: Dict[str, Deque[float]] = {} + self._cap: Dict[str, int] = {} + self._cap_since: Dict[str, float] = {} + # Token bucket at the learned rate (cap per window): tokens left, and when last counted. + # It goes negative as requests reserve future slots, so starts are spread evenly. + self._tokens: Dict[str, float] = {} + self._refill_at: Dict[str, float] = {} + self._totals: Dict[str, Dict[str, int]] = {} + self._urls: Dict[str, str] = {} + + def reset(self, *, min_interval: Optional[float] = None, max_interval: Optional[float] = None, + window: Optional[float] = None) -> None: + """Forget every endpoint's state (tests; a settings change).""" + if min_interval is not None: + self.min_interval = max(0.0, float(min_interval)) + if max_interval is not None: + self.max_interval = max(self.min_interval, float(max_interval)) + if window is not None: + self.window = max(0.01, float(window)) + for store in (self._slots, self._next_start, self._interval, self._starts, self._ok, self._limited, + self._cap, self._cap_since, self._tokens, self._refill_at, self._totals, self._urls): + store.clear() + + # ── the meter ──────────────────────────────────────────────────────────── + def _trim(self, q: Deque[float], now: float) -> Deque[float]: + while q and q[0] < now - self.window: + q.popleft() + return q + + def _count(self, key: str, what: str) -> None: + totals = self._totals.setdefault(key, {"requests": 0, "ok": 0, "rate_limited": 0}) + totals[what] = totals.get(what, 0) + 1 + + def cap(self, base_url: str) -> Optional[int]: + return self._cap.get(self._key(base_url)) + + def usage(self) -> List[Dict[str, Any]]: + """Per endpoint: what is in flight and in line, the gap and cooldown, the learned cap, + and the rolling window's requests, successes and rate limits (``GET /v1/expert/usage``).""" + now = time.monotonic() + out: List[Dict[str, Any]] = [] + for key, url in self._urls.items(): + gate = self._slots.get(key) + limited = self._trim(self._limited.setdefault(key, deque()), now) + out.append({ + "endpoint": url, + "in_flight": gate.inflight if gate else 0, + "queued": gate.waiting() if gate else 0, + "interval_s": round(self._interval.get(key, self.min_interval), 2), + "cooldown_s": round(max(0.0, self._next_start.get(key, 0.0) - now), 1), + "window_s": self.window, + "cap_per_window": self._cap.get(key), + "tokens": round(self._tokens[key], 2) if key in self._tokens else None, + "requests_in_window": len(self._trim(self._starts.setdefault(key, deque()), now)), + "ok_in_window": len(self._trim(self._ok.setdefault(key, deque()), now)), + "rate_limited_in_window": len(limited), + "last_rate_limit_s_ago": round(now - limited[-1], 1) if limited else None, + "totals": dict(self._totals.get(key, {})), + }) + return out + + @staticmethod + def _key(base_url: str) -> str: + # Keyed by event loop too: a semaphore belongs to the loop it first waited in. + return f"{id(asyncio.get_running_loop())}|{(base_url or '').rstrip('/').lower()}" + + def interval(self, base_url: str) -> float: + return self._interval.get(self._key(base_url), self.min_interval) + + def _gate(self, key: str) -> _PriorityGate: + gate = self._slots.get(key) + if gate is None: + gate = self._slots[key] = _PriorityGate(self.max_inflight) + return gate + + def expected_wait(self, base_url: str) -> float: + """Roughly how long a request sent now waits before it starts: the cooldown, plus one + gap per request already in line. Used to tell the reader before a long wait.""" + key = self._key(base_url) + gate = self._gate(key) + cooldown = max(0.0, self._next_start.get(key, 0.0) - time.monotonic()) + queued = gate.waiting() + (1 if gate.inflight >= gate.capacity else 0) + return cooldown + queued * self._interval.get(key, self.min_interval) + + def _bucket(self, key: str, cap: int, now: float) -> float: + """Tokens in the bucket now (refilled at ``cap`` per window since last counted).""" + rate = cap / self.window + tokens = self._tokens.get(key, float(cap)) + tokens = min(float(cap), tokens + (now - self._refill_at.get(key, now)) * rate) + self._refill_at[key] = now + return tokens + + def _reserve(self, key: str) -> float: + """This request's start time: after the cooldown and the gap, and — once a cap has been + learned — when the token bucket has a token for it. Starts are then spread evenly at the + learned rate (one every window/cap seconds) instead of a burst and a long stall.""" + now = time.monotonic() + start_at = max(now, self._next_start.get(key, 0.0)) + starts = self._starts.setdefault(key, deque()) + cap = self._cap.get(key) + if cap: + tokens = self._bucket(key, cap, now) - 1.0 + self._tokens[key] = tokens + if tokens < 0: + start_at = max(start_at, now + (-tokens) * self.window / cap) + starts.append(start_at) + while starts and starts[0] < now - 2 * self.window: + starts.popleft() + self._next_start[key] = start_at + self._interval.get(key, self.min_interval) + return start_at + + @contextlib.asynccontextmanager + async def slot(self, base_url: str) -> AsyncIterator[None]: + key = self._key(base_url) + self._urls.setdefault(key, (base_url or "").rstrip("/")) + gate = self._gate(key) + await gate.acquire(_PRIORITY.get()) + try: + # Reserve this request's start time before sleeping, so two waiting callers can never + # read the same free moment and start together. + start_at = self._reserve(key) + self._count(key, "requests") + now = time.monotonic() + if start_at > now: + await asyncio.sleep(start_at - now) + yield + finally: + gate.release() + + def penalize(self, base_url: str, retry_after: Optional[float] = None) -> float: + """A rate limit: double the gap, hold every caller until the cooldown ends, and learn the + cap from the successes this window took — the endpoint's limit is at most that. With no + success in the window there is nothing to learn from (the limit may be shared with other + clients): an existing cap is lowered by one, and otherwise backoff alone handles it. + The bucket is emptied, so the next start waits one learned interval.""" + key = self._key(base_url) + now = time.monotonic() + self._count(key, "rate_limited") + self._trim(self._limited.setdefault(key, deque()), now).append(now) + ok = len(self._trim(self._ok.setdefault(key, deque()), now)) + previous = self._cap.get(key) + if ok >= 1: + self._cap[key] = max(1, min(ok, previous - 1)) if previous else ok + elif previous: + self._cap[key] = max(1, previous - 1) + if key in self._cap: + self._cap_since[key] = now + self._tokens[key] = min(0.0, self._bucket(key, self._cap[key], now)) + if self._cap[key] != previous: + logger.info("endpoint %s: learned a limit of about %d requests per %.0fs", self._urls.get(key, key), + self._cap[key], self.window) + current = self._interval.get(key, self.min_interval) + grown = min(self.max_interval, max(current * 2, 2.0, self.min_interval)) + self._interval[key] = grown + cooldown = min(self.max_interval, max(grown, retry_after or 0.0)) + self._next_start[key] = max(self._next_start.get(key, 0.0), now + cooldown) + return grown + + def reward(self, base_url: str) -> None: + """A success: count it, shrink the gap back toward ``min_interval``, and after a quiet + stretch (three windows without a rate limit) raise the learned cap by one — additive + increase, so a limit that was lifted is found again.""" + key = self._key(base_url) + now = time.monotonic() + self._count(key, "ok") + self._trim(self._ok.setdefault(key, deque()), now).append(now) + cap = self._cap.get(key) + if cap is not None and now - self._cap_since.get(key, now) > 3 * self.window: + if cap >= 120: + self._cap.pop(key, None) # far above any limit seen: stop capping + self._cap_since.pop(key, None) + else: + self._cap[key] = cap + 1 + self._cap_since[key] = now + current = self._interval.get(key) + if current is not None: + relaxed = max(self.min_interval, current * 0.85) + if relaxed <= self.min_interval: + self._interval.pop(key, None) + else: + self._interval[key] = relaxed + + +_PACER = _EndpointPacer(EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S, + EXPERT_PROVIDER_WINDOW_S) + + +def endpoint_usage() -> List[Dict[str, Any]]: + """The usage meter, for ``GET /v1/expert/usage``.""" + return _PACER.usage() + + +class _Transient(RuntimeError): + """A failure worth a short wait and another try — before any token has been produced, so + nothing is repeated to the reader. Past the retries it reaches the user as it is.""" + + def __init__(self, message: str, retry_after: Optional[float] = None) -> None: + super().__init__(message) + self.retry_after = retry_after + + +class _RateLimited(_Transient): + """HTTP 429, or a body that says "rate limit" (Open WebUI says it with a 400).""" + + +class _Overloaded(_Transient): + """HTTP 502/503/504: a gateway shedding load (503 + Retry-After is backpressure, not an + error) or a replica mid-restart. One or two tries later it is usually gone.""" + + +def _retry_after(headers: Any) -> Optional[float]: + try: + value = float((headers or {}).get("retry-after") or 0) + except (TypeError, ValueError): + return None + return value if 0 < value <= 30 else None + + +def _backoff_s(attempt: int, retry_after: Optional[float]) -> float: + """Exponential backoff with jitter: about 2 s, 4 s, 8 s, 16 s (capped at 20 s), each ±25 % + so callers that failed together do not retry together. A Retry-After header is a floor.""" + base = min(20.0, 2.0 * (2 ** attempt)) + if retry_after: + base = max(base, retry_after) + return base * random.uniform(0.75, 1.25) + + +def _wait_budget() -> float: + """How long a request may wait in line and retry before it gives up: a person waits less + than a background job.""" + return EXPERT_PROVIDER_BACKGROUND_WAIT_S if _PRIORITY.get() == BACKGROUND else EXPERT_PROVIDER_WAIT_S + + +def _gave_up(err: "_Transient", waited: float, model: str) -> RuntimeError: + """The error a reader sees once the wait budget is spent: what happened, and what to do.""" + name = (model or "the selected model").split("/")[-1] + if isinstance(err, _RateLimited): + text = (f"The model provider for {name} is busy right now (rate limited). HomePilot waited " + f"{waited:.0f} s in line; please try again in a minute.") + else: + text = (f"The model provider for {name} is overloaded or restarting. HomePilot waited " + f"{waited:.0f} s; please try again in a minute.") + out = type(err)(text, retry_after=err.retry_after) + out.__cause__ = err + return out + + +def _next_retry(err: "_Transient", attempt: int, started: float, base_url: str, model: str) -> float: + """The delay before the next try, or raise when the budget (time or tries) is spent.""" + delay = _backoff_s(attempt, err.retry_after) + waited = time.monotonic() - started + if attempt >= EXPERT_PROVIDER_RETRIES or waited + delay > _wait_budget(): + logger.warning("%s for %s: giving up after %.0fs and %d tries: %s", base_url, model, waited, attempt + 1, err) + raise _gave_up(err, waited, model) + logger.info("%s for %s: %s; retry %d in %.1fs (waited %.0fs of %.0fs)", base_url, model, + type(err).__name__.strip("_").lower(), attempt + 1, delay, waited, _wait_budget()) + return delay + + +def _wait_event(text: str, seconds: float, attempt: int = 0) -> Dict[str, Any]: + return {"wait": {"text": text, "retry_in": round(max(0.0, seconds), 1), "attempt": attempt}} + +# If the configured EXPERT_LOCAL_MODEL / EXPERT_LOCAL_FAST_MODEL isn't pulled, +# fall back to whatever Ollama has locally. Set to "false" to disable and get +# a hard error instead — useful in production where the deployed model must +# match exactly. +_LOCAL_AUTO_FALLBACK = os.getenv("EXPERT_LOCAL_AUTO_FALLBACK", "true").lower() != "false" + +# Where Ollama may live besides EXPERT_OLLAMA_URL: loopback, and the Windows / +# Docker host when this service runs inside WSL or a container. The first URL +# that answers /api/tags with models wins and is remembered for a short time +# (same rules as HomePilot's backend/app/model_resolver.py; this service is +# deliberately isolated, so the 40 lines are copied rather than imported). +_OLLAMA_CACHE: Dict[str, Any] = {"url": "", "ts": 0.0, "models": []} +_OLLAMA_CACHE_TTL_S = 30.0 + + +def _read_text(path: str) -> str: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + return fh.read() + except OSError: + return "" + + +def _host_gateway_ips() -> List[str]: + out: List[str] = [] + for line in _read_text("/proc/net/route").splitlines()[1:]: + parts = line.split() + if len(parts) >= 3 and parts[1] == "00000000": + try: + raw = int(parts[2], 16) + out.append(".".join(str((raw >> (8 * i)) & 0xFF) for i in range(4))) + except ValueError: + pass + for line in _read_text("/etc/resolv.conf").splitlines(): + p = line.split() + if len(p) >= 2 and p[0] == "nameserver" and p[1].count(".") == 3 and p[1] not in out: + out.append(p[1]) + return [ip for ip in out if not ip.startswith("127.")] + + +def _ollama_candidates() -> List[str]: + urls = [EXPERT_OLLAMA_URL, "http://127.0.0.1:11434", "http://localhost:11434"] + in_wsl = bool(os.environ.get("WSL_DISTRO_NAME")) or "microsoft" in _read_text("/proc/version").lower() + if in_wsl: + urls += [f"http://{ip}:11434" for ip in _host_gateway_ips()] + if os.path.exists("/.dockerenv"): + urls += ["http://host.docker.internal:11434", "http://172.17.0.1:11434"] + seen: List[str] = [] + for u in urls: + u = (u or "").rstrip("/") + if u and u not in seen: + seen.append(u) + return seen + + +def _ollama_base() -> str: + """Runtime URL the last successful probe found (EXPERT_OLLAMA_URL until then).""" + return _OLLAMA_CACHE["url"] or EXPERT_OLLAMA_URL + + +async def _ollama_pulled_models() -> List[str]: + """Model tags of the first reachable Ollama among ``_ollama_candidates()`` + that has models. Remembers which URL answered so chat_local/stream_local + talk to the same runtime. Empty when nothing answers.""" + import time as _time + now = _time.monotonic() + if _OLLAMA_CACHE["url"] and _OLLAMA_CACHE["models"] and now - _OLLAMA_CACHE["ts"] < _OLLAMA_CACHE_TTL_S: + return list(_OLLAMA_CACHE["models"]) + best_url, best_models = "", [] + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client: + for url in _ollama_candidates(): + try: + r = await client.get(f"{url}/api/tags") + r.raise_for_status() + data = r.json() + except Exception: + continue + models = [m.get("name") or m.get("model") for m in data.get("models", []) if isinstance(m, dict)] + if models: + best_url, best_models = url, models + break + if not best_url: + best_url = url + except Exception: + pass + if best_url: + _OLLAMA_CACHE.update(url=best_url, ts=now, models=best_models) + return best_models + + +async def _resolve_local_model(requested: str) -> str: + """Return ``requested`` if Ollama has it pulled, otherwise either fall back + to the first available model or raise a clear error describing how to pull + it. Behavior controlled by ``EXPERT_LOCAL_AUTO_FALLBACK``.""" + pulled = await _ollama_pulled_models() + if not pulled: + probed = ", ".join(_ollama_candidates()) + raise RuntimeError( + f"No Ollama with models answered (tried {probed}). Open Settings → Models in " + f"HomePilot to download one, or run `ollama pull {requested}`. If Ollama runs on " + f"Windows and this service runs in WSL, enable WSL mirrored networking." + ) + if requested in pulled: + return requested + if not _LOCAL_AUTO_FALLBACK: + raise RuntimeError( + f"Expert local model '{requested}' is not pulled. " + f"Available: {pulled}. Run `ollama pull {requested}` to fix." + ) + # Prefer an exact stem match (handles ':latest' vs ':Q4_K_M' variants). + stem = requested.split(":")[0] + for m in pulled: + if m and m.split(":")[0] == stem: + return m + return pulled[0] + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _openai_wrap( + content: str, model: str, provider: str, tool_calls: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """Wrap a plain text response into OpenAI-like schema for orchestrator consistency. + ``tool_calls`` (OpenAI shape, see :func:`_normalize_tool_calls`) ride on the message.""" + message: Dict[str, Any] = {"role": "assistant", "content": content} + if tool_calls: + message["tool_calls"] = tool_calls + return { + "choices": [{"message": message}], + "model": model, + "provider": provider, + } + + +def _extract_content(data: Dict[str, Any]) -> str: + """Extract text content from OpenAI-style response dict.""" + try: + return data["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError): + return "" + + +# ───────────────────────────────────────────────────────────────────────────── +# Tool calling — one wire shape inside Expert (OpenAI's), translated per provider +# ───────────────────────────────────────────────────────────────────────────── + +class ToolsUnsupported(RuntimeError): + """The model or endpoint rejected the ``tools`` field; the caller retries without tools.""" + + +def _rate_limited(status: int, body: str) -> bool: + text = (body or "").lower() + return status == 429 or "rate limit" in text or "rate_limit" in text or "too many requests" in text + + +def _tools_rejected(status: int, body: str) -> bool: + """Ollama answers 400 " does not support tools"; OllaBridge says ``tools_unsupported``; + other gateways say similar things. Never a rate limit, whatever its status.""" + text = (body or "").lower() + if _rate_limited(status, text): + return False + if "tools_unsupported" in text: + return True + return status in (400, 422) and ("tool" in text or "function" in text) + + +def _legacy_tools_failure(status: int, body: str) -> bool: + """OllaBridge before October 2026 sent a tool-bearing request for a provider model to local + Ollama: HTTP 500 wrapping "404 Not Found … /api/chat". Read as "no tools here".""" + text = (body or "").lower() + return status >= 500 and "/api/chat" in text and "404" in text + + +def _wrapped_local_refusal(status: int, body: str) -> bool: + """An OllaBridge 500 wrapping local Ollama's 400 ("Client error '400 Bad Request' for url … + /api/chat"): on a tool-bearing request, Ollama's "this model does not support tools".""" + text = (body or "").lower() + return status >= 500 and "/api/chat" in text and "400" in text + + +def _message_in(data: Any) -> str: + """The human message in an error body, however it is nested: OpenAI's ``error.message``, + FastAPI's ``detail`` (a string, or OllaBridge's ``detail.error.message``), or ``message``.""" + if isinstance(data, str): + return data.strip() + if not isinstance(data, dict): + return "" + for key in ("error", "detail", "message"): + found = _message_in(data.get(key)) + if found: + return found + return "" + + +def _detail_text(body: str) -> str: + """The message inside an error body (see :func:`_message_in`), else the body.""" + try: + data = json.loads(body or "") + except (json.JSONDecodeError, TypeError): + return (body or "").strip() + return _message_in(data) or (body or "").strip() + + +def _openai_compat_error(status: int, detail: str, headers: Any, *, base_url: str, model: str, tools: bool) -> Exception: + """The exception for an error answer from ``{base_url}/chat/completions``: a rate limit to + wait out, a refusal of the tools field to remember, or the endpoint's own explanation.""" + who = f"{model or 'The selected model'} at {base_url}" + text = (detail or "").strip() + if _rate_limited(status, text): + return _RateLimited( + f"{who} is being rate limited by its provider (HTTP {status}" + + (f": {text[:160]}" if text else "") + "). Try again in a moment.", + retry_after=_retry_after(headers), + ) + if tools and (_tools_rejected(status, text) or _legacy_tools_failure(status, text) + or _wrapped_local_refusal(status, text)): + return ToolsUnsupported( + f"{who} rejected the tools field (HTTP {status}" + (f": {text[:160]}" if text else "") + ")" + ) + if _legacy_tools_failure(status, text): + # An older OllaBridge reports a provider failure (most often its rate limit) as a 500 + # from local Ollama; for a request without tools it is worth waiting for, not fatal. + return _RateLimited( + f"{who} could not reach its provider (HTTP {status}; an older OllaBridge reports a " + "provider's rate limit this way). Try again in a moment.", + retry_after=_retry_after(headers), + ) + if status in (502, 503, 504): + return _Overloaded( + f"{who} is overloaded or restarting (HTTP {status}" + + (f": {text[:160]}" if text else "") + "). Try again in a moment.", + retry_after=_retry_after(headers), + ) + return RuntimeError(f"{who} answered HTTP {status}" + (f": {text[:240]}" if text else "")) + + +def _normalize_tool_calls(raw: Any) -> List[Dict[str, Any]]: + """Tool calls as OpenAI emits them: ``{id, type, function: {name, arguments: }}``. + Ollama sends ``arguments`` as an object and no id; both are normalized here.""" + out: List[Dict[str, Any]] = [] + for i, call in enumerate(raw or []): + if not isinstance(call, dict): + continue + fn = call.get("function") or {} + args = fn.get("arguments") + if not isinstance(args, str): + args = json.dumps(args or {}, ensure_ascii=False) + out.append({ + "id": str(call.get("id") or f"call_{i + 1}"), + "type": "function", + "function": {"name": str(fn.get("name") or ""), "arguments": args or "{}"}, + }) + return out + + +def _openai_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Messages for an OpenAI-compatible endpoint: role/content, plus the tool-call fields.""" + out: List[Dict[str, Any]] = [] + for m in messages: + role = m.get("role") + if role == "tool": + out.append({"role": "tool", "tool_call_id": str(m.get("tool_call_id") or ""), "content": str(m.get("content") or "")}) + elif role == "assistant" and m.get("tool_calls"): + out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": _normalize_tool_calls(m["tool_calls"])}) + else: + out.append({"role": role, "content": m.get("content") or ""}) + return out + + +def _ollama_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Messages for Ollama's /api/chat: tool results carry ``tool_name`` and arguments are objects.""" + out: List[Dict[str, Any]] = [] + for m in messages: + role = m.get("role") + if role == "tool": + out.append({"role": "tool", "content": str(m.get("content") or ""), "tool_name": str(m.get("name") or "")}) + elif role == "assistant" and m.get("tool_calls"): + calls = [] + for c in _normalize_tool_calls(m["tool_calls"]): + try: + args = json.loads(c["function"]["arguments"] or "{}") + except json.JSONDecodeError: + args = {"input": c["function"]["arguments"]} + calls.append({"function": {"name": c["function"]["name"], "arguments": args if isinstance(args, dict) else {"input": args}}}) + out.append({"role": "assistant", "content": m.get("content") or "", "tool_calls": calls}) + else: + out.append({"role": role, "content": m.get("content") or ""}) + return out + + +def _params_b(text: Any) -> Optional[float]: + """'8.2B' → 8.2, '70B' → 70, '355M' → 0.355; None when the text says nothing.""" + s = str(text or "").strip().upper() + if not s: + return None + try: + if s.endswith("B"): + return float(s[:-1]) + if s.endswith("M"): + return float(s[:-1]) / 1000.0 + return float(s) + except ValueError: + return None + + +async def ollama_models_detail(base_url: Optional[str] = None) -> List[Dict[str, Any]]: + """``[{name, params_b, family, size_gb}]`` from ``/api/tags`` of ``base_url`` or the default + Ollama. Empty when nothing answers.""" + urls = [base_url.rstrip("/")] if base_url else _ollama_candidates() + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client: + for url in urls: + try: + r = await client.get(f"{url}/api/tags") + r.raise_for_status() + data = r.json() + except Exception: + continue + out = [] + for m in data.get("models", []) or []: + if not isinstance(m, dict): + continue + details = m.get("details") or {} + out.append({ + "name": m.get("name") or m.get("model") or "", + "params_b": _params_b(details.get("parameter_size")), + "family": str(details.get("family") or ""), + "size_gb": round(float(m.get("size") or 0) / 1e9, 1), + }) + if out: + return out + except Exception: + pass + return [] + + +async def openai_compat_models(base_url: str, api_key: str = "") -> List[str]: + """Model ids an OpenAI-compatible endpoint lists at ``GET {base_url}/models``.""" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client: + r = await client.get(f"{base_url.rstrip('/')}/models", headers=headers) + r.raise_for_status() + data = r.json() + except Exception: + return [] + items = data.get("data") if isinstance(data, dict) else data + names: List[str] = [] + for item in items or []: + name = item.get("id") or item.get("name") if isinstance(item, dict) else item + if isinstance(name, str) and name: + names.append(name) + return names + + +# ───────────────────────────────────────────────────────────────────────────── +# xAI Grok (OpenAI-compatible — reuses same wire format) +# ───────────────────────────────────────────────────────────────────────────── + +async def chat_grok( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> Dict[str, Any]: + """xAI Grok via OpenAI-compatible /v1/chat/completions.""" + if not GROK_API_KEY: + raise RuntimeError("GROK_API_KEY not set — cannot use Grok provider.") + mdl = model or GROK_MODEL + url = f"{GROK_BASE_URL}/chat/completions" + payload = { + "model": mdl, + "messages": messages, + "temperature": float(temperature), + "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post( + url, json=payload, + headers={"Authorization": f"Bearer {GROK_API_KEY}"}, + ) + r.raise_for_status() + data = r.json() + data["provider"] = "grok" + return data + + +async def stream_grok( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> AsyncIterator[str]: + """Stream tokens from Grok, yielding SSE-ready delta strings.""" + if not GROK_API_KEY: + raise RuntimeError("GROK_API_KEY not set.") + mdl = model or GROK_MODEL + url = f"{GROK_BASE_URL}/chat/completions" + payload = { + "model": mdl, "messages": messages, "stream": True, + "temperature": float(temperature), "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client: + async with client.stream( + "POST", url, json=payload, + headers={"Authorization": f"Bearer {GROK_API_KEY}"}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + chunk = line[6:] + if chunk.strip() == "[DONE]": + break + try: + delta = json.loads(chunk)["choices"][0]["delta"].get("content", "") + if delta: + yield delta + except (json.JSONDecodeError, KeyError, IndexError): + continue + + +# ───────────────────────────────────────────────────────────────────────────── +# Groq (ultra-fast, free tier, open models: Llama 3.3 70B, Mixtral, etc.) +# ───────────────────────────────────────────────────────────────────────────── + +async def chat_groq( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> Dict[str, Any]: + """Groq inference API — OpenAI-compatible, blazing fast.""" + if not GROQ_API_KEY: + raise RuntimeError("GROQ_API_KEY not set — cannot use Groq provider.") + mdl = model or GROQ_MODEL + url = f"{GROQ_BASE_URL}/chat/completions" + payload = { + "model": mdl, + "messages": messages, + "temperature": float(temperature), + "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post( + url, json=payload, + headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, + ) + r.raise_for_status() + data = r.json() + data["provider"] = "groq" + return data + + +async def stream_groq( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> AsyncIterator[str]: + """Stream tokens from Groq.""" + if not GROQ_API_KEY: + raise RuntimeError("GROQ_API_KEY not set.") + mdl = model or GROQ_MODEL + url = f"{GROQ_BASE_URL}/chat/completions" + payload = { + "model": mdl, "messages": messages, "stream": True, + "temperature": float(temperature), "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client: + async with client.stream( + "POST", url, json=payload, + headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + chunk = line[6:] + if chunk.strip() == "[DONE]": + break + try: + delta = json.loads(chunk)["choices"][0]["delta"].get("content", "") + if delta: + yield delta + except (json.JSONDecodeError, KeyError, IndexError): + continue + + +# ───────────────────────────────────────────────────────────────────────────── +# Google Gemini (via OpenAI-compatible endpoint — available since Gemini 1.5) +# ───────────────────────────────────────────────────────────────────────────── + +async def chat_gemini( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> Dict[str, Any]: + """Google Gemini via its OpenAI-compatible REST endpoint.""" + if not GEMINI_API_KEY: + raise RuntimeError("GEMINI_API_KEY not set — cannot use Gemini provider.") + mdl = model or GEMINI_MODEL + url = f"{GEMINI_BASE_URL}/chat/completions" + payload = { + "model": mdl, + "messages": messages, + "temperature": float(temperature), + "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post( + url, json=payload, + headers={"Authorization": f"Bearer {GEMINI_API_KEY}"}, + ) + r.raise_for_status() + data = r.json() + data["provider"] = "gemini" + return data + + +async def stream_gemini( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> AsyncIterator[str]: + """Stream tokens from Gemini.""" + if not GEMINI_API_KEY: + raise RuntimeError("GEMINI_API_KEY not set.") + mdl = model or GEMINI_MODEL + url = f"{GEMINI_BASE_URL}/chat/completions" + payload = { + "model": mdl, "messages": messages, "stream": True, + "temperature": float(temperature), "max_tokens": int(max_tokens), + } + async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client: + async with client.stream( + "POST", url, json=payload, + headers={"Authorization": f"Bearer {GEMINI_API_KEY}"}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + chunk = line[6:] + if chunk.strip() == "[DONE]": + break + try: + delta = json.loads(chunk)["choices"][0]["delta"].get("content", "") + if delta: + yield delta + except (json.JSONDecodeError, KeyError, IndexError): + continue + + +# ───────────────────────────────────────────────────────────────────────────── +# OpenAI-compatible endpoint the user chose for chat (OllaBridge gateway, Cloud relay, vLLM…) +# ───────────────────────────────────────────────────────────────────────────── + +async def chat_openai_compat( + messages: List[Dict[str, Any]], + *, + base_url: str, + model: str = "", + api_key: str = "", + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + provider_label: str = "openai_compat", + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """``POST {base_url}/chat/completions`` — the same request HomePilot's ordinary chat sends + (``app.llm.chat_openai_compat``), kept here because Expert is loaded as its own top-level + package and cannot import the backend's modules relatively. + + Failures carry the endpoint's own explanation: a gateway that refuses a model says why in + its body, and "HTTP 500" alone sends the user looking in the wrong place. + """ + url = f"{base_url.rstrip('/')}/chat/completions" + payload: Dict[str, Any] = { + "messages": _openai_messages(messages), + "temperature": float(temperature), + "max_tokens": int(max_tokens), + "stream": False, + } + if model: + payload["model"] = model + if tools: + payload["tools"] = tools + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + attempt = 0 + started = time.monotonic() + while True: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client, _PACER.slot(base_url): + r = await client.post(url, json=payload, headers=headers) + except httpx.HTTPError as exc: + raise RuntimeError( + f"Could not reach {base_url} for {model or 'the selected model'} " + f"({type(exc).__name__}). Is it running?" + ) from exc + if r.status_code < 400: + _PACER.reward(base_url) + break + err = _openai_compat_error( + r.status_code, _error_detail(r), r.headers, base_url=base_url, model=model, tools=bool(tools), + ) + if not isinstance(err, _Transient): + raise err + if isinstance(err, _RateLimited): + _PACER.penalize(base_url, err.retry_after) + await asyncio.sleep(_next_retry(err, attempt, started, base_url, model)) + attempt += 1 + data = r.json() + try: + raw_calls = data["choices"][0]["message"].get("tool_calls") + except (KeyError, IndexError, AttributeError): + raw_calls = None + return _openai_wrap( + _extract_content(data), str(data.get("model") or model), provider_label, + tool_calls=_normalize_tool_calls(raw_calls), + ) + + +def _error_detail(r: httpx.Response) -> str: + try: + return _message_in(r.json()) or (r.text or "").strip() + except Exception: # noqa: BLE001 + return (r.text or "").strip() + + +async def stream_openai_compat_events( + messages: List[Dict[str, Any]], + *, + base_url: str, + model: str = "", + api_key: str = "", + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + tools: Optional[List[Dict[str, Any]]] = None, +) -> AsyncIterator[Dict[str, Any]]: + """Stream ``{base_url}/chat/completions`` (:func:`_stream_openai_compat_once`) through the + endpoint's queue, retried with backoff while the provider rate-limits or sheds load — only + before the first token, so nothing is ever repeated to the reader. While it waits it says + so: ``{"wait": {"text", "retry_in", "attempt"}}`` events, which the chat shows in place of + a silent pause.""" + attempt = 0 + started = time.monotonic() + ahead = _PACER.expected_wait(base_url) + if ahead >= 2.0: + yield _wait_event("Waiting in line for the model provider…", ahead) + while True: + try: + async for event in _stream_openai_compat_once( + messages, base_url=base_url, model=model, api_key=api_key, + temperature=temperature, max_tokens=max_tokens, tools=tools, + ): + yield event + _PACER.reward(base_url) + return + except _Transient as exc: + if isinstance(exc, _RateLimited): + _PACER.penalize(base_url, exc.retry_after) + delay = _next_retry(exc, attempt, started, base_url, model) + busy = "busy" if isinstance(exc, _RateLimited) else "overloaded" + yield _wait_event(f"The model provider is {busy}; retrying in {delay:.0f} s…", delay, attempt + 1) + await asyncio.sleep(delay) + attempt += 1 + + +async def _stream_openai_compat_once( + messages: List[Dict[str, Any]], + *, + base_url: str, + model: str = "", + api_key: str = "", + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + tools: Optional[List[Dict[str, Any]]] = None, +) -> AsyncIterator[Dict[str, Any]]: + """Stream ``{base_url}/chat/completions``: ``{"delta": text}`` as tokens arrive and, once + the stream ends, ``{"tool_calls": [...]}`` when the model called tools (their argument + fragments are assembled here, by index, the way OpenAI streams them).""" + url = f"{base_url.rstrip('/')}/chat/completions" + payload: Dict[str, Any] = { + "messages": _openai_messages(messages), + "temperature": float(temperature), + "max_tokens": int(max_tokens), + "stream": True, + } + if model: + payload["model"] = model + if tools: + payload["tools"] = tools + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + pending: Dict[int, Dict[str, str]] = {} + try: + async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client, _PACER.slot(base_url): + async with client.stream("POST", url, json=payload, headers=headers) as resp: + if resp.status_code >= 400: + body = (await resp.aread()).decode("utf-8", "replace") + raise _openai_compat_error( + resp.status_code, _detail_text(body), resp.headers, + base_url=base_url, model=model, tools=bool(tools), + ) + if "text/event-stream" not in (resp.headers.get("content-type") or "").lower(): + # A gateway that ignores stream=true answers with one JSON completion (OllaBridge + # does): hand it over as a single delta rather than reading it as empty SSE. + raw_body = (await resp.aread()).decode("utf-8", "replace") + try: + data = json.loads(raw_body or "{}") + except json.JSONDecodeError: + data = {} + content = _extract_content(data) if isinstance(data, dict) else "" + if content: + yield {"delta": content} + try: + raw_calls = data["choices"][0]["message"].get("tool_calls") + except (KeyError, IndexError, AttributeError, TypeError): + raw_calls = None + calls = _normalize_tool_calls(raw_calls) + if calls: + yield {"tool_calls": calls} + return + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + chunk = line[5:].strip() + if chunk == "[DONE]": + break + try: + obj = json.loads(chunk) + except json.JSONDecodeError: + continue + choices = obj.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta") or {} + if delta.get("content"): + yield {"delta": delta["content"]} + for tc in delta.get("tool_calls") or []: + idx = int(tc.get("index", len(pending)) or 0) + slot = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""}) + if tc.get("id"): + slot["id"] = str(tc["id"]) + fn = tc.get("function") or {} + if fn.get("name") and not slot["name"]: + slot["name"] = str(fn["name"]) + if fn.get("arguments"): + slot["arguments"] += str(fn["arguments"]) + except httpx.HTTPError as exc: + raise RuntimeError( + f"Could not reach {base_url} for {model or 'the selected model'} ({type(exc).__name__}). Is it running?" + ) from exc + if pending: + yield {"tool_calls": _normalize_tool_calls([ + {"id": s["id"], "function": {"name": s["name"], "arguments": s["arguments"] or "{}"}} + for _, s in sorted(pending.items()) + ])} + + +# ───────────────────────────────────────────────────────────────────────────── +# Local Ollama (free, on-premises, no API key needed) +# ───────────────────────────────────────────────────────────────────────────── + +def _ollama_failure(r: httpx.Response, mdl: str) -> RuntimeError: + """Ollama's own reason for a 5xx, which is usually the useful part ("model requires more + system memory", "llama runner process has terminated") and was hidden behind a bare + ``500 Internal Server Error``.""" + reason = "" + try: + reason = str((r.json() or {}).get("error") or "") + except Exception: # noqa: BLE001 + reason = (r.text or "").strip() + reason = reason[:240] or r.reason_phrase or "server error" + return RuntimeError(f"Ollama could not run '{mdl}' (HTTP {r.status_code}): {reason}") + + +async def chat_local( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + fast: bool = False, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + base_url: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Ollama local inference — zero cost, fully sovereign. + + ``base_url`` is an Ollama the user chose for chat; its model is used as given, since the + installed-model check below is about the default Ollama, not that one. ``tools`` (OpenAI + shape) are offered to the model; a model without tool support raises ToolsUnsupported. + """ + requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL) + mdl = requested if (base_url and model) else await _resolve_local_model(requested) + url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat" + payload: Dict[str, Any] = { + "model": mdl, + "messages": _ollama_messages(messages), + "stream": False, + "options": { + "temperature": float(temperature), + "num_predict": int(max_tokens), + }, + } + if tools: + payload["tools"] = tools + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post(url, json=payload) + if r.status_code == 404: + raise RuntimeError( + f"Ollama rejected model '{mdl}' with 404. Run " + f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)." + ) + if r.status_code >= 500: + raise _ollama_failure(r, mdl) + if tools and _tools_rejected(r.status_code, r.text): + raise ToolsUnsupported(f"{mdl} does not support tool calling.") + r.raise_for_status() + data = r.json() + + message = data.get("message", {}) or {} + return _openai_wrap(message.get("content", ""), mdl, "local", tool_calls=_normalize_tool_calls(message.get("tool_calls"))) + + +async def stream_local_events( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + fast: bool = False, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + base_url: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None, +) -> AsyncIterator[Dict[str, Any]]: + """Stream Ollama: ``{"delta": text}`` per token and ``{"tool_calls": [...]}`` when the model + calls tools. ``base_url`` and ``tools``: as in :func:`chat_local`.""" + requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL) + mdl = requested if (base_url and model) else await _resolve_local_model(requested) + url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat" + payload: Dict[str, Any] = { + "model": mdl, + "messages": _ollama_messages(messages), + "stream": True, + "options": {"temperature": float(temperature), "num_predict": int(max_tokens)}, + } + if tools: + payload["tools"] = tools + calls: List[Dict[str, Any]] = [] + async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client: + async with client.stream("POST", url, json=payload) as resp: + if resp.status_code == 404: + raise RuntimeError( + f"Ollama rejected model '{mdl}' with 404. Run " + f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)." + ) + if resp.status_code == 400 and tools: + body = (await resp.aread()).decode("utf-8", "replace") + if _tools_rejected(400, body): + raise ToolsUnsupported(f"{mdl} does not support tool calling.") + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.strip(): + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + continue + message = chunk.get("message", {}) or {} + delta = message.get("content", "") + if delta: + yield {"delta": delta} + if message.get("tool_calls"): + calls.extend(_normalize_tool_calls(message["tool_calls"])) + if chunk.get("done"): + break + if calls: + yield {"tool_calls": calls} + + +async def stream_local( + messages: List[Dict[str, Any]], + *, + model: Optional[str] = None, + fast: bool = False, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + base_url: Optional[str] = None, +) -> AsyncIterator[str]: + """Stream tokens from Ollama. ``base_url``: as in :func:`chat_local`.""" + async for event in stream_local_events( + messages, model=model, fast=fast, temperature=temperature, max_tokens=max_tokens, base_url=base_url, + ): + if "delta" in event: + yield event["delta"] diff --git a/expert/backend/app/expert/readiness.py b/expert/backend/app/expert/readiness.py new file mode 100644 index 0000000000000000000000000000000000000000..04979248698ecc45af38f6cfbc1295052d04591f --- /dev/null +++ b/expert/backend/app/expert/readiness.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Dict, List + +from .mcp_catalog import mcp_key_to_env, p0_required_server_keys + + +@dataclass +class ReadinessCheck: + name: str + passed: bool + detail: str + + +@dataclass +class ReadinessReport: + ready: bool + stage: str + checks: List[ReadinessCheck] + + +def build_readiness_report(env: Dict[str, str] | None = None) -> ReadinessReport: + source = env or dict(os.environ) + preprod_mode = _truthy(source.get("EXPERT_PREPROD_MODE")) + + checks = [ + _check("tools_live", _truthy(source.get("EXPERT_TOOLS_ENABLED")), "Tool layer must be enabled."), + _check("memory_enabled", _truthy(source.get("EXPERT_MEMORY_ENABLED")), "Memory layer must be enabled."), + _check("evals_enabled", _truthy(source.get("EXPERT_EVALS_ENABLED")), "Eval recording must be enabled."), + _check("router_v2", _truthy(source.get("EXPERT_V2_ROUTER_ENABLED")), "V2 router must be enabled."), + _check( + "persistent_memory", + bool(source.get("EXPERT_MEMORY_BACKEND", "").strip()), + "Persistent memory backend must be configured.", + ), + _check( + "observability", + bool(source.get("EXPERT_TELEMETRY_ENDPOINT", "").strip()), + "Telemetry endpoint must be configured.", + ), + _check( + "safety_policy", + bool(source.get("EXPERT_SAFETY_POLICY", "").strip()), + "Safety policy profile must be configured.", + ), + ] + checks.extend(_mcp_preprod_checks(source, enabled=preprod_mode)) + + passed = sum(1 for c in checks if c.passed) + ready = passed == len(checks) + if ready: + stage = "production" + elif passed >= 4: + stage = "preprod" + else: + stage = "dev" + + return ReadinessReport(ready=ready, stage=stage, checks=checks) + + +def _check(name: str, passed: bool, detail: str) -> ReadinessCheck: + return ReadinessCheck(name=name, passed=passed, detail=detail) + + +def _truthy(v: str | None) -> bool: + return (v or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _mcp_preprod_checks(source: Dict[str, str], enabled: bool) -> List[ReadinessCheck]: + checks: List[ReadinessCheck] = [] + if not enabled: + return checks + orchestrator = (source.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower() + checks.append( + _check( + "mcp_orchestrator", + orchestrator in {"context_forge", "direct"}, + "EXPERT_MCP_ORCHESTRATOR must be 'context_forge' or 'direct'.", + ) + ) + + if orchestrator == "context_forge": + checks.append( + _check( + "context_forge_url", + bool(source.get("CONTEXT_FORGE_URL", "").strip()), + "Set CONTEXT_FORGE_URL for MCP Context Forge orchestration.", + ) + ) + return checks + + for key in p0_required_server_keys(): + env_key = mcp_key_to_env(key) + checks.append( + _check( + f"mcp:{key}", + bool(source.get(env_key, "").strip()), + f"Set {env_key} for preprod MCP P0 integration.", + ) + ) + return checks diff --git a/expert/backend/app/expert/router.py b/expert/backend/app/expert/router.py new file mode 100644 index 0000000000000000000000000000000000000000..bc0e966c76bee5ced41ef6813ddca687acd88365 --- /dev/null +++ b/expert/backend/app/expert/router.py @@ -0,0 +1,521 @@ +# expert/router.py +# Smart multi-provider routing: score query complexity → pick optimal provider. +# Fallback chain ensures resilience: if preferred provider fails, try next tier. +from __future__ import annotations + +import asyncio +import logging +from typing import Any, AsyncIterator, Dict, List, Literal, Optional + +from .config import ( + GROK_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, + EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD, + EXPERT_SYSTEM_PROMPT, EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_PROVIDER_TIMEOUT_S, + EXPERT_HISTORY_MAX_TURNS, EXPERT_HISTORY_MAX_CHARS, + available_expert_providers, +) +from .providers import ( + chat_grok, stream_grok, + chat_groq, stream_groq, + chat_gemini, stream_gemini, + chat_local, stream_local, stream_local_events, + chat_openai_compat, stream_openai_compat_events, + ToolsUnsupported, +) +from . import compaction +from . import grounding +from . import target as target_mod + +logger = logging.getLogger("expert.router") + +ProviderName = Literal["local", "groq", "grok", "gemini", "claude", "openai", "auto"] + + +def _attach_dispatch_meta( + raw: Dict[str, Any], + *, + requested_provider: ProviderName, + fallback_applied: bool, + notice: Optional[str] = None, +) -> Dict[str, Any]: + provider_used = raw.get("provider", requested_provider) + raw["__expert_meta"] = { + "requested_provider": requested_provider, + "provider_used": provider_used, + "fallback_applied": fallback_applied, + "notice": notice, + } + return raw + + +def extract_dispatch_meta(raw: Dict[str, Any]) -> Dict[str, Any]: + meta = raw.get("__expert_meta") + if isinstance(meta, dict): + return meta + provider = raw.get("provider", "local") + return { + "requested_provider": provider, + "provider_used": provider, + "fallback_applied": False, + "notice": None, + } + + +# ───────────────────────────────────────────────────────────────────────────── +# Complexity scorer +# ───────────────────────────────────────────────────────────────────────────── + +_COMPLEX_KEYWORDS = { + "analyze", "analyse", "compare", "reason", "explain", "prove", + "research", "summarize", "summarise", "evaluate", "critique", + "code", "debug", "implement", "design", "architecture", + "math", "calculate", "derive", "equation", + "write", "draft", "essay", "report", +} + +_SIMPLE_KEYWORDS = { + "what is", "who is", "when", "where", "define", "list", + "how many", "translate", "hi", "hello", "thanks", +} + + +def score_complexity(query: str) -> int: + """ + Score query complexity 0–10. + 0–3 → local fast model (simple factual, greetings) + 4–6 → Groq 70B (moderate reasoning, explanations) + 7–10 → Grok / Gemini (deep reasoning, long-form, code) + """ + score = 0 + q = query.lower().strip() + + # Length signals + words = len(q.split()) + if words > 100: + score += 3 + elif words > 40: + score += 2 + elif words > 15: + score += 1 + + # Complexity keywords + if any(kw in q for kw in _COMPLEX_KEYWORDS): + score += 3 + + # Simple keywords (penalise complexity) + if any(kw in q for kw in _SIMPLE_KEYWORDS): + score = max(0, score - 2) + + # Multi-sentence questions suggest depth + sentences = [s.strip() for s in q.split(".") if s.strip()] + if len(sentences) > 3: + score += 1 + + # Code blocks or backticks + if "```" in query or "`" in query: + score += 2 + + # Multiple questions + if query.count("?") > 1: + score += 1 + + return min(score, 10) + + +# ───────────────────────────────────────────────────────────────────────────── +# Provider selection +# ───────────────────────────────────────────────────────────────────────────── + +def select_provider(query: str, preferred: ProviderName = "auto") -> ProviderName: + """ + Select best available provider for this query. + + If preferred == 'auto', use complexity-based routing. + Otherwise, use the requested provider if available, else fallback to auto. + """ + available = available_expert_providers() + + if preferred != "auto": + if preferred in available: + return preferred + logger.warning("Provider '%s' not available, falling back to auto routing.", preferred) + + complexity = score_complexity(query) + logger.debug("Query complexity score: %d", complexity) + + if complexity <= EXPERT_LOCAL_THRESHOLD: + return "local" + + if complexity <= EXPERT_GROQ_THRESHOLD: + if "groq" in available: + return "groq" + if "local" in available: + return "local" + + # High complexity — best available cloud + for p in ("grok", "gemini", "claude", "openai", "groq", "local"): + if p in available: + return p + + return "local" # ultimate fallback + + +# ───────────────────────────────────────────────────────────────────────────── +# Conversation history +# ───────────────────────────────────────────────────────────────────────────── + +# Prefixed to a pipeline step's instruction when earlier turns precede it, so the step reads +# the user's latest question against them rather than as a question from nowhere. +CONTINUATION_NOTE = ( + "The task below continues the conversation above. Resolve references in it (\"it\", " + "\"the second option\", \"as before\"), keep the constraints already agreed, and answer " + "in the conversation's language.\n\n" +) + + +def clean_turns(history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, str]]: + """User and assistant turns with text, in order; everything else dropped.""" + return [ + {"role": str(m.get("role")), "content": str(m.get("content"))} + for m in (history or []) + if isinstance(m, dict) + and m.get("role") in ("user", "assistant") + and str(m.get("content") or "").strip() + ] + + +def history_window( + history: Optional[List[Dict[str, Any]]], + *, + max_turns: Optional[int] = None, + max_chars: Optional[int] = None, +) -> List[Dict[str, str]]: + """The recent conversation as clean chat turns: user and assistant messages with text, + the newest that fit the turn and character budgets, opening on a user turn (providers + that enforce role alternation accept the window as it is). The turns before the window + are not lost: compaction.py carries them as a note.""" + max_turns = EXPERT_HISTORY_MAX_TURNS if max_turns is None else max_turns + max_chars = EXPERT_HISTORY_MAX_CHARS if max_chars is None else max_chars + clean = clean_turns(history) + kept: List[Dict[str, str]] = [] + used = 0 + for m in reversed(clean): + if len(kept) >= max_turns or used + len(m["content"]) > max_chars: + break + kept.append(m) + used += len(m["content"]) + kept.reverse() + while kept and kept[0]["role"] != "user": + kept.pop(0) + return kept + + +def build_messages( + user_query: str, + history: Optional[List[Dict[str, str]]] = None, + system_prompt: Optional[str] = None, +) -> List[Dict[str, str]]: + """Assemble the messages list: system prompt, the recent conversation as role-tagged + turns, then the new query as the newest user turn — the shape chat models are trained on.""" + messages: List[Dict[str, str]] = [{ + "role": "system", + "content": compaction.apply(grounding.apply(system_prompt or EXPERT_SYSTEM_PROMPT)), + }] + messages.extend(history_window(history)) + messages.append({"role": "user", "content": user_query}) + return messages + + +def step_messages( + instruction: str, + *, + system_prompt: str, + history: Optional[List[Dict[str, str]]] = None, +) -> List[Dict[str, str]]: + """Messages for one call of a multi-step pipeline (think, heavy). Every step sees the + conversation the question came from — so a follow-up ("and for the second one?") is + understood in the analysis and the final answer matches the thread — while the step's + own instruction stays the newest user turn, told that it continues that conversation.""" + turns = history_window(history) + if turns: + instruction = CONTINUATION_NOTE + instruction + return build_messages(instruction, history=turns, system_prompt=system_prompt) + + +# ───────────────────────────────────────────────────────────────────────────── +# Dispatch — sync +# ───────────────────────────────────────────────────────────────────────────── + +async def dispatch( + messages: List[Dict[str, str]], + provider: ProviderName, + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """ + Call the selected provider and return an OpenAI-compatible response dict. + Includes one fallback level: if provider fails, try local Ollama. + ``tools`` (OpenAI shape) reach the local/target path — Ollama and OpenAI-compatible + endpoints; the response message then carries ``tool_calls`` when the model used them. + """ + try: + raw = await asyncio.wait_for( + _call_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools), + timeout=EXPERT_PROVIDER_TIMEOUT_S, + ) + return _attach_dispatch_meta(raw, requested_provider=provider, fallback_applied=False) + except ToolsUnsupported: + raise + except Exception as e: + logger.warning("Provider '%s' failed (%s), falling back to local.", provider, e) + if provider != "local": + # "local" is the user's chosen chat model when there is one (see target.py) — + # called directly, so the fallback never re-enters the path that just failed. + raw = await asyncio.wait_for( + _call_local(messages, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools), + timeout=EXPERT_PROVIDER_TIMEOUT_S, + ) + return _attach_dispatch_meta( + raw, + requested_provider=provider, + fallback_applied=True, + notice="Requested provider failed; used local fallback.", + ) + raise + + +async def _call_provider( + messages: List[Dict[str, str]], + provider: ProviderName, + *, + model: Optional[str], + temperature: float, + max_tokens: int, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens) + if provider == "grok": + return await chat_grok(messages, **kwargs) + if provider == "groq": + return await chat_groq(messages, **kwargs) + if provider == "gemini": + return await chat_gemini(messages, **kwargs) + if provider == "claude": + # Delegate to existing HomePilot llm.py to reuse ANTHROPIC_API_KEY + from ..llm import chat_claude + return await chat_claude(messages, **{k: v for k, v in kwargs.items() if v is not None}) + if provider == "openai": + from ..llm import chat_openai + return await chat_openai(messages, **{k: v for k, v in kwargs.items() if v is not None}) + return await _call_local(messages, tools=tools, **kwargs) + + +async def _call_local( + messages: List[Dict[str, str]], + *, + model: Optional[str], + temperature: float, + max_tokens: int, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """The chat model the user picked (target.py), else local Ollama.""" + kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens, tools=tools) + chosen = target_mod.current() + if chosen is not None: + return await _call_target(messages, chosen, **kwargs) + return await chat_local(messages, **kwargs) + + +async def _call_target( + messages: List[Dict[str, str]], + chosen: "target_mod.ChatTarget", + *, + model: Optional[str], + temperature: float, + max_tokens: int, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Answer from the user's chosen chat model: an Ollama model, or an OpenAI-compatible + endpoint such as an OllaBridge gateway (``http://localhost:11435/v1``) or the Cloud relay. + """ + if chosen.is_ollama: + return await chat_local( + messages, + model=chosen.model or model, + base_url=chosen.base_url or None, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + ) + return await chat_openai_compat( + messages, + base_url=chosen.base_url, + model=chosen.model or model or "", + api_key=chosen.api_key, + temperature=temperature, + max_tokens=max_tokens, + provider_label=chosen.label, + tools=tools, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Dispatch — streaming +# ───────────────────────────────────────────────────────────────────────────── + +async def dispatch_stream( + messages: List[Dict[str, str]], + provider: ProviderName, + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> AsyncIterator[str]: + """ + Stream tokens from selected provider. + Falls back to local if cloud provider fails on first chunk. + """ + try: + async for chunk in _stream_provider(messages, provider, model=model, + temperature=temperature, max_tokens=max_tokens): + yield chunk + except Exception as e: + logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e) + if provider != "local": + async for chunk in _stream_provider(messages, "local", model=model, + temperature=temperature, max_tokens=max_tokens): + yield chunk + else: + raise + + +async def _stream_provider( + messages: List[Dict[str, str]], + provider: ProviderName, + *, + model: Optional[str], + temperature: float, + max_tokens: int, +) -> AsyncIterator[str]: + kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens) + if provider == "grok": + async for c in stream_grok(messages, **kwargs): yield c + elif provider == "groq": + async for c in stream_groq(messages, **kwargs): yield c + elif provider == "gemini": + async for c in stream_gemini(messages, **kwargs): yield c + elif provider in ("claude", "openai"): + # Cloud providers without dedicated stream adapter: fall back to non-streaming + # and emit the full response as one chunk — transparent to the client + resp = await _call_provider(messages, provider, **kwargs) + content = resp.get("choices", [{}])[0].get("message", {}).get("content", "") + yield content + else: + chosen = target_mod.current() + if chosen is not None and chosen.is_ollama: + async for c in stream_local(messages, model=chosen.model or model, + base_url=chosen.base_url or None, + temperature=temperature, max_tokens=max_tokens): + yield c + elif chosen is not None: + # Real streaming from the OpenAI-compatible endpoint; a gateway that cannot stream + # answers in one chunk, the way Claude and OpenAI are streamed above. + yielded = False + try: + async for ev in stream_openai_compat_events( + messages, base_url=chosen.base_url, model=chosen.model or model or "", + api_key=chosen.api_key, temperature=temperature, max_tokens=max_tokens, + ): + if "delta" in ev: + yielded = True + yield ev["delta"] + except Exception: + if yielded: + raise + resp = await _call_target(messages, chosen, **kwargs) + yield resp.get("choices", [{}])[0].get("message", {}).get("content", "") + else: + async for c in stream_local(messages, **kwargs): yield c + + +# ───────────────────────────────────────────────────────────────────────────── +# Dispatch — streaming with structure (tokens and tool calls) +# ───────────────────────────────────────────────────────────────────────────── + +async def stream_provider_events( + messages: List[Dict[str, Any]], + provider: ProviderName, + *, + tools: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, +) -> AsyncIterator[Dict[str, Any]]: + """Stream ``{"delta": text}`` / ``{"tool_calls": [...]}`` events, with the same local + fallback as :func:`dispatch_stream` (announced as a ``{"notice": ..., "fallback": True}`` + event). ``tools`` reach the local/target path — Ollama and OpenAI-compatible endpoints; + the other providers stream text only. A ToolsUnsupported refusal is passed through so the + caller can retry without tools.""" + try: + async for ev in _stream_provider_events(messages, provider, tools=tools, model=model, + temperature=temperature, max_tokens=max_tokens): + yield ev + except ToolsUnsupported: + raise + except Exception as e: + if provider == "local": + logger.warning("Streaming provider 'local' failed: %s", e) + raise + logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e) + yield {"notice": "Requested provider failed; used local fallback.", "fallback": True} + async for ev in _stream_provider_events(messages, "local", tools=tools, model=model, + temperature=temperature, max_tokens=max_tokens): + yield ev + + +async def _stream_provider_events( + messages: List[Dict[str, Any]], + provider: ProviderName, + *, + tools: Optional[List[Dict[str, Any]]], + model: Optional[str], + temperature: float, + max_tokens: int, +) -> AsyncIterator[Dict[str, Any]]: + if provider in ("grok", "groq", "gemini", "claude", "openai"): + async for c in _stream_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens): + yield {"delta": c} + return + chosen = target_mod.current() + kwargs = dict(temperature=temperature, max_tokens=max_tokens, tools=tools) + if chosen is not None and not chosen.is_ollama: + # Real streaming from the OpenAI-compatible endpoint; when the stream fails before its + # first event, the plain request is tried once (a gateway that cannot stream still + # answers that). A refusal of the tools field passes through for the retry without. + yielded = False + try: + async for ev in stream_openai_compat_events( + messages, base_url=chosen.base_url, model=chosen.model or model or "", api_key=chosen.api_key, **kwargs, + ): + yielded = True + yield ev + except ToolsUnsupported: + raise + except Exception: + if yielded: + raise + raw = await _call_target(messages, chosen, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools) + message = ((raw.get("choices") or [{}])[0] or {}).get("message") or {} + if message.get("content"): + yield {"delta": message["content"]} + if message.get("tool_calls"): + yield {"tool_calls": message["tool_calls"]} + elif chosen is not None: + async for ev in stream_local_events(messages, model=chosen.model or model, base_url=chosen.base_url or None, **kwargs): + yield ev + else: + async for ev in stream_local_events(messages, model=model, **kwargs): + yield ev diff --git a/expert/backend/app/expert/routes.py b/expert/backend/app/expert/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..3aea80b3036066d3ba87c99a8fc65686b1999119 --- /dev/null +++ b/expert/backend/app/expert/routes.py @@ -0,0 +1,865 @@ +# expert/routes.py +# FastAPI router for the Expert module. +# Mount pattern identical to upscale.py / enhance.py — include in main.py with 2 lines. +# +# Endpoints: +# GET /v1/expert/info → provider status + routing config +# POST /v1/expert/chat → single-turn or multi-turn, returns full response +# POST /v1/expert/stream → streaming SSE response +# POST /v1/expert/route → debug: returns which provider would be selected +# +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from typing import Any, AsyncIterator, Dict, Optional + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse + +from .config import ( + EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD, + EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL, + GROQ_MODEL, GROK_MODEL, GEMINI_MODEL, + EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, + EXPERT_HEAVY_ESCALATE, EXPERT_DEEP_TIME_BUDGET_S, + available_expert_providers, +) +from .router import ( + build_messages, + dispatch, + dispatch_stream, + extract_dispatch_meta, + score_complexity, + select_provider, +) +from .tool_loop import run_with_tools, stream_with_tools +from .policies import effort_for, is_trivial, resolve_thinking_mode +from .project_context import PROJECT_TOOL, project_system_prompt, project_tool +from . import tools as tools_mod +from .thinking import think, stream_think +from .heavy import heavy, stream_heavy +from .schemas import ( + CapabilitiesRequest, + ExpertChatRequest, + ExpertChatResponse, + ExpertInfoResponse, + ExpertStreamRequest, + OllabridgeChatRequest, + PersonaDraftRequest, + PersonaDraftResponse, + ProductionReadinessResponse, + ReadinessCheckItem, +) +from . import capability +from . import compaction +from . import config as config_mod +from . import deep +from . import grounding +from . import target as target_mod +from .readiness import build_readiness_report +from .mcp_catalog import ESSENTIAL_MCP_SERVERS, TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env +from .mcp_gateway import MCPGateway + +logger = logging.getLogger("expert.routes") + +router = APIRouter(prefix="/v1/expert", tags=["expert"]) + + +def _compute_request_complexity(req: ExpertChatRequest) -> int: + score = score_complexity(req.query) + if req.has_attachments: + score += 2 + if len(req.history) > 8: + score += 1 + return min(score, 10) + + +# ───────────────────────────────────────────────────────────────────────────── +# GET /v1/expert/info +# ───────────────────────────────────────────────────────────────────────────── + +@router.get("/info", response_model=ExpertInfoResponse, summary="Expert module status") +async def expert_info() -> ExpertInfoResponse: + """Return which providers are configured and the current routing thresholds.""" + providers = available_expert_providers() + return ExpertInfoResponse( + available_providers=providers, + default_provider="auto", + local_threshold=EXPERT_LOCAL_THRESHOLD, + groq_threshold=EXPERT_GROQ_THRESHOLD, + local_model=EXPERT_LOCAL_MODEL, + local_fast_model=EXPERT_LOCAL_FAST_MODEL, + groq_model=GROQ_MODEL, + grok_model=GROK_MODEL, + gemini_model=GEMINI_MODEL, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# POST /v1/expert/chat (non-streaming, full response) +# ───────────────────────────────────────────────────────────────────────────── + +def _provider_for(req: ExpertChatRequest, chosen) -> str: + """The provider to start from. With the user's own chat model chosen (``target``), 'auto' + means that model — a complex question is not a reason to send it to a cloud provider the + user did not pick.""" + if chosen is not None and req.provider == "auto": + return "local" + return select_provider(req.query, preferred=req.provider) + + +def _model_label(chosen, requested: Optional[str]) -> Optional[str]: + """The model a response reports: the chosen target's, else the one the request named.""" + if chosen is not None and chosen.model: + return chosen.model + return requested or None + + +async def _escalate_for_heavy(chosen, req: ExpertChatRequest, mode: str, provider: str): + """Heavy runs on the strongest model of the user's own connection when that is a clear + step up (capability.py) — the same Ollama or gateway, never a provider they did not pick. + Returns ``(target to run with, escalated model name or None)``.""" + if mode != "heavy" or not EXPERT_HEAVY_ESCALATE or provider != "local": + return chosen, None + if isinstance(req.feature_hints, dict) and req.feature_hints.get("escalate") is False: + return chosen, None + try: + caps = await capability.assess(chosen, req.model) + except Exception as exc: # noqa: BLE001 + logger.debug("Heavy escalation skipped: %s", exc) + return chosen, None + best, selected = caps.get("best"), caps.get("selected") or {} + if not best or not capability.stronger(best, selected): + return chosen, None + if chosen is None: + return target_mod.ChatTarget(provider="ollama", base_url="", model=best["name"]), best["name"] + return ( + target_mod.ChatTarget(provider=chosen.provider, base_url=chosen.base_url, model=best["name"], api_key=chosen.api_key), + best["name"], + ) + + +def _project(req: ExpertChatRequest) -> Optional[Dict[str, Any]]: + return req.project.model_dump() if req.project is not None else None + + +def _request_tools(req: ExpertChatRequest) -> tools_mod.RequestTools: + """The shared tools plus this request's own: the project's documents, when there are any + and the backend's retrieval is reachable (project_context.py).""" + local = [t for t in (project_tool(_project(req)),) if t is not None] + return tools_mod.RequestTools(tools_mod.registry(), local) + + +def _forced_tools(req: ExpertChatRequest, registry: Optional[tools_mod.RequestTools] = None): + """What runs before the model is asked anything: the project's documents when the chat is + inside a project with documents, and a web search when the chat's "Search the web" switch + is on (feature_hints.webSearch = "force") — in every mode.""" + forced: list = [] + project = _project(req) + if project and int(project.get("document_count") or 0) > 0 and (registry is None or registry.spec(PROJECT_TOOL)): + forced.append((PROJECT_TOOL, {"query": req.query})) + hints = req.feature_hints if isinstance(req.feature_hints, dict) else {} + if str(hints.get("webSearch") or "").lower() in ("force", "on", "true", "1"): + forced.append(("hp_web_search", {"query": req.query})) + return forced or None + + +def _effort_notice(effort) -> Optional[str]: + return None + + +def _escalation_notice(escalated: str, chosen, req: ExpertChatRequest) -> str: + was = _model_label(chosen, req.model) or EXPERT_LOCAL_MODEL + return f"Heavy used {escalated}, the strongest model on this connection, instead of {was}." + + +@router.post("/chat", response_model=ExpertChatResponse, summary="Expert chat (sync)") +async def expert_chat(req: ExpertChatRequest) -> ExpertChatResponse: + """ + Send a message to the Expert and get a complete response. + + thinking_mode controls the pipeline: + 'fast' → single LLM call (default for simple queries) + 'think' → analyze → plan → solve (3 chained LLM calls) + 'heavy' → research → reason → synthesize → validate (4-agent pipeline) + 'auto' → complexity score decides which pipeline to use + + ``target`` (the user's chosen chat model) is in force for every step of the pipeline. + """ + chosen = target_mod.from_request(req.target) + # Today's date (and any web sources HomePilot found) reach every step's system prompt. + with target_mod.using(chosen), grounding.using(req.client_timezone, req.context): + return await _expert_chat(req, chosen) + + +async def _expert_chat(req: ExpertChatRequest, chosen) -> ExpertChatResponse: + complexity = _compute_request_complexity(req) + provider = _provider_for(req, chosen) + started = time.perf_counter() + include_raw = os.getenv("EXPERT_DEBUG", "").lower() in ("1", "true", "yes") + + resolution = resolve_thinking_mode(req.thinking_mode, complexity) + mode = resolution.mode_used + strategy = resolution.strategy + route_notices: list[str] = [] + if req.thinking_mode == "auto": + route_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.") + budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else "" + if budget_tier == "low" and mode == "heavy": + route_notices.append("Budget tier is low; downgraded heavy mode to think.") + mode = "think" + strategy = "expert-thinking" + + # The request's tools (the project's documents among them), what runs first, and the + # effort this message earns inside the mode (policies.effort_for). + registry = _request_tools(req) + forced = _forced_tools(req, registry) + effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced)) + temperature = req.temperature if req.temperature is not None else effort.temperature + max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens + system_prompt = project_system_prompt(req.system_prompt, _project(req)) + direct = effort.level == "direct" + if direct and mode != "fast": + strategy = "direct" + + # Compaction: the turns the window keeps go verbatim; the older ones ride as a note. + window, note, compaction_notice = await compaction.prepare( + [m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=req.model, + ) + if compaction_notice: + route_notices.append(compaction_notice) + run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider) + if escalated: + route_notices.append(_escalation_notice(escalated, chosen, req)) + + trace_id = (req.session_id or "expert")[:8] + history_chars = sum(len(m.content or "") for m in req.history) + print( + f"[EXPERT CHAT {trace_id}] start requested_mode={req.thinking_mode!r} " + f"resolved_mode={mode!r} strategy={strategy!r} provider={provider!r} " + f"model={req.model!r} target={chosen!r} complexity={complexity} history={len(req.history)} " + f"history_chars={history_chars} query_chars={len(req.query or '')} " + f"max_tokens={max_tokens} temperature={temperature}" + ) + + try: + # ── heavy pipeline ──────────────────────────────────────────────────── + if mode == "heavy" and not direct: + with target_mod.using(run_target), compaction.using(note): + result = await heavy( + req.query, provider, + history=window, system_prompt=system_prompt, forced_tools=forced, + research=effort.research, registry=registry, tool_rounds=effort.tool_rounds, + model=req.model, temperature=temperature, max_tokens=max_tokens, + ) + print( + f"[EXPERT CHAT {trace_id}] done mode=heavy provider={result.get('provider', provider)!r} " + f"elapsed_ms={int((time.perf_counter() - started) * 1000)} " + f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))} " + f"verdict={result.get('verdict')!r} escalated={escalated!r}" + ) + return ExpertChatResponse( + content=result["final_answer"], + provider_used=result.get("provider", provider), + model_used=_model_label(run_target, req.model), + complexity_score=complexity, + thinking_mode_used="heavy", + steps=result.get("agents"), + strategy_used=strategy, + fallback_applied=bool(result.get("fallback_applied", False)), + notices=[*route_notices, *list(result.get("notices", []))], + latency_ms=int((time.perf_counter() - started) * 1000), + tool_trace=list(result.get("tool_trace", [])), + sources=list(result.get("sources", [])), + verdict=result.get("verdict"), + verdict_detail=result.get("verdict_detail"), + model_escalated=escalated, + effort=effort.as_dict(), + ) + + # ── think pipeline ──────────────────────────────────────────────────── + if mode == "think" and not direct: + with compaction.using(note): + result = await think( + req.query, provider, + history=window, system_prompt=system_prompt, forced_tools=forced, + plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds, + model=req.model, temperature=temperature, max_tokens=max_tokens, + with_critique=req.with_critique, + ) + print( + f"[EXPERT CHAT {trace_id}] done mode=think provider={result.get('provider', provider)!r} " + f"elapsed_ms={int((time.perf_counter() - started) * 1000)} " + f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))}" + ) + return ExpertChatResponse( + content=result["final_answer"], + provider_used=result.get("provider", provider), + model_used=result.get("model") or _model_label(chosen, req.model), + complexity_score=complexity, + thinking_mode_used="think", + steps=result.get("steps"), + strategy_used=strategy, + fallback_applied=bool(result.get("fallback_applied", False)), + notices=[*route_notices, *list(result.get("notices", []))], + latency_ms=int((time.perf_counter() - started) * 1000), + tool_trace=list(result.get("tool_trace", [])), + sources=list(result.get("sources", [])), + effort=effort.as_dict(), + ) + + # ── one generation with the per-turn tool loop: Fast, and a trivial message in any mode ── + with compaction.using(note): + messages = build_messages(req.query, history=window, system_prompt=system_prompt) + result = await run_with_tools( + messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens, + call=dispatch, forced=forced, registry=registry, max_rounds=effort.tool_rounds, + ) + content = result.get("content") or "" + notices = [*route_notices, *list(result.get("notices", []))] + + print( + f"[EXPERT CHAT {trace_id}] done mode={mode} effort={effort.level} provider={provider!r} " + f"model={_model_label(chosen, req.model)!r} elapsed_ms={int((time.perf_counter() - started) * 1000)} " + f"answer_len={len(content or '')} tools={len(result.get('tool_trace', []))} " + f"fallback={bool(result.get('fallback_applied', False))}" + ) + return ExpertChatResponse( + content=content, + provider_used=(chosen.label if chosen is not None else provider), + model_used=_model_label(chosen, req.model), + complexity_score=complexity, + thinking_mode_used=mode, + strategy_used=strategy, + fallback_applied=bool(result.get("fallback_applied", False)), + notices=notices, + latency_ms=int((time.perf_counter() - started) * 1000), + tool_trace=list(result.get("tool_trace", [])), + sources=list(result.get("sources", [])), + effort=effort.as_dict(), + ) + + except Exception as exc: + print(f"[EXPERT CHAT {trace_id}] error mode={mode!r} provider={provider!r} error={type(exc).__name__}: {exc}") + logger.error("Expert chat failed (mode=%s): %s", mode, exc, exc_info=True) + raise HTTPException(status_code=502, detail=f"Expert pipeline error: {exc}") + + +# ───────────────────────────────────────────────────────────────────────────── +# POST /v1/expert/stream (Server-Sent Events streaming) +# ───────────────────────────────────────────────────────────────────────────── + +@router.post("/stream", summary="Expert chat (streaming SSE)") +async def expert_stream(req: ExpertStreamRequest) -> StreamingResponse: + """ + Stream Expert response via SSE. Routes through fast/think/heavy pipeline. + + Event types: + event: meta → {provider, complexity, thinking_mode} + event: step → {step, label, provider} (think/heavy only) + event: step_end → {step} + data: → plain text token + data: [DONE] → end of stream + event: error → {error: str} + """ + complexity = _compute_request_complexity(req) + chosen = target_mod.from_request(req.target) + provider = _provider_for(req, chosen) + + resolution = resolve_thinking_mode(req.thinking_mode, complexity) + mode = resolution.mode_used + stream_notices: list[str] = [] + if req.thinking_mode == "auto": + stream_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.") + budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else "" + if budget_tier == "low" and mode == "heavy": + stream_notices.append("Budget tier is low; downgraded heavy mode to think.") + mode = "think" + resolution = resolve_thinking_mode(mode, complexity) + + # The request's tools, what runs first, and the effort this message earns (as in _expert_chat). + registry = _request_tools(req) + forced = _forced_tools(req, registry) + effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced)) + temperature = req.temperature if req.temperature is not None else effort.temperature + max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens + system_prompt = project_system_prompt(req.system_prompt, _project(req)) + direct = effort.level == "direct" + strategy = "direct" if direct and mode != "fast" else resolution.strategy + + # Compaction runs inside the stream (below): the reader sees "Compacting the conversation…" + # while the summary is written, and the summary is written by the chat's own model (the + # target context is set around the stream, not around this function). + history_dump = [m.model_dump() for m in req.history] + compacting_turns = compaction.needs_summary(history_dump, req.session_id) + run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider) + if escalated: + stream_notices.append(_escalation_notice(escalated, chosen, req)) + started = time.perf_counter() + project = _project(req) + + def sse(name: str, payload: Dict[str, Any]) -> str: + return f"event: {name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + + def tok(text: str) -> str: + return f"data: {text.replace(chr(10), chr(92) + 'n')}\n\n" + + async def event_generator(): + summary: Dict[str, Any] = {"tool_trace": [], "sources": [], "verdict": None, "notices": list(stream_notices)} + final_answer = "" + + def relay(event: Dict[str, Any]) -> str: + """A pipeline event → its SSE frame ('' for events the client has no use for).""" + kind = event["type"] + if kind == "step_start": + return sse("step", { + "step": event["step"], "label": event.get("label", ""), + "provider": event.get("provider", provider), "role": event.get("role", "work"), + }) + if kind == "step_end": + return sse("step_end", {"step": event["step"]}) + if kind == "token": + return tok(event["content"]) + if kind in ("tool_start", "tool_end"): + payload = {k: v for k, v in event.items() if k != "type"} + payload["phase"] = "start" if kind == "tool_start" else "end" + if kind == "tool_end": + summary["tool_trace"].append( + {k: event.get(k) for k in ("id", "name", "label", "ok", "summary", "elapsed_ms", "step")} + ) + return sse("tool", payload) + if kind == "sources": + summary["sources"] = event["sources"] + return sse("sources", {"sources": event["sources"], "step": event.get("step")}) + if kind == "reset": + return sse("reset", {"step": event.get("step")}) + if kind == "wait": + return sse("wait", {k: v for k, v in event.items() if k != "type"}) + if kind == "notice": + if event["text"] not in summary["notices"]: + summary["notices"].append(event["text"]) + return sse("notice", {"text": event["text"]}) + if kind == "verdict": + summary["verdict"] = event["verdict"] + summary["verdict_detail"] = {"verdict": event["verdict"], "note": event.get("note", ""), **(event.get("detail") or {})} + return sse("verdict", {"verdict": event["verdict"], "note": event.get("note", ""), "detail": event.get("detail") or {}}) + return "" + + if compacting_turns: + yield sse("compacting", { + "turns": compacting_turns, + "text": f"Compacting the conversation: summarizing {compacting_turns} earlier " + f"message{'' if compacting_turns == 1 else 's'}…", + }) + window, note, compaction_notice = await compaction.prepare( + history_dump, session_id=req.session_id, provider=provider, model=req.model, + ) + if compaction_notice: + stream_notices.append(compaction_notice) + summary["notices"].append(compaction_notice) + yield sse("meta", { + "provider": provider, "complexity": complexity, "thinking_mode": mode, + "strategy": strategy, "notices": list(stream_notices), + "model": _model_label(run_target, req.model), "escalated": escalated, + "compacted": bool(note), "effort": effort.as_dict(), + "project": {"id": project["id"], "name": project.get("name")} if project else None, + }) + try: + with compaction.using(note): + if mode == "heavy" and not direct: + with target_mod.using(run_target): + async for event in stream_heavy( + req.query, provider, history=window, system_prompt=system_prompt, + forced_tools=forced, research=effort.research, registry=registry, + tool_rounds=effort.tool_rounds, + model=req.model, temperature=temperature, max_tokens=max_tokens, + ): + if event["type"] == "final": + final_answer = event.get("content") or "" + continue + frame = relay(event) + if frame: + yield frame + elif mode == "think" and not direct: + async for event in stream_think( + req.query, provider, history=window, system_prompt=system_prompt, + forced_tools=forced, plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds, + model=req.model, temperature=temperature, max_tokens=max_tokens, + with_critique=req.with_critique, + ): + if event["type"] == "final": + final_answer = event.get("content") or "" + continue + frame = relay(event) + if frame: + yield frame + else: + # One generation with the tool loop: Fast, and a trivial message in any mode. + messages = build_messages(req.query, history=window, system_prompt=system_prompt) + async for event in stream_with_tools( + messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens, + forced=forced, registry=registry, max_rounds=effort.tool_rounds, + ): + if event["type"] == "done": + final_answer = event.get("content") or "" + summary["tool_trace"] = list(event.get("tool_trace", [])) + summary["sources"] = list(event.get("sources", [])) + continue + frame = relay(event) + if frame: + yield frame + if mode in ("heavy", "think") and not direct and final_answer: + # The reply, sent whole: the step tokens above were the work, not the answer. + yield "event: final_answer\ndata: start\n\n" + yield tok(final_answer) + yield sse("done", { + "content": final_answer, "thinking_mode": mode, "strategy": strategy, "effort": effort.as_dict(), + "latency_ms": int((time.perf_counter() - started) * 1000), **summary, + }) + except Exception as exc: + logger.error("Expert stream error (mode=%s): %s", mode, exc, exc_info=True) + yield sse("error", {"error": str(exc)}) + yield "data: [DONE]\n\n" + + async def with_target(): + # Set inside the generator: the response body is iterated after this function has + # returned, so a context set around the return would already be gone. + with target_mod.using(chosen), grounding.using(req.client_timezone, req.context): + async for item in event_generator(): + yield item + + return StreamingResponse( + with_heartbeat(with_target()), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, + ) + + +async def with_heartbeat(source: AsyncIterator[str], every: Optional[float] = None) -> AsyncIterator[str]: + """``source``'s frames, plus a ``: keep-alive`` comment whenever it is silent for ``every`` + seconds (a long model call before the first token, a slow tool). Proxies and browsers + then never take a working stream for a dead one. The source runs as its own task, so a + timeout never cancels it mid-await; when the reader goes away, that task is cancelled + with the response, which closes the model request behind it.""" + every = config_mod.EXPERT_SSE_HEARTBEAT_S if every is None else every + if not every or every <= 0: + async for item in source: + yield item + return + queue: "asyncio.Queue[Any]" = asyncio.Queue() + finished = object() + + async def pump() -> None: + try: + async for item in source: + await queue.put(item) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - re-raised on the reader's side + await queue.put(exc) + return + await queue.put(finished) + + task = asyncio.create_task(pump()) + try: + while True: + try: + item = await asyncio.wait_for(queue.get(), timeout=every) + except asyncio.TimeoutError: + yield ": keep-alive\n\n" + continue + if item is finished: + break + if isinstance(item, Exception): + raise item + yield item + finally: + if not task.done(): + task.cancel() + try: + await task + except BaseException: # noqa: BLE001 - the pump's own cancellation + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Capabilities + Deep research +# ───────────────────────────────────────────────────────────────────────────── + +_SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"} + + +@router.get("/usage", summary="Usage meter: each model endpoint's queue, pacing and learned rate limit") +async def expert_usage() -> dict: + """Per OpenAI-compatible endpoint this process has called: requests in flight and in line, + the gap between starts and any cooldown, the learned cap (requests per window before the + provider rate-limits), and the rolling window's requests, successes and rate limits.""" + from .providers import endpoint_usage + from .config import EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S + return { + "endpoints": endpoint_usage(), + "policy": { + "max_in_flight": EXPERT_PROVIDER_MAX_INFLIGHT, + "interactive_wait_s": EXPERT_PROVIDER_WAIT_S, + "background_wait_s": EXPERT_PROVIDER_BACKGROUND_WAIT_S, + }, + } + + +@router.post("/capabilities", summary="What the connected models can carry (the Deep research gate)") +async def expert_capabilities(req: CapabilitiesRequest) -> dict: + """The selected model's tier, the strongest model on the same connection, and whether + Deep research is offered (capability.py). POST, so a gateway key never rides in a URL.""" + chosen = target_mod.from_request(req.target) + return await capability.assess(chosen, req.model, run_probe=req.probe) + + +@router.post("/deep", summary="Start a Deep research run (background job)") +async def deep_start(req: ExpertChatRequest) -> dict: + """Plan → sub-agents with tools → cited report, as a background job (deep.py). Refused + (403) when the connection has no high-end model, unless EXPERT_DEEP_RESEARCH=always.""" + chosen = target_mod.from_request(req.target) + provider = _provider_for(req, chosen) + caps = await capability.assess(chosen, req.model) + gate = caps["deep_research"] + if not gate["enabled"]: + raise HTTPException(status_code=403, detail=gate["reason"]) + notices: list = [] + # The model chosen in the chat runs the research too (no silent swap to another model). + run_target, model = chosen, req.model + window, note, compaction_notice = await compaction.prepare( + [m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=model, + ) + if compaction_notice: + notices.append(compaction_notice) + registry = _request_tools(req) + job = deep.start( + req.query, provider=provider, model=_model_label(run_target, model), history=window, chosen=run_target, + timezone=req.client_timezone, context=req.context, note=note, notices=notices, + registry=registry, operator_prompt=project_system_prompt(req.system_prompt, _project(req)), + session_id=req.session_id, + ) + return {"job_id": job.id, "status": job.status, "model": job.model, "notices": notices, + "time_budget_s": EXPERT_DEEP_TIME_BUDGET_S} + + +@router.get("/deep/{job_id}", summary="Deep research job snapshot") +async def deep_status(job_id: str) -> dict: + job = deep.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown Deep research job") + return job.snapshot() + + +@router.get("/deep/{job_id}/events", summary="Deep research progress (SSE; replays from `after`, or from the start)") +async def deep_events(job_id: str, request: Request, after: int = 0) -> StreamingResponse: + """Every frame carries ``id: ``. A client that reconnects sends ``Last-Event-ID`` (or + ``?after=``) and gets only what it missed — no duplicate steps, no duplicate text.""" + job = deep.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown Deep research job") + header = request.headers.get("last-event-id") + if header and header.isdigit(): + after = max(after, int(header)) + + async def gen(): + queue = job.subscribe(after=after) + try: + while True: + try: + ev = await asyncio.wait_for(queue.get(), timeout=15.0) + except asyncio.TimeoutError: + if job.finished_state: + break + yield ": keep-alive\n\n" + continue + yield f"id: {ev.get('seq', 0)}\nevent: {ev['type']}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n" + if ev["type"] in ("done", "error", "cancelled"): + break + finally: + job.unsubscribe(queue) + yield "data: [DONE]\n\n" + + return StreamingResponse(gen(), media_type="text/event-stream", headers=_SSE_HEADERS) + + +@router.delete("/deep/{job_id}", summary="Cancel a Deep research job") +async def deep_cancel(job_id: str) -> dict: + job = deep.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown Deep research job") + job.cancel() + return {"job_id": job_id, "status": job.status} + + +# ───────────────────────────────────────────────────────────────────────────── +# POST /v1/expert/route (debug helper — shows routing decision) +# ───────────────────────────────────────────────────────────────────────────── + +@router.post("/route", summary="Debug: show provider routing decision") +async def expert_route(query: str, preferred: str = "auto") -> dict: + """ + Returns the provider that would be selected for a given query. + Useful for debugging and tuning routing thresholds. + """ + complexity = score_complexity(query) + provider = select_provider(query, preferred=preferred) # type: ignore[arg-type] + return { + "query_preview": query[:100], + "complexity_score": complexity, + "selected_provider": provider, + "available_providers": available_expert_providers(), + "thresholds": { + "local": f"complexity ≤ {EXPERT_LOCAL_THRESHOLD}", + "groq": f"complexity ≤ {EXPERT_GROQ_THRESHOLD}", + "cloud": f"complexity > {EXPERT_GROQ_THRESHOLD}", + }, + } + + +@router.post("/ollabridge/chat/completions", summary="OllaBridge-compatible Expert chat") +async def ollabridge_chat(req: OllabridgeChatRequest) -> dict: + """ + OpenAI-compatible adapter endpoint so HomePilot can expose Expert through + OllaBridge cloud persona workflows. + """ + if req.stream: + raise HTTPException(status_code=400, detail="stream=true is not supported on this adapter endpoint yet.") + + if not req.messages: + raise HTTPException(status_code=422, detail="messages cannot be empty.") + + user_query = "" + history = [] + for msg in req.messages: + d = msg.model_dump() + if d["role"] == "user": + user_query = d["content"] + else: + history.append(d) + + if not user_query: + raise HTTPException(status_code=422, detail="At least one user message is required.") + + expert_req = ExpertChatRequest( + query=user_query, + provider=req.provider, + thinking_mode=req.thinking_mode, + history=history, + model=req.model, + temperature=req.temperature, + max_tokens=req.max_tokens, + ) + expert_resp = await expert_chat(expert_req) + + return { + "id": "expert-ollabridge-chat", + "object": "chat.completion", + "provider": expert_resp.provider_used, + "model": expert_resp.model_used or req.model or "expert-auto", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": expert_resp.content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + +@router.post("/persona/draft", response_model=PersonaDraftResponse, summary="Create persona draft from Expert") +async def expert_persona_draft(req: PersonaDraftRequest) -> PersonaDraftResponse: + """ + Generate a persona draft that can be used by HomePilot persona creation + flows (including OllaBridge cloud personas). + """ + constraints = "\\n".join(f"- {c}" for c in req.constraints) if req.constraints else "- Keep safety-first behavior." + prompt = ( + "Create a high-quality HomePilot persona draft as JSON fields:\\n" + "name, description, system_prompt, first_message, suggested_tools (array), " + "memory_policy (object with keys recall, retention_days, pii_redaction).\\n\\n" + f"Mission: {req.mission}\\n" + f"Domain: {req.domain}\\n" + f"Tone: {req.tone}\\n" + f"Constraints:\\n{constraints}\\n" + ) + + expert_req = ExpertChatRequest( + query=prompt, + provider=req.provider, + thinking_mode=req.thinking_mode, + model=req.model, + temperature=req.temperature, + max_tokens=req.max_tokens, + ) + resp = await expert_chat(expert_req) + + content = resp.content.strip() + default_name = f"{req.domain.title()} Research Persona" + default_desc = f"Persona for {req.mission[:120]}" + return PersonaDraftResponse( + name=default_name, + description=default_desc, + system_prompt=content, + first_message=f"Hello, I am your {default_name}. I can help with: {req.mission}.", + suggested_tools=["retrieval", "web_search", "model_compare"], + memory_policy={"recall": True, "retention_days": 30, "pii_redaction": True}, + provider_used=resp.provider_used, + thinking_mode_used=resp.thinking_mode_used, + ) + + +@router.get("/readiness", response_model=ProductionReadinessResponse, summary="Expert production readiness report") +async def expert_readiness() -> ProductionReadinessResponse: + """ + Returns a lightweight production-readiness report based on Expert feature + flags and required runtime configuration. + """ + report = build_readiness_report() + return ProductionReadinessResponse( + ready=report.ready, + stage=report.stage, # type: ignore[arg-type] + checks=[ReadinessCheckItem(name=c.name, passed=c.passed, detail=c.detail) for c in report.checks], + ) + + +@router.get("/mcp/plan", summary="Expert MCP rollout plan and configuration status") +async def expert_mcp_plan() -> dict: + gateway = MCPGateway() + status_by_key = {item.key: item for item in gateway.status()} + servers = [] + + for spec in ESSENTIAL_MCP_SERVERS: + status = status_by_key.get(spec.key) + servers.append( + { + "key": spec.key, + "priority": spec.priority, + "purpose": spec.purpose, + "required_apis": spec.required_apis, + "configured": status.configured if status else False, + "endpoint": status.endpoint if status else "", + "forge_tool_id": status.forge_tool_id if status else "", + "env_key": mcp_key_to_env(spec.key), + "forge_tool_env_key": mcp_key_to_forge_tool_env(spec.key), + } + ) + + return { + "orchestrator": gateway.orchestrator, + "context_forge_url": gateway.forge_base_url, + "tool_mapping": TOOL_MAPPING, + "servers": servers, + "minimal_production_set": [ + "mcp-web-search", + "mcp-doc-retrieval", + "mcp-memory-store", + "mcp-safety-policy", + "mcp-observability", + ], + } diff --git a/expert/backend/app/expert/safety_policy.py b/expert/backend/app/expert/safety_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..a9837d7aab5c965b101517ce199c31bfe7227ae3 --- /dev/null +++ b/expert/backend/app/expert/safety_policy.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + + +_BLOCKED_PATTERNS = [ + "how to build a bomb", + "weaponize", + "bypass biosafety", +] + + +@dataclass +class SafetyDecision: + allowed: bool + reasons: List[str] + + +def evaluate_prompt(prompt: str, profile: str = "strict-research") -> SafetyDecision: + q = prompt.lower() + reasons: List[str] = [] + + if profile == "off": + return SafetyDecision(True, reasons) + + for p in _BLOCKED_PATTERNS: + if p in q: + reasons.append(f"Blocked pattern matched: {p}") + + return SafetyDecision(allowed=not reasons, reasons=reasons) diff --git a/expert/backend/app/expert/schemas.py b/expert/backend/app/expert/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..5917ef711e7a90bf010de3dd649eaf86fd2c9605 --- /dev/null +++ b/expert/backend/app/expert/schemas.py @@ -0,0 +1,230 @@ +# expert/schemas.py +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional +from pydantic import BaseModel, Field + + +class ExpertMessage(BaseModel): + """Single turn in conversation history.""" + role: Literal["user", "assistant", "system"] + content: str + + +class ExpertTarget(BaseModel): + """The chat model the user picked in Models / Settings (see ``target.py``).""" + provider: str = Field(default="", max_length=40, description="'ollama', 'openai_compat', …") + base_url: Optional[str] = Field(default=None, max_length=500) + model: Optional[str] = Field(default=None, max_length=300) + api_key: Optional[str] = Field(default=None, max_length=4096, repr=False) + + +class ExpertProject(BaseModel): + """The project a chat runs inside: its instructions reach every step; its documents become + the ``project_search`` tool when the backend's retrieval is reachable.""" + id: str = Field(..., max_length=120) + name: Optional[str] = Field(default=None, max_length=200) + instructions: Optional[str] = Field(default=None, max_length=20000) + document_count: int = Field(default=0, ge=0) + + +class ExpertChatRequest(BaseModel): + """Request body for POST /v1/expert/chat (non-streaming).""" + query: str = Field(..., min_length=1, description="User's message / question") + project: Optional[ExpertProject] = Field(default=None, description="The project this chat runs inside.") + provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = Field( + default="auto", + description="Provider to use. 'auto' applies complexity-based routing.", + ) + thinking_mode: Literal["auto", "fast", "think", "heavy"] = Field( + default="auto", + description=( + "Pipeline mode. " + "'auto' = complexity-based selection; " + "'fast' = single LLM call, local model; " + "'think' = analyze→plan→solve chain (3 LLM calls); " + "'heavy' = 4-agent research→reason→synthesize→validate pipeline." + ), + ) + with_critique: bool = Field( + default=False, + description="In 'think' mode, add a self-critique/correction step.", + ) + history: List[ExpertMessage] = Field( + default_factory=list, + description="Previous conversation turns (newest last).", + ) + model: Optional[str] = Field( + default=None, + description="Override the default model for the selected provider.", + ) + temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(default=None, ge=1, le=8192) + system_prompt: Optional[str] = Field( + default=None, + description="Override the default Expert system prompt.", + ) + client_timezone: Optional[str] = Field( + default=None, max_length=64, + description="The browser's IANA time zone, so the prompt's 'today' is the user's (grounding.py).", + ) + context: Optional[str] = Field( + default=None, max_length=40000, + description="Grounding for this request — e.g. HomePilot's numbered web sources — added to every system prompt.", + ) + session_id: Optional[str] = Field( + default=None, + description="Session identifier for memory/trace linkage.", + ) + has_attachments: bool = Field( + default=False, + description="Whether the request includes attachments or external artifacts.", + ) + feature_hints: Dict[str, Any] = Field( + default_factory=dict, + description="Optional non-authoritative hints from UI (e.g. budget tier, tool-use preference).", + ) + target: Optional[ExpertTarget] = Field( + default=None, + description=( + "The user's chosen chat model. When set, 'local' (and 'auto') answer from it — " + "an Ollama model or an OpenAI-compatible endpoint such as an OllaBridge gateway." + ), + ) + + +class ExpertStreamRequest(ExpertChatRequest): + """Same as ExpertChatRequest — exists for clarity in the route signature.""" + pass + + +class ExpertChatResponse(BaseModel): + """Response from POST /v1/expert/chat.""" + model_config = {"protected_namespaces": ()} + + content: str = Field(..., description="The assistant's reply.") + provider_used: str = Field(..., description="Which provider actually answered.") + model_used: Optional[str] = Field(default=None) + complexity_score: int = Field( + ..., ge=0, le=10, + description="Complexity score that drove provider selection (0=simple, 10=complex).", + ) + thinking_mode_used: str = Field( + default="fast", + description="Which pipeline actually ran: fast / think / heavy.", + ) + steps: Optional[Dict[str, Any]] = Field( + default=None, + description="Intermediate step outputs from think/heavy pipelines.", + ) + provider_raw: Optional[Dict[str, Any]] = Field( + default=None, + description="Raw provider response for debugging (omitted in production).", + ) + strategy_used: str = Field( + default="single-pass", + description="Resolved execution strategy: single-pass / expert-thinking / heavy-multi-pass.", + ) + fallback_applied: bool = Field( + default=False, + description="True when the request had to fall back from requested provider/pipeline path.", + ) + notices: List[str] = Field( + default_factory=list, + description="User-visible operational notices (fallbacks, policy blocks, etc.).", + ) + latency_ms: Optional[int] = Field( + default=None, + description="Total backend processing latency in milliseconds.", + ) + tool_trace: List[Dict[str, Any]] = Field( + default_factory=list, + description="Tool calls made while answering (name, label, ok, elapsed, source numbers).", + ) + sources: List[Dict[str, Any]] = Field( + default_factory=list, + description="Numbered sources the answer may cite as [n] (n, title, url, domain, snippet).", + ) + verdict: Optional[str] = Field( + default=None, + description="Heavy only: the validator's verdict — validated / corrected / needs_review / reviewed.", + ) + verdict_detail: Optional[Dict[str, Any]] = Field( + default=None, + description="Heavy only: issues, method and limitations behind the verdict.", + ) + model_escalated: Optional[str] = Field( + default=None, + description="Heavy only: the stronger model used instead of the selected one, when escalated.", + ) + effort: Optional[Dict[str, Any]] = Field( + default=None, + description="What this request spent: level (direct / standard / full) and why (policies.py).", + ) + + +class CapabilitiesRequest(BaseModel): + """Body for POST /v1/expert/capabilities: which connection and model to assess.""" + target: Optional[ExpertTarget] = None + model: Optional[str] = Field(default=None, max_length=300) + probe: bool = Field(default=False, description="Run the one-time tool-calling and JSON probes when not cached.") + + +class ExpertInfoResponse(BaseModel): + """Response from GET /v1/expert/info — lists available providers and routing config.""" + available_providers: List[str] + default_provider: str + local_threshold: int + groq_threshold: int + local_model: str + local_fast_model: str + groq_model: str + grok_model: str + gemini_model: str + + +class OllabridgeChatRequest(BaseModel): + """OpenAI-compatible request body for OllaBridge Expert adapter endpoint.""" + model: Optional[str] = None + messages: List[ExpertMessage] = Field(default_factory=list) + stream: bool = False + temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(default=None, ge=1, le=8192) + provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto" + thinking_mode: Literal["auto", "fast", "think", "heavy"] = "auto" + + +class PersonaDraftRequest(BaseModel): + """Generate a persona draft from Expert for HomePilot persona creation.""" + mission: str = Field(..., min_length=3, description="Primary role and objective of the persona.") + domain: str = Field(default="general") + tone: str = Field(default="helpful") + constraints: List[str] = Field(default_factory=list) + provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto" + thinking_mode: Literal["auto", "fast", "think", "heavy"] = "think" + model: Optional[str] = None + temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(default=None, ge=1, le=8192) + + +class PersonaDraftResponse(BaseModel): + name: str + description: str + system_prompt: str + first_message: str + suggested_tools: List[str] = Field(default_factory=list) + memory_policy: Dict[str, Any] = Field(default_factory=dict) + provider_used: str + thinking_mode_used: str + + +class ReadinessCheckItem(BaseModel): + name: str + passed: bool + detail: str + + +class ProductionReadinessResponse(BaseModel): + ready: bool + stage: Literal["dev", "preprod", "production"] + checks: List[ReadinessCheckItem] diff --git a/expert/backend/app/expert/state_store.py b/expert/backend/app/expert/state_store.py new file mode 100644 index 0000000000000000000000000000000000000000..b9bfbf2a0e851398c22f439e18ffe2dcfac020f9 --- /dev/null +++ b/expert/backend/app/expert/state_store.py @@ -0,0 +1,227 @@ +"""A small durable store for the Expert service (SQLite, one file). + +What must survive a restart: the capability facts learned about models ("this gateway model +rejects the tools field"), and Deep research jobs with their event history, so a report is +not lost and a client can reconnect. Opened per call with a process lock; a handful of rows +per day, so there is nothing to tune. + +Location: ``EXPERT_STATE_DB``, else ``/expert_state.sqlite`` (HomePilot's data +directory), else ``backend/data/expert_state.sqlite`` next to the main database. +""" +from __future__ import annotations + +import json +import logging +import os +import sqlite3 +import tempfile +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .config import EXPERT_STATE_DB + +logger = logging.getLogger("expert.state") + +_LOCK = threading.RLock() +_PATH: Optional[Path] = None +_READY = False + + +def _default_path() -> Path: + if EXPERT_STATE_DB: + return Path(EXPERT_STATE_DB) + data_dir = (os.getenv("DATA_DIR") or "").strip() + if data_dir: + return Path(data_dir) / "expert_state.sqlite" + repo = Path(__file__).resolve().parents[4] + return repo / "backend" / "data" / "expert_state.sqlite" + + +def configure(path: Optional[os.PathLike] = None) -> Path: + """Point the store at ``path`` (tests), or at the default. Creates the schema.""" + global _PATH, _READY + with _LOCK: + _PATH = Path(path) if path is not None else _default_path() + _READY = False + try: + _PATH.parent.mkdir(parents=True, exist_ok=True) + _init() + except Exception as exc: # noqa: BLE001 + fallback = Path(tempfile.gettempdir()) / "homepilot-expert-state.sqlite" + logger.warning("expert state store at %s unusable (%s); using %s", _PATH, exc, fallback) + _PATH = fallback + _init() + return _PATH + + +def path() -> Path: + if _PATH is None: + configure() + return _PATH # type: ignore[return-value] + + +def _connect() -> sqlite3.Connection: + con = sqlite3.connect(str(path()), timeout=10, check_same_thread=False) + con.execute("PRAGMA journal_mode=WAL") + return con + + +def _init() -> None: + global _READY + with sqlite3.connect(str(_PATH), timeout=10) as con: + con.execute("CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL, expires REAL)") + con.execute( + "CREATE TABLE IF NOT EXISTS deep_jobs(id TEXT PRIMARY KEY, created REAL NOT NULL, updated REAL NOT NULL, " + "status TEXT NOT NULL, snapshot TEXT NOT NULL)" + ) + con.execute( + "CREATE TABLE IF NOT EXISTS deep_events(job_id TEXT NOT NULL, seq INTEGER NOT NULL, event TEXT NOT NULL, " + "PRIMARY KEY(job_id, seq))" + ) + _READY = True + + +def _ensure() -> None: + if not _READY: + configure(_PATH) + + +# ── key/value with expiry ──────────────────────────────────────────────────── + +def kv_get(key: str) -> Optional[Dict[str, Any]]: + with _LOCK: + _ensure() + try: + with _connect() as con: + row = con.execute("SELECT value, expires FROM kv WHERE key = ?", (key,)).fetchone() + except sqlite3.Error as exc: + logger.debug("kv_get %s failed: %s", key, exc) + return None + if not row: + return None + value, expires = row + if expires is not None and expires < time.time(): + kv_delete(key) + return None + try: + data = json.loads(value) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +def kv_set(key: str, value: Dict[str, Any], ttl_s: Optional[float] = None) -> None: + expires = time.time() + ttl_s if ttl_s else None + with _LOCK: + _ensure() + try: + with _connect() as con: + con.execute("INSERT OR REPLACE INTO kv(key, value, expires) VALUES (?, ?, ?)", + (key, json.dumps(value, ensure_ascii=False), expires)) + except sqlite3.Error as exc: + logger.debug("kv_set %s failed: %s", key, exc) + + +def kv_delete(key: str) -> None: + with _LOCK: + _ensure() + try: + with _connect() as con: + con.execute("DELETE FROM kv WHERE key = ?", (key,)) + except sqlite3.Error: + pass + + +# ── Deep research jobs ─────────────────────────────────────────────────────── + +def job_save(job_id: str, status: str, snapshot: Dict[str, Any], created: Optional[float] = None) -> None: + now = time.time() + with _LOCK: + _ensure() + try: + with _connect() as con: + con.execute( + "INSERT INTO deep_jobs(id, created, updated, status, snapshot) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET updated = excluded.updated, status = excluded.status, snapshot = excluded.snapshot", + (job_id, created or now, now, status, json.dumps(snapshot, ensure_ascii=False)), + ) + except sqlite3.Error as exc: + logger.debug("job_save %s failed: %s", job_id, exc) + + +def job_load(job_id: str) -> Optional[Dict[str, Any]]: + with _LOCK: + _ensure() + try: + with _connect() as con: + row = con.execute("SELECT created, updated, status, snapshot FROM deep_jobs WHERE id = ?", (job_id,)).fetchone() + except sqlite3.Error: + return None + if not row: + return None + created, updated, status, snapshot = row + try: + data = json.loads(snapshot) + except json.JSONDecodeError: + data = {} + return {"created": created, "updated": updated, "status": status, "snapshot": data} + + +def jobs_unfinished() -> List[str]: + with _LOCK: + _ensure() + try: + with _connect() as con: + rows = con.execute( + "SELECT id FROM deep_jobs WHERE status NOT IN ('done', 'failed', 'cancelled')" + ).fetchall() + except sqlite3.Error: + return [] + return [r[0] for r in rows] + + +def event_append(job_id: str, seq: int, event: Dict[str, Any]) -> None: + with _LOCK: + _ensure() + try: + with _connect() as con: + con.execute("INSERT OR REPLACE INTO deep_events(job_id, seq, event) VALUES (?, ?, ?)", + (job_id, seq, json.dumps(event, ensure_ascii=False))) + except sqlite3.Error as exc: + logger.debug("event_append %s/%s failed: %s", job_id, seq, exc) + + +def events_after(job_id: str, after: int = 0) -> List[Dict[str, Any]]: + with _LOCK: + _ensure() + try: + with _connect() as con: + rows = con.execute( + "SELECT event FROM deep_events WHERE job_id = ? AND seq > ? ORDER BY seq", (job_id, after) + ).fetchall() + except sqlite3.Error: + return [] + out: List[Dict[str, Any]] = [] + for (raw,) in rows: + try: + out.append(json.loads(raw)) + except json.JSONDecodeError: + continue + return out + + +def jobs_prune(older_than_s: float) -> int: + cutoff = time.time() - older_than_s + with _LOCK: + _ensure() + try: + with _connect() as con: + ids = [r[0] for r in con.execute("SELECT id FROM deep_jobs WHERE updated < ?", (cutoff,)).fetchall()] + for job_id in ids: + con.execute("DELETE FROM deep_events WHERE job_id = ?", (job_id,)) + con.execute("DELETE FROM deep_jobs WHERE id = ?", (job_id,)) + return len(ids) + except sqlite3.Error: + return 0 diff --git a/expert/backend/app/expert/target.py b/expert/backend/app/expert/target.py new file mode 100644 index 0000000000000000000000000000000000000000..b3a40499beaa6d1cb17346e702ee0b6faba53cfb --- /dev/null +++ b/expert/backend/app/expert/target.py @@ -0,0 +1,92 @@ +# expert/target.py +"""The chat model the user picked, for the length of one Expert request. + +Expert used to choose its own model: a request said ``provider: "auto"`` and Expert answered +from Ollama's default — even after the user had connected an OllaBridge gateway in Models and +picked ``openwebui/internal-gemma4-31b`` for chat. The reasoning modes (Fast, Think, Heavy) +therefore talked to a different model from the rest of the chat, and when Ollama was busy the +user saw ``500 Internal Server Error for url 'http://localhost:11434/api/chat'`` for a model +they never chose. + +The client now sends the chat selection as ``target`` (provider, base URL, model, key). It is +held in a context variable rather than threaded through every pipeline step, so Fast, Think, +Heavy and their streaming forms all use it without each learning a new parameter — the steps +run inside the request's task, which carries the variable with it. + +``local`` means "the user's chosen model" whenever a target is set. A target is never replaced +by a cloud provider behind the user's back: with one set, ``auto`` routes to it rather than to +Groq or Gemini on a complex question. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any, Iterator, Optional + +#: Providers answered through Ollama's own API; everything else is OpenAI-compatible. +OLLAMA_PROVIDERS = ("", "ollama") + + +@dataclass(frozen=True) +class ChatTarget: + provider: str + base_url: str + model: str + api_key: str = "" + + @property + def is_ollama(self) -> bool: + return self.provider in OLLAMA_PROVIDERS + + @property + def label(self) -> str: + """What a response reports as the provider that answered.""" + return "local" if self.is_ollama else self.provider + + def __repr__(self) -> str: # never print the key + return f"ChatTarget(provider={self.provider!r}, base_url={self.base_url!r}, model={self.model!r})" + + +_CURRENT: ContextVar[Optional[ChatTarget]] = ContextVar("expert_chat_target", default=None) + + +def from_request(raw: Any) -> Optional[ChatTarget]: + """A target from the request's ``target`` field, or ``None`` when it names nothing. + + An Ollama target with no model and no URL is the same as no target: Expert's own defaults + already mean "the local Ollama". + """ + if raw is None: + return None + get = raw.get if isinstance(raw, dict) else (lambda k, d=None: getattr(raw, k, d)) + provider = str(get("provider", "") or "").strip().lower() + base_url = str(get("base_url", "") or "").strip().rstrip("/") + model = str(get("model", "") or "").strip() + api_key = str(get("api_key", "") or "").strip() + if provider in OLLAMA_PROVIDERS and not model and not base_url: + return None + if provider in OLLAMA_PROVIDERS and base_url.lower().endswith("/v1"): + # "Ollama" with an OpenAI-style Base URL (OllaBridge's http://localhost:11435/v1, or + # Ollama's own /v1): that URL speaks the OpenAI API, not Ollama's — sending + # /api/chat there answered 404 and the chat said "Ollama rejected model …". + provider = "openai_compat" + if provider not in OLLAMA_PROVIDERS and not base_url: + # An OpenAI-compatible provider with nowhere to send it cannot be honoured; Expert's + # defaults are a better answer than an error about a URL the user never typed. + return None + return ChatTarget(provider=provider, base_url=base_url, model=model, api_key=api_key) + + +def current() -> Optional[ChatTarget]: + return _CURRENT.get() + + +@contextmanager +def using(target: Optional[ChatTarget]) -> Iterator[Optional[ChatTarget]]: + """Make ``target`` the chat target for everything awaited inside the block.""" + token = _CURRENT.set(target) + try: + yield target + finally: + _CURRENT.reset(token) diff --git a/expert/backend/app/expert/thinking.py b/expert/backend/app/expert/thinking.py new file mode 100644 index 0000000000000000000000000000000000000000..d18c68dd26af01c6870ec28f600f28068ca7205e --- /dev/null +++ b/expert/backend/app/expert/thinking.py @@ -0,0 +1,311 @@ +# expert/thinking.py +# Multi-step chain-of-thought pipeline for Expert "think" mode. +# +# Instead of a single prompt → response, this pipeline runs 3 sequential +# LLM calls that mirror how a human expert actually solves hard problems: +# +# Step 1 — ANALYZE: Decompose the problem, identify what's being asked +# Step 2 — PLAN: Create a step-by-step strategy +# Step 3 — SOLVE: Execute the plan into a final answer +# +# Each step is streamed individually so the UI can show progress. +# The pipeline reuses the same dispatch layer — provider-agnostic. +from __future__ import annotations + +import logging +from typing import Any, AsyncIterator, Dict, List, Optional, Tuple + +from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT +from .router import ( + dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName, +) +from .tool_loop import run_with_tools, stream_with_tools + +logger = logging.getLogger("expert.thinking") + +# Standard effort: the analysis is written (and shown), then answered from — no separate plan. +_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. + +Original problem: +{input} + +Analysis: +{analysis} + +Final answer:""" + + +def _solve_prompt(query: str, *, plan: bool, plan_text: str, analyze: bool, analysis: str) -> str: + """The solve step's instruction for the stages that ran before it.""" + if plan: + return _SOLVE_PROMPT.format(input=query, plan=plan_text) + if analyze and analysis.strip(): + return _SOLVE_FROM_ANALYSIS_PROMPT.format(input=query, analysis=analysis.strip()) + return _SOLVE_DIRECT_PROMPT.format(input=query) + +# ───────────────────────────────────────────────────────────────────────────── +# Step prompts +# ───────────────────────────────────────────────────────────────────────────── + +_ANALYZE_PROMPT = """\ +You are an analytical reasoning engine. Your job is ONLY to decompose and analyze the following problem. + +Do NOT attempt to solve it yet. Instead: +1. Identify what is actually being asked (restate it precisely) +2. Identify the domain(s) involved +3. Identify what information or reasoning is needed +4. Flag any ambiguities or unstated assumptions + +Problem: +{input} + +Analysis:""" + +_PLAN_PROMPT = """\ +You are a strategic planner. Based on the following analysis, create a clear step-by-step plan to solve the original problem. + +Each step should be concrete and actionable. Do NOT solve yet — only plan. + +Analysis: +{analysis} + +Step-by-step plan:""" + +_SOLVE_PROMPT = """\ +You are an expert solver. Execute the following plan to produce a final, complete answer to the original problem. + +Be thorough, accurate, and clear. Show your work where relevant. + +Original problem: {input} + +Plan to execute: +{plan} + +Final answer:""" + +_SOLVE_DIRECT_PROMPT = """\ +You are an expert solver. Produce a complete, accurate answer to the following problem; show your work where it helps. + +Problem: {input} + +Final answer:""" + +_CRITIQUE_PROMPT = """\ +You are a rigorous critic. Review the following answer and check for: +- Factual errors +- Logical gaps +- Missing edge cases +- Clarity issues + +If the answer is correct and complete, say so briefly. Otherwise, provide a corrected version. + +Answer to review: +{solution} + +Review:""" + + +# ───────────────────────────────────────────────────────────────────────────── +# Sync thinking pipeline (returns full result + steps for inspection) +# ───────────────────────────────────────────────────────────────────────────── + +async def think( + query: str, + provider: ProviderName = "auto", + *, + history: Optional[List[Dict[str, str]]] = None, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + with_critique: bool = False, + system_prompt: Optional[str] = None, + forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None, + plan: bool = True, + analyze: bool = False, + registry: Any = None, + tool_rounds: Optional[int] = None, +) -> Dict[str, Any]: + """ + Run the analyze → plan → solve pipeline, or solve alone when ``plan`` is False (a request + below the planning threshold, see policies.effort_for). + Returns a dict with each step's output plus the final answer. + ``system_prompt`` (the persona's or the user's instructions) replaces the Expert default; + ``forced_tools`` run before the solve step asks the model anything; ``registry`` is the + request's tool set (the shared one when None). + """ + base = system_prompt or EXPERT_SYSTEM_PROMPT + + # Each step gets the recent conversation ahead of its instruction (see step_messages). + def _msg(prompt: str) -> List[Dict[str, str]]: + return step_messages(prompt, system_prompt=base, history=history) + + kw = dict(model=model, temperature=temperature, max_tokens=max_tokens) + notices: List[str] = [] + fallback_applied = False + analysis = plan_text = "" + + if plan or analyze: + # Step 1: Analyze — the visible reasoning (also alone, at standard effort) + logger.debug("Thinking step 1: analyze") + raw_analysis = await dispatch(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw) + analysis = _extract(raw_analysis) + notices.extend(_collect_notice(raw_analysis)) + fallback_applied = _fallback(raw_analysis) + + if plan: + # Step 2: Plan + logger.debug("Thinking step 2: plan") + raw_plan = await dispatch(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw) + plan_text = _extract(raw_plan) + notices.extend(_collect_notice(raw_plan)) + fallback_applied = fallback_applied or _fallback(raw_plan) + + # Step 3: Solve — with the per-turn tool loop, so the answer can check facts + logger.debug("Thinking step 3: solve") + solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis) + solved = await run_with_tools( + _msg(solve_prompt), provider, call=dispatch, forced=forced_tools, registry=registry, + max_rounds=tool_rounds, **kw + ) + solution = solved.get("content") or "" + notices.extend(solved.get("notices", [])) + fallback_applied = fallback_applied or bool(solved.get("fallback_applied")) + + # Step 4 (optional): Self-critique + critique = None + if with_critique: + logger.debug("Thinking step 4: critique") + raw_critique = await dispatch( + _msg(_CRITIQUE_PROMPT.format(solution=solution)), provider, **kw + ) + critique = _extract(raw_critique) + notices.extend(_collect_notice(raw_critique)) + fallback_applied = fallback_applied or _fallback(raw_critique) + + return { + "final_answer": solution, + "steps": { + **({"analysis": analysis} if (plan or analyze) else {}), + **({"plan": plan_text} if plan else {}), + "solution": solution, + **({"critique": critique} if critique else {}), + }, + "planned": plan, + "provider": provider, + "model": model, + "pipeline": "think", + "fallback_applied": fallback_applied, + "notices": _dedupe_notices(notices), + "tool_trace": solved.get("tool_trace", []), + "sources": solved.get("sources", []), + } + + +# ───────────────────────────────────────────────────────────────────────────── +# Streaming thinking pipeline — yields step labels + tokens +# ───────────────────────────────────────────────────────────────────────────── + +async def stream_think( + query: str, + provider: ProviderName = "auto", + *, + history: Optional[List[Dict[str, str]]] = None, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + with_critique: bool = False, + system_prompt: Optional[str] = None, + forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None, + plan: bool = True, + analyze: bool = False, + registry: Any = None, + tool_rounds: Optional[int] = None, +) -> AsyncIterator[Dict[str, Any]]: + """ + Stream the thinking pipeline step by step (solve alone when ``plan`` is False). + Yields dicts with type='step_start'|'token'|'step_end'|'final'|'done', plus the tool + loop's events (tool_start, tool_end, sources, reset, notice) tagged with their step. + A step_start carries role='work' or role='answer' (the step whose text is the reply). + + The route /v1/expert/stream uses this when thinking_mode='think'. + The UI can render each step in a collapsible panel. + """ + base = system_prompt or EXPERT_SYSTEM_PROMPT + + def _msg(prompt: str) -> List[Dict[str, str]]: + return step_messages(prompt, system_prompt=base, history=history) + + kw = dict(model=model, temperature=temperature, max_tokens=max_tokens) + analysis = plan_text = "" + + if plan or analyze: + # ── Step 1: Analyze — the visible reasoning, shown as ChatGPT and Claude show theirs + yield {"type": "step_start", "step": "analyze", "label": "🔍 Analyzing problem…", "role": "work"} + analysis_chunks: List[str] = [] + async for chunk in dispatch_stream(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw): + analysis_chunks.append(chunk) + yield {"type": "token", "step": "analyze", "content": chunk} + analysis = "".join(analysis_chunks) + yield {"type": "step_end", "step": "analyze"} + + if plan: + # ── Step 2: Plan ───────────────────────────────────────────────────── + yield {"type": "step_start", "step": "plan", "label": "📋 Building plan…", "role": "work"} + plan_chunks: List[str] = [] + async for chunk in dispatch_stream(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw): + plan_chunks.append(chunk) + yield {"type": "token", "step": "plan", "content": chunk} + plan_text = "".join(plan_chunks) + yield {"type": "step_end", "step": "plan"} + + # ── Step 3: Solve — with the per-turn tool loop ───────────────────────── + yield {"type": "step_start", "step": "solve", "label": "⚡ Solving…" if plan else "⚡ Answering…", "role": "answer"} + solution_text = "" + solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis) + async for ev in stream_with_tools( + _msg(solve_prompt), provider, + stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, **kw + ): + if ev["type"] == "token": + yield {"type": "token", "step": "solve", "content": ev["content"]} + elif ev["type"] == "done": + solution_text = ev.get("content") or "" + else: + yield {**ev, "step": "solve"} + yield {"type": "step_end", "step": "solve"} + + # ── Step 4 (optional): Critique ────────────────────────────────────────── + if with_critique: + yield {"type": "step_start", "step": "critique", "label": "🔎 Self-reviewing…", "role": "work"} + async for chunk in dispatch_stream( + _msg(_CRITIQUE_PROMPT.format(solution=solution_text)), provider, **kw + ): + yield {"type": "token", "step": "critique", "content": chunk} + yield {"type": "step_end", "step": "critique"} + + yield {"type": "final", "content": solution_text} + yield {"type": "done"} + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper +# ───────────────────────────────────────────────────────────────────────────── + +def _extract(raw: Dict[str, Any]) -> str: + try: + return raw["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError): + return "" + + +def _fallback(raw: Dict[str, Any]) -> bool: + return bool(extract_dispatch_meta(raw).get("fallback_applied")) + + +def _collect_notice(raw: Dict[str, Any]) -> List[str]: + notice = extract_dispatch_meta(raw).get("notice") + return [notice] if isinstance(notice, str) and notice else [] + + +def _dedupe_notices(notices: List[str]) -> List[str]: + return list(dict.fromkeys(notices)) diff --git a/expert/backend/app/expert/tool_loop.py b/expert/backend/app/expert/tool_loop.py new file mode 100644 index 0000000000000000000000000000000000000000..31b7dcfef5d028cd9ee373f74bcb2d33545f9857 --- /dev/null +++ b/expert/backend/app/expert/tool_loop.py @@ -0,0 +1,522 @@ +"""The per-turn tool loop: offer tools, run the calls the model makes, then answer. + +The shape ChatGPT and Claude use for one turn: the model may call tools for a few rounds +(bounded by ``EXPERT_TOOL_MAX_ROUNDS``), each result goes back as a ``tool`` message, and +the last round is forced to answer without tools. The streaming form emits events so the UI +can show "Searching the web…" chips, the numbered sources, and tokens as they arrive; the +non-streaming form goes through ``dispatch`` (the seam the sync pipelines and their tests +already use). Models or endpoints that refuse the ``tools`` field are remembered and answer +without them. +""" +from __future__ import annotations + +import json +import logging +import re +import time +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional, Tuple + +from .config import ( + EXPERT_MAX_TOKENS, + EXPERT_TEMPERATURE, + EXPERT_TOOLS_ENABLED, + EXPERT_TOOL_MAX_CALLS_PER_ROUND, + EXPERT_TOOL_MAX_ROUNDS, + EXPERT_TOOL_RESULT_MAX_CHARS, +) +from .config import EXPERT_CAPABILITY_NEGATIVE_TTL_S, EXPERT_CAPABILITY_TTL_S +from .providers import ToolsUnsupported +from .router import ProviderName, dispatch, extract_dispatch_meta, stream_provider_events +from . import state_store +from . import target as target_mod +from . import tools as tools_mod + +logger = logging.getLogger("expert.tool_loop") + +# What is known about a model's tool calling, by connection key (see capability_key): True when +# a call came back, False when the tools field was rejected. Learned facts survive a restart in +# the state store (an hour for a refusal, a month for a success); this dict is the warm copy. +# Bump the version when what a stored fact means changes (v2: facts learned against a gateway +# that failed every tool-bearing request no longer apply). +_TOOL_FACTS: Dict[str, bool] = {} +TOOL_SCHEMA_VERSION = "v2" + +DispatchFn = Callable[..., Awaitable[Dict[str, Any]]] + + +def capability_key(target: Optional["target_mod.ChatTarget"], provider: str, model: Optional[str]) -> str: + """One key per endpoint + model + tool schema version: what a learned fact applies to.""" + if target is not None: + return f"{target.provider}|{target.base_url}|{target.model or model or ''}|{TOOL_SCHEMA_VERSION}" + return f"{provider}||{model or ''}|{TOOL_SCHEMA_VERSION}" + + +def tools_known(key: str) -> Optional[bool]: + """True / False when a fact is known for ``key``, None when it has to be tried.""" + if key in _TOOL_FACTS: + return _TOOL_FACTS[key] + stored = state_store.kv_get(f"tools:{key}") + if stored is not None and isinstance(stored.get("ok"), bool): + _TOOL_FACTS[key] = stored["ok"] + return stored["ok"] + return None + + +def remember_tools(key: str, ok: bool, *, source: str = "observed") -> None: + _TOOL_FACTS[key] = ok + ttl = EXPERT_CAPABILITY_TTL_S * 4 if ok else EXPERT_CAPABILITY_NEGATIVE_TTL_S + state_store.kv_set(f"tools:{key}", {"ok": ok, "source": source, "at": time.time()}, ttl_s=ttl) + + +def forget_tools(key: Optional[str] = None) -> None: + """Drop learned facts (tests; a settings change).""" + if key is None: + for k in list(_TOOL_FACTS): + state_store.kv_delete(f"tools:{k}") + _TOOL_FACTS.clear() + else: + _TOOL_FACTS.pop(key, None) + state_store.kv_delete(f"tools:{key}") + +_THINK_BLOCK = re.compile(r".*?\s*", re.S) +_LEAKED_TAG = re.compile(r"^\s*(?:\s*|>\s*\n+)") + + +def strip_think(text: str) -> str: + """Text without a reasoning model's ``…`` block, or the tag fragment some + runtimes leak at the start of an answer (````, or a lone ``>`` before the text).""" + return _LEAKED_TAG.sub("", _THINK_BLOCK.sub("", text or "")) + + +class ThinkFilter: + """:func:`strip_think` for a token stream: holds back a possible partial tag, drops + everything inside a think block, and never emits the leaked fragment at the start.""" + + _TAGS = ("", "") + + def __init__(self) -> None: + self.buf = "" + self.inside = False + self.emitted = False + + def _pending_tag(self) -> int: + """Length of a tail of the buffer that may be the start of a tag.""" + for n in range(min(8, len(self.buf)), 0, -1): + tail = self.buf[-n:] + if any(tag.startswith(tail) for tag in self._TAGS): + return n + return 0 + + def feed(self, delta: str) -> str: + self.buf += delta + out = "" + while self.buf: + if self.inside: + end = self.buf.find("") + if end < 0: + self.buf = self.buf[-8:] # thinking is dropped; a split tag may be pending + break + self.buf = self.buf[end + len(""):].lstrip() + self.inside = False + continue + start = self.buf.find("") + if start >= 0: + out += self.buf[:start] + self.buf = self.buf[start + len(""):] + self.inside = True + continue + keep = self._pending_tag() + out += self.buf[: len(self.buf) - keep] + self.buf = self.buf[len(self.buf) - keep:] + break + if out and not self.emitted: + out = _LEAKED_TAG.sub("", out) + if out.strip(): + self.emitted = True + return out + + def flush(self) -> str: + out = "" if self.inside else self.buf + self.buf = "" + if out and not self.emitted: + out = _LEAKED_TAG.sub("", out) + return out + + +def _model_key(provider: str, model: Optional[str]) -> str: + return capability_key(target_mod.current(), provider, model) + + +def _with_system_note(messages: List[Dict[str, Any]], note: str) -> List[Dict[str, Any]]: + """``messages`` with ``note`` appended to the system prompt (one added when there is none).""" + if messages and messages[0].get("role") == "system": + first = dict(messages[0]) + first["content"] = f"{first.get('content') or ''}\n\n{note}" + return [first, *messages[1:]] + return [{"role": "system", "content": note}, *messages] + + +def _with_tools_note(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return _with_system_note(messages, tools_mod.TOOLS_NOTE) + + +# Once there are numbered results in the conversation. The one-line mention in TOOLS_NOTE is +# not enough for smaller models; said on its own, right before the answer, it is followed. +CITE_NOTE = ( + "The results you were given are numbered. Cite each fact you take from them as [n] at the " + "end of its sentence, using the number shown in the result; an answer may cite several." +) + + +def _round_messages(convo: List[Dict[str, Any]], *, offer: bool, book: "SourceBook") -> List[Dict[str, Any]]: + """What the model sees this round: the tools note while tools are offered, and the + citation note as soon as there is something to cite.""" + out = _with_tools_note(convo) if offer else convo + if book.items: + out = _with_system_note(out, CITE_NOTE) + return out + + +def _parse_args(call: Dict[str, Any]) -> Dict[str, Any]: + raw = (call.get("function") or {}).get("arguments") or "{}" + try: + args = json.loads(raw) if isinstance(raw, str) else raw + except json.JSONDecodeError: + args = {"input": raw} + return args if isinstance(args, dict) else {"input": args} + + +def _no_tools_note(model: Optional[str]) -> str: + return f"{model or 'This model'} cannot call tools, so it answered from its own knowledge." + + +ForcedTools = List[Tuple[str, Dict[str, Any]]] + + +def _unavailable_note(forced: ForcedTools) -> str: + names = ", ".join(n.replace("_", " ") for n, _ in forced) or "the tool" + return f"{names} is not available right now, so the answer is from the model's own knowledge." + + +def without_tool_roles(convo: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """The conversation for a model that cannot take tool messages: results already gathered + (a forced web search, say) become plain user turns, and the tool-call stubs go.""" + out: List[Dict[str, Any]] = [] + for m in convo: + role = m.get("role") + if role == "assistant" and m.get("tool_calls"): + if m.get("content"): + out.append({"role": "assistant", "content": m["content"]}) + continue + if role == "tool": + label = str(m.get("name") or "tool").replace("_", " ") + out.append({"role": "user", "content": f"Results from {label}:\n{m.get('content') or ''}"}) + continue + out.append(m) + return out + + +def _forced_calls(forced: Optional[ForcedTools], registry: tools_mod.ToolRegistry) -> List[Dict[str, Any]]: + """Calls the operator asks for before the model's first word (the chat's "Search the + web" switch), in the shape the model's own calls take; unknown tools are skipped.""" + calls: List[Dict[str, Any]] = [] + for i, (name, args) in enumerate(forced or [], 1): + if registry.spec(name) is None: + continue + calls.append({"id": f"forced_{i}", "type": "function", + "function": {"name": name, "arguments": json.dumps(args or {}, ensure_ascii=False)}}) + return calls + + +class SourceBook: + """Numbered, de-duplicated sources across a turn: the model cites [n], the UI links n.""" + + def __init__(self) -> None: + self.items: List[Dict[str, Any]] = [] + self._by_url: Dict[str, Dict[str, Any]] = {} + + def add(self, sources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Register new sources; returns only the ones not seen before.""" + new: List[Dict[str, Any]] = [] + for s in sources or []: + url = s.get("url") + if not url or url in self._by_url: + continue + item = {**{k: v for k, v in s.items() if k != "n"}, "n": len(self.items) + 1} + self.items.append(item) + self._by_url[url] = item + new.append(item) + return new + + def numbered(self, sources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [self._by_url[s["url"]] for s in sources or [] if s.get("url") in self._by_url] + + +def result_text(outcome: tools_mod.ToolOutcome, numbered: List[Dict[str, Any]]) -> str: + """What the model reads back: numbered sources for web-like results, else the tool's text.""" + if not outcome.ok: + return f"Tool error: {outcome.error or 'failed'}"[:EXPERT_TOOL_RESULT_MAX_CHARS] + if numbered: + parts = [] + for s in numbered: + block = f"[{s['n']}] {s.get('title') or s['url']}\n{s['url']}" + if s.get("snippet"): + block += f"\n{s['snippet']}" + parts.append(block) + text = "\n\n".join(parts) + else: + text = outcome.text + return (text or "(no result)")[:EXPERT_TOOL_RESULT_MAX_CHARS] + + +def _summary(outcome: tools_mod.ToolOutcome, numbered: List[Dict[str, Any]]) -> str: + if not outcome.ok: + return (outcome.error or "failed")[:120] + if numbered: + return f"{len(numbered)} result{'s' if len(numbered) != 1 else ''}" + return (outcome.text or "").strip().replace("\n", " ")[:120] + + +async def _execute_calls( + calls: List[Dict[str, Any]], + *, + registry: tools_mod.ToolRegistry, + book: SourceBook, + trace: List[Dict[str, Any]], + convo: List[Dict[str, Any]], +) -> AsyncIterator[Dict[str, Any]]: + """Run one round's tool calls: events out, results appended to ``convo`` as tool messages.""" + for call in calls[:EXPERT_TOOL_MAX_CALLS_PER_ROUND]: + name = str((call.get("function") or {}).get("name") or "") + args = _parse_args(call) + spec = registry.spec(name) + label = tools_mod.label_for(spec.native if spec else name, args) + yield {"type": "tool_start", "id": call.get("id"), "name": name, "label": label, "arguments": args} + outcome = await registry.call(name, args) + new = book.add(outcome.sources) + numbered = book.numbered(outcome.sources) + trace.append({ + "id": call.get("id"), "name": name, "label": label, "arguments": args, + "ok": outcome.ok, "elapsed_ms": outcome.elapsed_ms, + "sources": [s["n"] for s in numbered], "error": outcome.error, + }) + yield { + "type": "tool_end", "id": call.get("id"), "name": name, "label": label, + "ok": outcome.ok, "summary": _summary(outcome, numbered), "elapsed_ms": outcome.elapsed_ms, + } + if new: + yield {"type": "sources", "sources": list(book.items)} + convo.append({"role": "tool", "tool_call_id": call.get("id"), "name": name, "content": result_text(outcome, numbered)}) + for call in calls[EXPERT_TOOL_MAX_CALLS_PER_ROUND:]: + name = str((call.get("function") or {}).get("name") or "") + convo.append({"role": "tool", "tool_call_id": call.get("id"), "name": name, + "content": "Skipped: too many tool calls in one round; answer with what you have."}) + + +async def stream_with_tools( + messages: List[Dict[str, Any]], + provider: ProviderName, + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + registry: Optional[tools_mod.ToolRegistry] = None, + max_rounds: Optional[int] = None, + enabled: Optional[bool] = None, + stream: Optional[Callable[..., AsyncIterator[Dict[str, Any]]]] = None, + forced: Optional[ForcedTools] = None, + assist: Optional[ForcedTools] = None, +) -> AsyncIterator[Dict[str, Any]]: + """Run one turn with tools, streaming. Events: + ``token`` {content} · ``reset`` (the text so far was a preamble, not the answer) · + ``tool_start`` {id, name, label, arguments} · ``tool_end`` {id, name, label, ok, summary, elapsed_ms} · + ``sources`` {sources} · ``notice`` {text} · + ``done`` {content, tool_trace, sources, notices, rounds, fallback_applied}. + ``stream`` is the provider event stream to use (``stream_provider_events`` by default — + pipelines pass their own module's name so the usual seam, and its fakes in tests, apply). + ``forced`` tools run first, before the model is asked anything. ``assist`` tools run + instead, only for a model that cannot call tools itself: HomePilot does the search and + hands the model the results (the research modes stay grounded on such models). + """ + stream = stream or stream_provider_events + registry = registry or tools_mod.registry() + use_tools = EXPERT_TOOLS_ENABLED if enabled is None else enabled + max_rounds = EXPERT_TOOL_MAX_ROUNDS if max_rounds is None else max_rounds + tools = (await registry.openai_tools()) if use_tools else [] + key = _model_key(provider, model) + book = SourceBook() + trace: List[Dict[str, Any]] = [] + notices: List[str] = [] + convo = list(messages) + answer: List[str] = [] + rounds = 0 + fallback_applied = False + + first = _forced_calls(forced, registry) if tools else [] + if not first and assist and tools and tools_known(key) is False: + first = _forced_calls(assist, registry) + if first: + rounds += 1 + convo.append({"role": "assistant", "content": "", "tool_calls": first}) + async for ev in _execute_calls(first, registry=registry, book=book, trace=trace, convo=convo): + yield ev + elif forced: + notices.append(_unavailable_note(forced)) + yield {"type": "notice", "text": notices[-1]} + + if tools_known(key) is False: + convo = without_tool_roles(convo) + + while True: + offer = bool(tools) and rounds < max_rounds and tools_known(key) is not False + round_text: List[str] = [] + calls: List[Dict[str, Any]] = [] + thinking = ThinkFilter() + try: + async for ev in stream( + _round_messages(convo, offer=offer, book=book), provider, + tools=tools if offer else None, model=model, temperature=temperature, max_tokens=max_tokens, + ): + if "delta" in ev: + clean = thinking.feed(ev["delta"]) + if clean: + round_text.append(clean) + yield {"type": "token", "content": clean} + elif ev.get("tool_calls") and offer: + calls = list(ev["tool_calls"]) + elif ev.get("wait"): + # In line, or waiting out a rate limit: transient, shown in place of a pause. + yield {"type": "wait", **ev["wait"]} + elif ev.get("notice"): + fallback_applied = fallback_applied or bool(ev.get("fallback")) + if ev["notice"] not in notices: + notices.append(ev["notice"]) + yield {"type": "notice", "text": ev["notice"]} + except ToolsUnsupported: + # A capability fact, not news for the reader: logged and remembered, the answer + # simply continues without tools (the UI stays clean; the trace is unchanged). + remember_tools(key, False) + logger.info("tools: %s rejected the tools field; answering without tools from now on (%s)", key, _no_tools_note(model)) + if round_text: + yield {"type": "reset"} + if assist and not first and tools: + # The model cannot search; HomePilot searches for it before asking again. + first = _forced_calls(assist, registry) + if first: + rounds += 1 + convo.append({"role": "assistant", "content": "", "tool_calls": first}) + async for ev in _execute_calls(first, registry=registry, book=book, trace=trace, convo=convo): + yield ev + convo = without_tool_roles(convo) + continue + tail = thinking.flush() + if tail: + round_text.append(tail) + yield {"type": "token", "content": tail} + if not calls: + answer = round_text + break + + if tools_known(key) is not True: + remember_tools(key, True) + rounds += 1 + if round_text: + yield {"type": "reset"} # whatever came before the calls is not the answer + convo.append({"role": "assistant", "content": "".join(round_text), "tool_calls": calls}) + async for ev in _execute_calls(calls, registry=registry, book=book, trace=trace, convo=convo): + yield ev + + yield { + "type": "done", "content": "".join(answer), "tool_trace": trace, + "sources": list(book.items), "notices": notices, "rounds": rounds, + "fallback_applied": fallback_applied, + } + + +async def run_with_tools( + messages: List[Dict[str, Any]], + provider: ProviderName, + *, + model: Optional[str] = None, + temperature: float = EXPERT_TEMPERATURE, + max_tokens: int = EXPERT_MAX_TOKENS, + registry: Optional[tools_mod.ToolRegistry] = None, + max_rounds: Optional[int] = None, + enabled: Optional[bool] = None, + call: Optional[DispatchFn] = None, + forced: Optional[ForcedTools] = None, + assist: Optional[ForcedTools] = None, +) -> Dict[str, Any]: + """The non-streaming turn, through ``call`` (``dispatch`` by default — callers pass their + own module's ``dispatch`` so the usual seam, and its fakes in tests, still apply). + Returns the ``done`` payload of :func:`stream_with_tools` plus ``provider`` and ``model``.""" + call = call or dispatch + registry = registry or tools_mod.registry() + use_tools = EXPERT_TOOLS_ENABLED if enabled is None else enabled + max_rounds = EXPERT_TOOL_MAX_ROUNDS if max_rounds is None else max_rounds + tools = (await registry.openai_tools()) if use_tools else [] + key = _model_key(provider, model) + book = SourceBook() + trace: List[Dict[str, Any]] = [] + notices: List[str] = [] + convo = list(messages) + rounds = 0 + fallback_applied = False + content = "" + raw: Dict[str, Any] = {} + + first = _forced_calls(forced, registry) if tools else [] + if not first and assist and tools and tools_known(key) is False: + first = _forced_calls(assist, registry) + if first: + rounds += 1 + convo.append({"role": "assistant", "content": "", "tool_calls": first}) + async for _ in _execute_calls(first, registry=registry, book=book, trace=trace, convo=convo): + pass + elif forced: + notices.append(_unavailable_note(forced)) + + if tools_known(key) is False: + convo = without_tool_roles(convo) + + while True: + offer = bool(tools) and rounds < max_rounds and tools_known(key) is not False + try: + raw = await call( + _round_messages(convo, offer=offer, book=book), provider, + model=model, temperature=temperature, max_tokens=max_tokens, tools=tools if offer else None, + ) + except ToolsUnsupported: + remember_tools(key, False) + logger.info("tools: %s rejected the tools field; answering without tools from now on", key) + if assist and not first and tools: + first = _forced_calls(assist, registry) + if first: + rounds += 1 + convo.append({"role": "assistant", "content": "", "tool_calls": first}) + async for _ in _execute_calls(first, registry=registry, book=book, trace=trace, convo=convo): + pass + convo = without_tool_roles(convo) + continue + raw = raw if isinstance(raw, dict) else {} + meta = extract_dispatch_meta(raw) + fallback_applied = fallback_applied or bool(meta.get("fallback_applied")) + if isinstance(meta.get("notice"), str) and meta["notice"] and meta["notice"] not in notices: + notices.append(meta["notice"]) + message = ((raw.get("choices") or [{}])[0] or {}).get("message") or {} + calls = list(message.get("tool_calls") or []) if offer else [] + if not calls: + content = strip_think(message.get("content") or "") + break + if tools_known(key) is not True: + remember_tools(key, True) + rounds += 1 + convo.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": calls}) + async for _ in _execute_calls(calls, registry=registry, book=book, trace=trace, convo=convo): + pass + + return { + "content": content, "tool_trace": trace, "sources": list(book.items), "notices": notices, + "rounds": rounds, "fallback_applied": fallback_applied, + "provider": raw.get("provider", provider), "model": raw.get("model", model), + } diff --git a/expert/backend/app/expert/tools.py b/expert/backend/app/expert/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..2787e92e41670c51651fcab06ce9ab362dbfdd92 --- /dev/null +++ b/expert/backend/app/expert/tools.py @@ -0,0 +1,296 @@ +"""Per-turn tools for the Expert pipelines — the other half of a ChatGPT-style turn. + +HomePilot's own MCP servers (web search, knowledge, personal notes) speak JSON-RPC +``tools/list`` / ``tools/call`` on ``/rpc``. The registry discovers a curated set of their +tools, offers them to the model in OpenAI's function-calling shape, runs the calls the model +makes, and turns each result into text the model can read plus sources the UI can show. +Context Forge's ``/rpc`` speaks the same protocol, so federated tools work by adding its URL +to ``EXPERT_TOOL_SERVERS`` (its REST-registered copies of HomePilot's servers do not forward +``tools/call``, which is why the servers are called directly by default). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional +from urllib.parse import urlparse + +import httpx + +from .config import ( + EXPERT_TOOLS_ENABLED, + EXPERT_TOOL_ALLOWLIST, + EXPERT_TOOL_SERVERS, + EXPERT_TOOL_TIMEOUT_S, +) + +logger = logging.getLogger("expert.tools") + +_DISCOVERY_TTL_S = 60.0 + +# Appended to the system prompt whenever tools are offered. +TOOLS_NOTE = ( + "You can call tools. Use them when the answer depends on current events, specific facts, " + "URLs, or the user's own documents and notes; answer directly when you already know. " + "After a web search, cite the results you used as [n], with the numbers given in the results." +) + + +@dataclass(frozen=True) +class ToolSpec: + name: str # what the model sees (OpenAI-safe: letters, digits, _ and -) + native: str # what the server knows + description: str + parameters: Dict[str, Any] + server: str # the /rpc URL + + def as_openai(self) -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": self.name, + "description": (self.description or self.native)[:1024], + "parameters": self.parameters or {"type": "object", "properties": {}}, + }, + } + + +@dataclass +class ToolOutcome: + name: str + ok: bool + text: str = "" + data: Any = None + sources: List[Dict[str, Any]] = field(default_factory=list) + error: str = "" + elapsed_ms: int = 0 + + +def sanitize_name(native: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]", "_", native)[:64] or "tool" + + +def _domain(url: str) -> str: + try: + return urlparse(url).netloc.replace("www.", "") + except Exception: # noqa: BLE001 + return "" + + +def label_for(native: str, arguments: Dict[str, Any]) -> str: + """What the chip says while a tool runs.""" + q = str(arguments.get("query") or arguments.get("question") or "").strip() + quoted = f" for “{q[:80]}”" if q else "" + n = native.lower() + if "web" in n and "search" in n: + return f"Searching the web{quoted}" + if "fetch" in n: + return f"Reading {_domain(str(arguments.get('url') or '')) or 'a page'}" + if "project" in n: + return f"Searching the project's documents{quoted}" + if "document" in n and not q: + return f"Opening document {arguments.get('doc_id', '')}".strip() + if "workspace" in n or "document" in n or "sources" in n: + return f"Searching your workspace{quoted}" + if "personal" in n or "memory" in n or "notes" in n: + return f"Searching your notes{quoted}" + return f"Running {native}" + + +async def rpc(url: str, method: str, params: Optional[Dict[str, Any]], timeout_s: float) -> Any: + """One JSON-RPC 2.0 call; the result, or RuntimeError with the server's message.""" + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_s, connect=5.0)) as client: + r = await client.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}) + r.raise_for_status() + body = r.json() + if isinstance(body, dict) and body.get("error"): + err = body["error"] + raise RuntimeError(str(err.get("message") if isinstance(err, dict) else err)) + return body.get("result") if isinstance(body, dict) else body + + +def _sources(data: Any) -> List[Dict[str, Any]]: + """Web-like results in a tool's structured content → source entries (url, title, snippet).""" + items = None + if isinstance(data, dict): + for key in ("results", "sources", "items", "hits"): + if isinstance(data.get(key), list): + items = data[key] + break + out: List[Dict[str, Any]] = [] + for it in items or []: + if not isinstance(it, dict): + continue + url = str(it.get("url") or it.get("link") or "").strip() + if not url.startswith(("http://", "https://")): + continue + entry: Dict[str, Any] = { + "title": str(it.get("title") or url)[:200], + "url": url, + "domain": _domain(url), + "snippet": str(it.get("snippet") or it.get("content") or "")[:300], + } + if it.get("published"): + entry["published"] = str(it["published"]) + out.append(entry) + return out + + +def outcome_from(name: str, result: Any, elapsed_ms: int = 0) -> ToolOutcome: + """An MCP ``tools/call`` result → text for the model, data and sources for the UI.""" + result = result if isinstance(result, dict) else {"content": [{"type": "text", "text": str(result)}]} + content = result.get("content") or [] + text = "\n".join( + str(c.get("text") or "") for c in content if isinstance(c, dict) and c.get("type", "text") == "text" + ).strip() + data = result.get("structuredContent") + if not text and data is not None: + text = json.dumps(data, ensure_ascii=False) + is_error = bool(result.get("isError")) + return ToolOutcome( + name=name, ok=not is_error, text=text, data=data, sources=_sources(data), + error=text if is_error else "", elapsed_ms=elapsed_ms, + ) + + +class ToolRegistry: + """The curated tools: discovered from the servers, cached briefly, called by name.""" + + def __init__( + self, + servers: Optional[List[str]] = None, + allow: Optional[str] = None, + timeout_s: Optional[float] = None, + ) -> None: + raw_servers = servers if servers is not None else EXPERT_TOOL_SERVERS.split(",") + self.servers = [s.strip().rstrip("/") for s in raw_servers if s and s.strip()] + allow_raw = EXPERT_TOOL_ALLOWLIST if allow is None else allow + self.allow = {a.strip() for a in allow_raw.split(",") if a.strip()} + self.timeout_s = float(timeout_s or EXPERT_TOOL_TIMEOUT_S) + self._specs: Dict[str, ToolSpec] = {} + self._discovered_at = 0.0 + self._lock = asyncio.Lock() + + def _allowed(self, native: str) -> bool: + return "*" in self.allow or native in self.allow + + def _fresh(self) -> bool: + return bool(self._specs) and (time.monotonic() - self._discovered_at) < _DISCOVERY_TTL_S + + async def _list(self, url: str) -> List[Dict[str, Any]]: + result = await rpc(url, "tools/list", {}, min(self.timeout_s, 8.0)) + return list((result or {}).get("tools") or []) if isinstance(result, dict) else [] + + async def discover(self, force: bool = False) -> List[ToolSpec]: + if not force and self._fresh(): + return list(self._specs.values()) + async with self._lock: + if not force and self._fresh(): + return list(self._specs.values()) + specs: Dict[str, ToolSpec] = {} + listed = await asyncio.gather(*(self._list(url) for url in self.servers), return_exceptions=True) + for url, tools in zip(self.servers, listed): + if isinstance(tools, BaseException): + logger.debug("tools: %s not listed (%s)", url, tools) + continue + for t in tools: + native = str(t.get("name") or "") + if not native or not self._allowed(native): + continue + name = sanitize_name(native) + if name in specs: + continue + specs[name] = ToolSpec( + name=name, native=native, + description=str(t.get("description") or native), + parameters=t.get("inputSchema") or {"type": "object", "properties": {}}, + server=url, + ) + # A server that is down keeps its last known tools for the TTL rather than vanishing. + if specs or not self._specs: + self._specs = specs + self._discovered_at = time.monotonic() + return list(self._specs.values()) + + async def openai_tools(self) -> List[Dict[str, Any]]: + return [s.as_openai() for s in await self.discover()] + + def spec(self, name: str) -> Optional[ToolSpec]: + return self._specs.get(name) + + async def call(self, name: str, arguments: Optional[Dict[str, Any]]) -> ToolOutcome: + spec = self._specs.get(name) + if spec is None: + await self.discover(force=True) + spec = self._specs.get(name) + if spec is None: + return ToolOutcome(name=name, ok=False, error=f"Unknown tool {name}") + started = time.monotonic() + try: + result = await rpc(spec.server, "tools/call", {"name": spec.native, "arguments": arguments or {}}, self.timeout_s) + except Exception as exc: # noqa: BLE001 + elapsed = int((time.monotonic() - started) * 1000) + logger.warning("tool %s failed: %s", spec.native, exc) + return ToolOutcome(name=name, ok=False, error=f"{type(exc).__name__}: {exc}", elapsed_ms=elapsed) + return outcome_from(name, result, int((time.monotonic() - started) * 1000)) + + +@dataclass +class LocalTool: + """A tool that exists for one request and runs in-process — a project's documents.""" + spec: ToolSpec + handler: Callable[[Dict[str, Any]], Awaitable[ToolOutcome]] + + +class RequestTools: + """The shared registry plus a request's own tools, behind the same four calls the loop + makes (discover, openai_tools, spec, call).""" + + def __init__(self, base: ToolRegistry, local: Optional[List[LocalTool]] = None) -> None: + self.base = base + self.local: Dict[str, LocalTool] = {t.spec.name: t for t in (local or [])} + + async def discover(self, force: bool = False) -> List[ToolSpec]: + shared = await self.base.discover(force) + return [*[s for s in shared if s.name not in self.local], *(t.spec for t in self.local.values())] + + async def openai_tools(self) -> List[Dict[str, Any]]: + return [s.as_openai() for s in await self.discover()] + + def spec(self, name: str) -> Optional[ToolSpec]: + if name in self.local: + return self.local[name].spec + return self.base.spec(name) + + async def call(self, name: str, arguments: Optional[Dict[str, Any]]) -> ToolOutcome: + tool = self.local.get(name) + if tool is None: + return await self.base.call(name, arguments) + started = time.monotonic() + try: + out = await tool.handler(arguments or {}) + except Exception as exc: # noqa: BLE001 + logger.warning("local tool %s failed: %s", name, exc) + return ToolOutcome(name=name, ok=False, error=f"{type(exc).__name__}: {exc}", + elapsed_ms=int((time.monotonic() - started) * 1000)) + if not out.elapsed_ms: + out.elapsed_ms = int((time.monotonic() - started) * 1000) + return out + + +_REGISTRY: Optional[ToolRegistry] = None + + +def registry() -> ToolRegistry: + global _REGISTRY + if _REGISTRY is None: + _REGISTRY = ToolRegistry() + return _REGISTRY + + +def enabled() -> bool: + return EXPERT_TOOLS_ENABLED diff --git a/frontend/.cache-bust b/frontend/.cache-bust index 920ce8246ed09c1fd8356607a4affde4a520a8d6..902385d2ac66eed3f3f83338bc9e4b0ba1a433a0 100644 --- a/frontend/.cache-bust +++ b/frontend/.cache-bust @@ -1 +1 @@ -1792514112 +1792659663 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 70f67eb8f87c283a12e3fb2d15697c04bf944950..4c1b3e8868df58bf78fe9bd9ecd33d86c932cd00 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,16 @@ "dependencies": { "axios": "^1.7.2", "html-to-image": "^1.11.13", + "katex": "^0.16.11", "lucide-react": "^0.457.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-easy-crop": "6.2.3", "react-markdown": "^9.0.1", "react-router-dom": "^6.22.0", + "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", "zustand": "^4.5.2" }, "devDependencies": { @@ -1655,6 +1658,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2433,7 +2442,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2829,6 +2837,113 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -2856,6 +2971,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -2869,6 +3000,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3150,6 +3298,31 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.16.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", + "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3391,6 +3564,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -3727,6 +3919,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -4704,6 +4915,25 @@ "node": ">=8" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -4722,6 +4952,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -5314,6 +5560,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -5340,6 +5600,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -5443,6 +5717,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -6174,6 +6462,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7faaccff143e07e7b343a68a64660f3cb5ecef0b..5de4b08d39069d07d12dadeb12258d6964e4a77d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,13 +17,16 @@ "dependencies": { "axios": "^1.7.2", "html-to-image": "^1.11.13", + "katex": "^0.16.11", "lucide-react": "^0.457.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-easy-crop": "6.2.3", "react-markdown": "^9.0.1", "react-router-dom": "^6.22.0", + "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/frontend/src/test/backendUrlMovedPort.test.ts b/frontend/src/test/backendUrlMovedPort.test.ts index 0bf069509e4859253a14506f3738655e48d159ed..60740e0eecd9ee0f932584cbe651e10de5455641 100644 --- a/frontend/src/test/backendUrlMovedPort.test.ts +++ b/frontend/src/test/backendUrlMovedPort.test.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { followMovedDevBackend, getDefaultBackendUrl, resolveBackendUrl } from '../ui/lib/backendUrl' +import { followMovedDevBackend, getDefaultBackendUrl, resolveBackendUrl, saveBackendUrl } from '../ui/lib/backendUrl' afterEach(() => { vi.unstubAllEnvs() @@ -45,3 +45,39 @@ describe('backend that make start moved to another port', () => { expect(followMovedDevBackend('http://localhost:8000')).toBe('http://localhost:8000') }) }) + +describe('a saved address from an earlier start', () => { + it('follows the backend back to :8000 when the app saved it automatically', () => { + vi.stubEnv('VITE_API_URL', '') + // An earlier `make start` put the backend on :8002 and the app saved that address. + localStorage.setItem('homepilot_backend_url', 'http://localhost:8002') + localStorage.setItem('homepilot_backend_url_origin', 'auto') + expect(resolveBackendUrl()).toBe('http://localhost:8000') + }) + + it('keeps an unmarked address from before the marker existed', () => { + vi.stubEnv('VITE_API_URL', '') + localStorage.setItem('homepilot_backend_url', 'http://localhost:8002/') + expect(resolveBackendUrl()).toBe('http://localhost:8002') + localStorage.setItem('homepilot_backend_url', 'http://localhost:9000') + expect(resolveBackendUrl()).toBe('http://localhost:9000') + }) + + it('keeps an address the user chose in Settings', () => { + vi.stubEnv('VITE_API_URL', '') + saveBackendUrl('http://localhost:8002', true) + expect(localStorage.getItem('homepilot_backend_url_origin')).toBe('user') + expect(resolveBackendUrl()).toBe('http://localhost:8002') + // The app re-saving it afterwards does not turn the choice into a default. + saveBackendUrl('http://localhost:8002', false) + expect(resolveBackendUrl()).toBe('http://localhost:8002') + }) + + it('marks the default as automatic, so it keeps following make start', () => { + vi.stubEnv('VITE_API_URL', 'http://localhost:8002') + saveBackendUrl('http://localhost:8002', true) + expect(localStorage.getItem('homepilot_backend_url_origin')).toBe('auto') + vi.stubEnv('VITE_API_URL', '') + expect(resolveBackendUrl()).toBe('http://localhost:8000') + }) +}) diff --git a/frontend/src/test/citations.test.tsx b/frontend/src/test/citations.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..428002d997efe9f15175bd75acdf7ac9e2e04952 --- /dev/null +++ b/frontend/src/test/citations.test.tsx @@ -0,0 +1,49 @@ +/** + * Citations as ChatGPT shows them: whatever marker the model writes (`[n]`, `【n†L1-L2】`, + * `[2, 3]`) becomes a small numbered pill linked to its source, and the sources row leads with + * the sources the answer actually cites. + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { MessageMarkdown } from '../ui/components/MessageMarkdown' +import { WebSources, citedNumbers, linkCitations, normalizeCitations, orderSources } from '../ui/components/WebSources' + +const sources = [ + { n: 1, title: 'EU energy prices', url: 'https://reuters.example/energy', domain: 'reuters.example' }, + { n: 6, title: 'Italy - Wikipedia', url: 'https://en.wikipedia.org/wiki/Italy', domain: 'en.wikipedia.org' }, + { n: 11, title: 'Rome - Wikipedia', url: 'https://en.wikipedia.org/wiki/Rome', domain: 'en.wikipedia.org' }, +] as any + +describe('normalizeCitations', () => { + it('turns browsing-style and grouped markers into [n]', () => { + expect(normalizeCitations('Rome【6†L1-L2】【11†L1-L2】.')).toBe('Rome[6][11].') + expect(normalizeCitations('See 【3】 and [4†source].')).toBe('See[3] and [4].') + expect(normalizeCitations('Mexico City [2, 3].')).toBe('Mexico City [2][3].') + expect(normalizeCitations('code `【6†L1】` stays')).toBe('code `【6†L1】` stays') + expect(normalizeCitations('a link [[6]](https://x) stays')).toBe('a link [[6]](https://x) stays') + }) + + it('finds what an answer cites, in order', () => { + expect(citedNumbers('Rome【6†L1-L2】 on the Tiber [11]; again [6].')).toEqual([6, 11]) + }) +}) + +describe('citations in the chat', () => { + it('renders each citation as a numbered pill linked to its source', () => { + render() + const pills = screen.getAllByTestId('citation') + expect(pills.map((p) => p.textContent)).toEqual(['6', '11']) + expect(pills[0].getAttribute('href')).toBe('https://en.wikipedia.org/wiki/Italy') + expect(document.body.textContent).not.toContain('†') + }) + + it('shows the cited sources first', () => { + expect(orderSources(sources, [11, 6]).map((s: any) => s.n)).toEqual([11, 6, 1]) + render() + const chips = screen.getAllByTestId('web-source') + expect(chips[0].textContent).toContain('Italy - Wikipedia') + expect(chips[1].textContent).toContain('Rome - Wikipedia') + }) +}) diff --git a/frontend/src/test/composerPaste.test.tsx b/frontend/src/test/composerPaste.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f74bc7c4adc5d1bad589bb08e689862f94b734f --- /dev/null +++ b/frontend/src/test/composerPaste.test.tsx @@ -0,0 +1,48 @@ +/** + * A long paste becomes a chip above the composer (as in ChatGPT and Claude) instead of a wall + * of text that stretches the field over the conversation; compaction has its own indicator. + */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { CompactingIndicator, PastedChips, isLongPaste, withPastes } from '../ui/components/ComposerExtras' + +describe('long pastes', () => { + it('keeps short pastes in the field and turns long ones into a chip', () => { + expect(isLongPaste('a short sentence')).toBe(false) + expect(isLongPaste('x'.repeat(1199))).toBe(false) + expect(isLongPaste('x'.repeat(1200))).toBe(true) + expect(isLongPaste(Array.from({ length: 15 }, (_, i) => `line ${i}`).join('\n'))).toBe(true) + }) + + it('sends what was typed, then each paste', () => { + expect(withPastes(' Summarize this: ', ['A long document'])).toBe('Summarize this:\n\nA long document') + expect(withPastes('', ['only the paste'])).toBe('only the paste') + }) + + it('shows a chip that opens to the text and can be removed', () => { + const paste = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n') + const onOpen = vi.fn() + const onRemove = vi.fn() + const { rerender } = render() + expect(screen.getByText('Pasted · 20 lines')).toBeTruthy() + expect(screen.queryByTestId('composer-paste-preview')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Pasted text, 20 lines/ })) + expect(onOpen).toHaveBeenCalledWith(0) + rerender() + expect(screen.getByTestId('composer-paste-preview').textContent).toContain('line 19') + fireEvent.click(screen.getByRole('button', { name: 'Remove pasted text' })) + expect(onRemove).toHaveBeenCalledWith(0) + }) +}) + +describe('CompactingIndicator', () => { + it('says what is happening, politely, with the condensing lines', () => { + render() + const el = screen.getByTestId('compacting') + expect(el.getAttribute('role')).toBe('status') + expect(el.textContent).toContain('summarizing 8 earlier messages') + expect(el.querySelectorAll('.hp-compact-lines > span').length).toBe(3) + }) +}) diff --git a/frontend/src/test/expertRoute.test.ts b/frontend/src/test/expertRoute.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..50c12d5637a6e2888ff8d3052bcdfbc8fed57c4c --- /dev/null +++ b/frontend/src/test/expertRoute.test.ts @@ -0,0 +1,22 @@ +/** + * The chat's Fast / Expert / Heavy path falls back to the standard chat when this HomePilot + * has no Expert at all (lib/expertRoute.ts) — the Space answered every "Hello" with HTTP 405. + */ +import { describe, expect, it } from 'vitest' + +import { isMissingRoute } from '../ui/lib/expertRoute' + +describe('a missing Expert route', () => { + it('is a 404, or the 405 a GET-only catch-all gives a POST', () => { + expect(isMissingRoute(new Error('HTTP 405 Method Not Allowed: {"detail":"Method Not Allowed"}'))).toBe(true) + expect(isMissingRoute(new Error('HTTP 404 Not Found: {"detail":"Not Found","path":"/v1/expert/chat"}'))).toBe(true) + }) + + it('is not an Expert error, a network failure or anything else', () => { + expect(isMissingRoute(new Error('HTTP 500 Internal Server Error: no local model'))).toBe(false) + expect(isMissingRoute(new Error('HTTP 401 Unauthorized'))).toBe(false) + expect(isMissingRoute(new TypeError('Failed to fetch'))).toBe(false) + expect(isMissingRoute('HTTP 4040')).toBe(false) + expect(isMissingRoute(null)).toBe(false) + }) +}) diff --git a/frontend/src/test/expertSteps.test.tsx b/frontend/src/test/expertSteps.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2edddd83d386b066f78c09039a513cbddd44f4e --- /dev/null +++ b/frontend/src/test/expertSteps.test.tsx @@ -0,0 +1,132 @@ +/** + * The card above an Expert answer: the running stage while it works (named once), a quiet + * summary once done (collapsed, with the verdict), the stages, chips, verdict detail and + * notices when opened, and the Deep research plan with each item's state. + */ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { ExpertSteps, hasLimitations, stageCopy, stripParts, stripTitle } from '../ui/components/ExpertSteps' +import type { ExpertTrace } from '../ui/lib/expertStream' + +const base: ExpertTrace = { mode: 'heavy', model: 'openwebui/internal-gemma4-31b', steps: [], tools: [], sources: [], notices: [], verdict: null } + +describe('stripParts', () => { + it('names the running stage once while pending, then summarizes', () => { + const running: ExpertTrace = { ...base, mode: 'think', steps: [ + { step: 'analyze', label: '🔍 Analyzing problem…', role: 'work', status: 'done', content: '' }, + { step: 'plan', label: '📋 Building plan…', role: 'work', status: 'running', content: '' }, + { step: 'solve', label: '⚡ Solving…', role: 'answer', status: 'running', content: '' }, + ] } + expect(stripParts(running, true, false)).toEqual({ title: 'Planning the approach', sub: 'Expert · step 2 of 3' }) + expect(stripParts(running, true, true).title).toBe('Expert · working') // open: the list shows the stage + const done: ExpertTrace = { ...running, elapsed_s: 3.8, steps: running.steps.map((s) => ({ ...s, status: 'done' as const })) } + expect(stripParts(done)).toEqual({ title: 'Reasoning completed', sub: 'Expert · 3 steps' }) + const heavy: ExpertTrace = { ...done, mode: 'heavy', verdict: { verdict: 'validated', note: 'ok' } } + expect(stripTitle(heavy)).toBe('Validated response') + expect(stripTitle({ ...heavy, verdict: { verdict: 'needs_review', note: '' } })).toBe('Needs review') + expect(stripParts({ ...base, tools: [{ id: 'c1', name: 'hp_web_search', label: 'Searching the web for “x”', status: 'running' }] }, true).title) + .toBe('Searching the web for “x”') + expect(stripParts({ ...base, tools: [{ id: 'c1', name: 'hp_web_search', label: 'Searching the web', status: 'done' }] }).title) + .toBe('Used 1 tool') + const deep: ExpertTrace = { ...base, mode: 'deep', status: 'researching', plan: [ + { id: 'q1', title: 'A', status: 'done' }, { id: 'q2', title: 'B', status: 'running' }, { id: 'q3', title: 'C', status: 'pending' }, + ] } + expect(stripParts(deep, true).title).toBe('Researching · 1 of 3 questions') + expect(stripParts({ ...deep, status: 'done', elapsed_s: 26.5 })).toEqual({ title: 'Research complete', sub: 'Deep research · 1 of 3 questions' }) + expect(stageCopy({ step: 'custom', label: '🧪 Testing things…' })).toEqual({ doing: 'Testing things…', done: 'Testing things', detail: '' }) + }) +}) + +describe('ExpertSteps', () => { + it('renders nothing when there is nothing to show', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('while pending shows the running tool as the title and a single live indicator', () => { + render() + expect(screen.getByTestId('expert-title').textContent).toBe('Searching the web for “X”') + expect(screen.getByText('Live')).toBeTruthy() + expect(screen.queryByText('Investigating')).toBeNull() // not repeated under the head + }) + + it('keeps the validator’s raw JSON out of the card once its verdict is shown, and hides "Not verified: none"', () => { + render() + const reasoning = screen.getAllByTestId('expert-reasoning').map((n) => n.textContent) + expect(reasoning).toEqual(['Mexico City is the capital.']) + expect(screen.queryByText(/Not verified/)).toBeNull() + expect(hasLimitations('no source for the date')).toBe(true) + expect(hasLimitations('None.')).toBe(false) + }) + + it('shows a stage’s reasoning inline only when asked; otherwise it sits behind "Show output"', () => { + const trace: ExpertTrace = { ...base, mode: 'think', steps: [ + { step: 'analyze', label: 'Analyzing', role: 'work', status: 'running', content: 'The question asks for X.' }, + ] } + const first = render() + expect(screen.getByTestId('expert-reasoning').textContent).toBe('The question asks for X.') + expect(screen.queryByText('Show output')).toBeNull() + first.unmount() + render() + expect(screen.queryByTestId('expert-reasoning')).toBeNull() + expect(screen.getByText('Show output')).toBeTruthy() + }) + + it('collapses to a summary with the verdict once done, and opens into detail', () => { + render() + const toggle = screen.getByRole('button', { expanded: false }) + expect(screen.getByTestId('expert-title').textContent).toBe('Needs review') + expect(toggle.textContent).toContain('12.1s') + expect(toggle.textContent).toContain('internal-gemma4-31b') // the provider prefix is dropped + expect(toggle.textContent).not.toContain('openwebui/') + expect(screen.queryByTestId('expert-notices')).toBeNull() + fireEvent.click(toggle) + expect(screen.getByTestId('expert-project').textContent).toContain('Using project Launch') + expect(screen.getByText('Investigated')).toBeTruthy() + expect(screen.getByText('Drafted the answer')).toBeTruthy() + expect(screen.getByTestId('expert-verdict-detail').textContent).toContain('The date may be wrong') + expect(screen.getByTestId('expert-verdict-detail').textContent).toContain('Not verified: no source for the date') + expect(screen.getByTestId('expert-notices').textContent).toContain('strongest model') + expect(screen.getByText('3 results', { exact: false })).toBeTruthy() + expect(screen.getByText('Show output')).toBeTruthy() // a work stage's output stays behind a disclosure + }) + + it('shows a Deep research plan with each item state', () => { + render() + expect(screen.getByTestId('expert-title').textContent).toBe('Deep research · 1 of 3 questions') + const plan = screen.getByTestId('expert-plan') + expect(plan.textContent).toContain('X is… [1]') + expect(plan.querySelector('.line-through')?.textContent).toBe('Price?') + }) +}) diff --git a/frontend/src/test/expertStream.test.ts b/frontend/src/test/expertStream.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..081f1724d19921e7b349d9da2f0eee4c4a387b54 --- /dev/null +++ b/frontend/src/test/expertStream.test.ts @@ -0,0 +1,194 @@ +/** + * The Expert stream, read event by event: tokens to the active step or the answer, tool + * chips that start and finish, numbered sources, the verdict, the final answer, and a Deep + * research job's plan. + */ +import { describe, expect, it } from 'vitest' + +import { + applyDeepDone, + applyExpertEvent, + initialExpertState, + parseExpertSse, + sourcesAsWeb, + traceHasWork, + type ExpertEvent, + type ExpertStreamState, +} from '../ui/lib/expertStream' +import { deepGateFrom, deepOptionState, UNKNOWN_GATE } from '../ui/lib/expertCapabilities' +import { isTrivialPrompt } from '../ui/lib/promptHeuristics' +import { COMPACTED_NOTE } from '../ui/lib/expertStream' + +function run(events: ExpertEvent[], mode?: string): ExpertStreamState { + return events.reduce((s, ev) => applyExpertEvent(s, ev), initialExpertState(mode)) +} + +describe('the SSE parser', () => { + it('reads named events, plain tokens with escaped newlines, keep-alives and [DONE]', () => { + const wire = + 'event: meta\ndata: {"thinking_mode":"think","model":"qwen3:32b","notices":[]}\n\n' + + 'event: step\ndata: {"step":"analyze","label":"🔍 Analyzing problem…","role":"work"}\n\n' + + 'data: line one\\nline two\n\n' + + ': keep-alive\n\n' + + 'event: tool\ndata: {"phase":"start","id":"c1","name":"hp_web_search","label":"Searching the web for “x”"}\n\n' + + 'data: [DONE]\n\n' + + 'event: step\ndata: {"step":"incompl' + const { events, rest } = parseExpertSse(wire) + expect(events.map((e) => e.type)).toEqual(['meta', 'step', 'token', 'tool', 'end']) + expect((events[2] as any).text).toBe('line one\nline two') + expect(rest.startsWith('event: step')).toBe(true) // the partial block waits + expect(parseExpertSse('event: bogus\ndata: {"a":1}\n\n').events).toEqual([]) + expect(parseExpertSse('event: sources\ndata: not json\n\n').events).toEqual([]) + }) +}) + +describe('the reducer', () => { + it('routes tokens to the active step, then to the answer after final_answer', () => { + const s = run([ + { type: 'meta', data: { thinking_mode: 'think', model: 'm' } }, + { type: 'step', step: 'analyze', label: 'Analyzing', role: 'work' }, + { type: 'token', text: 'thinking…' }, + { type: 'step_end', step: 'analyze' }, + { type: 'step', step: 'solve', label: 'Solving', role: 'answer' }, + { type: 'token', text: 'draft' }, + { type: 'step_end', step: 'solve' }, + { type: 'final_answer' }, + { type: 'token', text: 'The answer.' }, + { type: 'done', data: { content: 'The answer.', sources: [], notices: ['note'] } }, + ]) + expect(s.trace.steps.map((x) => [x.step, x.status, x.content])).toEqual([ + ['analyze', 'done', 'thinking…'], ['solve', 'done', 'draft'], + ]) + expect(s.text).toBe('The answer.') + expect(s.done).toBe(true) + expect(s.trace.notices).toEqual(['note']) + expect(s.trace.model).toBe('m') + }) + + it('reads sequence ids, the project, the effort and the verdict detail', () => { + const { events } = parseExpertSse('id: 7\nevent: status\ndata: {"status":"writing"}\n\nid: 8\ndata: tok\n\n') + expect(events.map((e) => e.seq)).toEqual([7, 8]) + const s = run([ + { type: 'meta', data: { thinking_mode: 'heavy', effort: { level: 'direct', reason: 'a short message needs no verification' }, project: { id: 'p1', name: 'Launch' } } }, + { type: 'token', text: 'Hello!' }, + { type: 'verdict', verdict: 'needs_review', note: 'doubts', detail: { issues: [{ severity: 'high', text: 'date?' }], method: 'model review', limitations: 'none' } }, + { type: 'done', data: { content: 'Hello!' }, seq: 9 }, + ]) + expect(s.text).toBe('Hello!') // direct: tokens are the answer + expect(s.trace.effort).toEqual({ level: 'direct', reason: 'a short message needs no verification' }) + expect(s.trace.project).toEqual({ id: 'p1', name: 'Launch' }) + expect(s.trace.verdict).toEqual({ verdict: 'needs_review', note: 'doubts', issues: [{ severity: 'high', text: 'date?' }], method: 'model review', limitations: 'none' }) + expect(s.lastSeq).toBe(9) + }) + + it('keeps the elapsed time from done and notes a summarized earlier context', () => { + const s = run([ + { type: 'meta', data: { thinking_mode: 'fast', compacted: true } }, + { type: 'token', text: 'ok' }, + { type: 'done', data: { content: 'ok', latency_ms: 3812 } }, + ]) + expect(s.trace.elapsed_s).toBe(3.8) + expect(s.trace.notices).toEqual([COMPACTED_NOTE]) + }) + + it('in fast mode tokens are the answer, a reset clears the preamble, chips and sources accumulate', () => { + const s = run([ + { type: 'meta', data: { thinking_mode: 'fast' } }, + { type: 'token', text: 'Let me check.' }, + { type: 'reset' }, + { type: 'tool', phase: 'start', id: 'c1', name: 'hp_web_search', label: 'Searching the web for “HomePilot”' }, + { type: 'tool', phase: 'end', id: 'c1', name: 'hp_web_search', label: 'Searching the web for “HomePilot”', ok: true, summary: '3 results', elapsed_ms: 900 }, + { type: 'sources', sources: [{ n: 1, title: 'HomePilot', url: 'https://github.com/ruslanmv/HomePilot', domain: 'github.com' }] }, + { type: 'token', text: 'HomePilot is on GitHub [1].' }, + { type: 'done', data: { content: 'HomePilot is on GitHub [1].' } }, + ]) + expect(s.text).toBe('HomePilot is on GitHub [1].') + expect(s.trace.tools).toEqual([expect.objectContaining({ id: 'c1', status: 'done', summary: '3 results', elapsed_ms: 900 })]) + expect(s.trace.sources[0].n).toBe(1) + expect(sourcesAsWeb(s.trace)).toEqual({ status: 'ok', query: 'HomePilot', sources: s.trace.sources }) + expect(sourcesAsWeb(initialExpertState().trace)).toBeNull() + }) + + it('keeps the verdict and marks a failed tool', () => { + const s = run([ + { type: 'meta', data: { thinking_mode: 'heavy' } }, + { type: 'step', step: 'research', label: 'Researching', role: 'work' }, + { type: 'tool', phase: 'start', id: 'c1', name: 'hp_web_search', label: 'Searching the web' }, + { type: 'tool', phase: 'end', id: 'c1', name: 'hp_web_search', label: 'Searching the web', ok: false, summary: 'timeout' }, + { type: 'verdict', verdict: 'corrected', note: 'The validator found issues and corrected the answer.' }, + { type: 'error', error: 'boom' }, + ]) + expect(s.trace.tools[0]).toMatchObject({ status: 'failed', step: 'research' }) + expect(s.trace.verdict).toEqual({ verdict: 'corrected', note: 'The validator found issues and corrected the answer.' }) + expect(s.error).toBe('boom') + }) + + it('follows a Deep research job: plan, items, report', () => { + let s = run([ + { type: 'status', status: 'planning' }, + { type: 'plan', items: [{ id: 'q1', title: 'What is X?', status: 'pending' }, { id: 'q2', title: 'Who makes X?', status: 'pending' }] }, + { type: 'status', status: 'researching' }, + { type: 'item', id: 'q1', status: 'running' }, + { type: 'tool', phase: 'start', id: 't1', name: 'hp_web_search', label: 'Searching the web for “X”' }, + { type: 'tool', phase: 'end', id: 't1', name: 'hp_web_search', label: 'Searching the web for “X”', ok: true }, + { type: 'item', id: 'q1', status: 'done', summary: 'X is… [1]', sources: [1] }, + { type: 'item', id: 'q2', status: 'skipped' }, + { type: 'status', status: 'writing' }, + { type: 'token', text: '# Report' }, + ], 'deep') + expect(s.text).toBe('# Report') // writing → tokens are the report + expect(s.trace.plan!.map((p) => p.status)).toEqual(['done', 'skipped']) + s = applyDeepDone(s, { report: '# Report\nX is… [1]', sources: [{ n: 1, title: 'X', url: 'https://x.test' }], plan: s.trace.plan, elapsed_s: 42.5, notices: [] }) + expect(s.done && s.text.startsWith('# Report')).toBe(true) + expect(s.trace.status).toBe('done') + expect(s.trace.elapsed_s).toBe(42.5) + }) +}) + +describe('the trivial-prompt check behind the Deep research hint', () => { + it('catches greetings and a few words, not real questions', () => { + for (const t of ['hello', 'Hi!', 'thanks', 'ok', 'how are you', 'test', 'summarize this']) expect(isTrivialPrompt(t)).toBe(true) + for (const t of ['What changed in HomePilot this week?', 'compare gemma4 and qwen3 for coding', 'why?']) { + expect(isTrivialPrompt(t)).toBe(false) + } + }) +}) + +describe('the Deep research gate', () => { + it('is unknown until measured, disabled with the reason, enabled with the model', () => { + expect(deepOptionState(UNKNOWN_GATE).disabled).toBe(true) + expect(deepGateFrom(null)).toBe(UNKNOWN_GATE) + const low = deepGateFrom({ deep_research: { enabled: false, reason: 'Deep research needs a high-end model; the strongest here is llama3.2:3b (3.2B parameters).', model: null } } as any) + expect(deepOptionState(low)).toEqual({ disabled: true, description: expect.stringContaining('llama3.2:3b') }) + const high = deepGateFrom({ deep_research: { enabled: true, reason: 'ok', model: 'internal-gpt-oss-120b' } } as any) + // Enabled: the menu's plain caption; the model is the chat's own and is not repeated. + expect(deepOptionState(high)).toEqual({ disabled: false, description: 'Plan, research and synthesize a report.' }) + }) +}) + +describe('waiting for a busy provider', () => { + it('shows the wait while it lasts and clears it on the next event', () => { + let s = initialExpertState('fast') + s = applyExpertEvent(s, { type: 'wait', text: 'The model provider is busy; retrying in 8 s…', retry_in: 8 }) + expect(s.trace.waiting).toBe('The model provider is busy; retrying in 8 s…') + expect(traceHasWork(s.trace)).toBe(false) // the skeleton stays, with the line + s = applyExpertEvent(s, { type: 'token', text: 'Hello' }) + expect(s.trace.waiting).toBeNull() + expect(s.text).toBe('Hello') + const parsed = parseExpertSse('event: wait\ndata: {"text": "Waiting in line for the model provider…", "retry_in": 3}\n\n') + expect(parsed.events[0]).toEqual({ type: 'wait', text: 'Waiting in line for the model provider…', retry_in: 3 }) + }) +}) + +describe('compacting a long conversation', () => { + it('shows the compaction until the turn starts, then keeps only the note', () => { + let s = initialExpertState('fast') + const parsed = parseExpertSse('event: compacting\ndata: {"turns": 8, "text": "Compacting the conversation: summarizing 8 earlier messages…"}\n\n') + s = applyExpertEvent(s, parsed.events[0]) + expect(s.trace.compacting).toBe('Compacting the conversation: summarizing 8 earlier messages…') + expect(traceHasWork(s.trace)).toBe(false) + s = applyExpertEvent(s, { type: 'meta', data: { thinking_mode: 'fast', compacted: true } }) + expect(s.trace.compacting).toBeNull() + expect(s.trace.notices).toContain(COMPACTED_NOTE) + }) +}) diff --git a/frontend/src/test/fileAuthToken.test.ts b/frontend/src/test/fileAuthToken.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..16309f12e2d5866eff1342f86ad9f2ba26a8b69e --- /dev/null +++ b/frontend/src/test/fileAuthToken.test.ts @@ -0,0 +1,28 @@ +/** + * A /files/ URL carries the account token only across origins (lib/fileUrl.ts): the dev setup + * needs it for tags, the packaged container has the session cookie and must not leak the + * token into URLs. + */ +import { afterEach, describe, expect, it } from 'vitest' + +import { fileAuthToken } from '../ui/lib/fileUrl' + +afterEach(() => { + localStorage.clear() +}) + +describe('the token in file URLs', () => { + it('is absent without a session', () => { + expect(fileAuthToken('http://localhost:8000')).toBe('') + }) + + it('is added only when the backend is on another origin than the page', () => { + localStorage.setItem('homepilot_auth_token', 'tok-123') + const page = window.location.origin + expect(fileAuthToken(page)).toBe('') // same origin: the cookie does it + expect(fileAuthToken(`${page}/`)).toBe('') + expect(fileAuthToken('')).toBe('') // relative backend = this origin + expect(fileAuthToken('http://localhost:8000')).toBe(page === 'http://localhost:8000' ? '' : 'tok-123') + expect(fileAuthToken('http://127.0.0.1:8002')).toBe('tok-123') + }) +}) diff --git a/frontend/src/test/installProgress.test.tsx b/frontend/src/test/installProgress.test.tsx index 55c89134a1c3d65195213f968d9e249c5fb0ae95..fa172075b7a3239095a516e7dbec1c653401f194 100644 --- a/frontend/src/test/installProgress.test.tsx +++ b/frontend/src/test/installProgress.test.tsx @@ -5,6 +5,10 @@ * The suggestions card used to show a bare spinner while a 6 GB checkpoint downloaded. It now * has the catalog rows' downloading look, with the percentage, and a bar with size and speed — * read once a second from GET /models/install/progress while the install request runs. + * + * And a download outlives the page: leaving Models for the chat and coming back used to show + * a plain Install button while the backend was still at 14%. The card now asks the backend + * what is running when it mounts and picks the download up where it is. */ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -13,6 +17,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ModelAdvisorCard } from '../ui/components/ModelAdvisorCard' import { parseInstallProgress, progressLabel } from '../ui/lib/installProgress' +const SDXL = 'sd_xl_base_1.0.safetensors' +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }) +const ADVISOR = { + ok: true, enabled: true, app_version: '2', hardware: { kind: 'nvidia', name: 'RTX', vram_gb: 6, detected: true }, + feeds: { llm: { source: 'bundled' }, media: { source: 'bundled' } }, fetch: null, attribution: 'FitLab', + suggestions: { + chat: [], vision: [], video: [], + image: [{ id: 'sdxl', name: 'SDXL Base 1.0', kind: 'image', verdict: 'fits', score: 1, reasons: ['Fits'], + install: { provider: 'comfyui', model_type: 'image', model_id: SDXL }, installed: false, download_gb: 6.94 }], + }, +} +const RUNNING_14 = { state: 'running', percent: 14, done: '549M', total: '4.0G', rate: '10.0MB/s' } + afterEach(() => { vi.restoreAllMocks() localStorage.clear() @@ -34,37 +51,74 @@ describe('the progress line', () => { describe('installing a suggestion', () => { it('shows the downloading button with the percent and a progress bar, then reports done', async () => { let finish: (v: Response) => void = () => undefined - const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }) vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => { const url = String(input) + if (url.endsWith('/models/install/progress')) return Promise.resolve(json({ installs: {} })) // nothing to pick up if (url.includes('/models/install/progress')) { - return Promise.resolve(json({ model_id: 'sd_xl_base_1.0.safetensors', state: 'running', percent: 42, done: '2.71G', total: '6.46G', rate: '5.63MB/s' })) + return Promise.resolve(json({ model_id: SDXL, state: 'running', percent: 42, done: '2.71G', total: '6.46G', rate: '5.63MB/s' })) } if (url.includes('/models/install')) return new Promise((resolve) => { finish = resolve }) - return Promise.resolve(json({ - ok: true, enabled: true, app_version: '2', hardware: { kind: 'nvidia', name: 'RTX', vram_gb: 6, detected: true }, - feeds: { llm: { source: 'bundled' }, media: { source: 'bundled' } }, fetch: null, attribution: 'FitLab', - suggestions: { - chat: [], vision: [], video: [], - image: [{ id: 'sdxl', name: 'SDXL Base 1.0', kind: 'image', verdict: 'fits', score: 1, reasons: ['Fits'], - install: { provider: 'comfyui', model_type: 'image', model_id: 'sd_xl_base_1.0.safetensors' }, installed: false, download_gb: 6.94 }], - }, - })) + return Promise.resolve(json(ADVISOR)) }) const onBusyChange = vi.fn() render() fireEvent.click(await screen.findByRole('button', { name: 'Install' })) // asks first fireEvent.click(screen.getByRole('button', { name: 'Install' })) // confirms - expect(onBusyChange).toHaveBeenCalledWith('sd_xl_base_1.0.safetensors') + expect(onBusyChange).toHaveBeenCalledWith(SDXL) expect(await screen.findByText('Downloading 42%')).toBeTruthy() const bar = await screen.findByTestId('install-progress') await waitFor(() => expect(bar.textContent).toBe('Downloading 42% · 2.7 GB of 6.5 GB · 5.63 MB/s')) expect(screen.getByRole('progressbar', { name: 'Download progress' }).getAttribute('aria-valuenow')).toBe('42') - finish(json({ ok: true, message: 'Successfully installed sd_xl_base_1.0.safetensors' })) + finish(json({ ok: true, message: `Successfully installed ${SDXL}` })) await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(null)) await waitFor(() => expect(screen.queryByTestId('install-progress')).toBeNull()) }) }) + +describe('a download still running when the card mounts', () => { + /** The backend: one install running at 14%; its own progress answers `perModel` in turn. */ + function backend(perModel: unknown[]) { + const seen = { install: 0 } + vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/models/install/progress')) return Promise.resolve(json({ installs: { [SDXL]: RUNNING_14 } })) + if (url.includes('/models/install/progress')) return Promise.resolve(json(perModel.length > 1 ? perModel.shift() : perModel[0])) + if (url.includes('/models/install')) { seen.install += 1; return Promise.resolve(json({ ok: true })) } + return Promise.resolve(json(ADVISOR)) + }) + return seen + } + + it('is picked up where it is, without a new install request, and ends the usual way', async () => { + const seen = backend([{ model_id: SDXL, ...RUNNING_14 }, { model_id: SDXL, state: 'done', percent: 100 }]) + const onBusyChange = vi.fn() + const onToast = vi.fn() + const onInstalled = vi.fn() + render() + + expect(await screen.findByText('Downloading 14%')).toBeTruthy() // nobody clicked Install + expect(onBusyChange).toHaveBeenCalledWith(SDXL) + await waitFor(() => expect(screen.getByRole('progressbar', { name: 'Download progress' }).getAttribute('aria-valuenow')).toBe('14')) + + await waitFor(() => expect(onInstalled).toHaveBeenCalled(), { timeout: 4000 }) // the next poll says done + expect(onToast).toHaveBeenLastCalledWith('SDXL Base 1.0 installed.') + expect(onBusyChange).toHaveBeenLastCalledWith(null) + await waitFor(() => expect(screen.queryByTestId('install-progress')).toBeNull()) + expect(seen.install).toBe(0) + }) + + it('is let go when the backend no longer knows it', async () => { + backend([{ model_id: SDXL, state: 'idle' }]) + const onBusyChange = vi.fn() + const onToast = vi.fn() + render() + + await waitFor(() => expect(onBusyChange).toHaveBeenCalledWith(SDXL)) + await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(null)) + expect(onToast.mock.calls.at(-1)?.[0]).toContain('Lost track of the SDXL Base 1.0 download') + expect(screen.getByRole('button', { name: 'Install' })).toBeTruthy() + }) +}) diff --git a/frontend/src/test/meetingsenseCloseNavigation.test.tsx b/frontend/src/test/meetingsenseCloseNavigation.test.tsx index b49a9582dc42c6ed41d75f5948983b170876ab2d..95fc127d29e91f2a4a41c7942ba5290330954875 100644 --- a/frontend/src/test/meetingsenseCloseNavigation.test.tsx +++ b/frontend/src/test/meetingsenseCloseNavigation.test.tsx @@ -54,6 +54,11 @@ beforeEach(() => { appShell.className = 'hp-app-shell'; appShell.appendChild(document.createElement('main')); document.body.appendChild(appShell); + // No network in a unit test. The provider's backend calls (the start marker, the recap + // lookup) resolve to http://localhost:8000 like the rest of the app; unstubbed, this test + // depended on whatever listened there and on how fast a refused connection failed — in CI + // the stop click landed while the start was still waiting on it, and nothing ended. + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('network is off in unit tests'); })); (globalThis as Record).hpMeetingSense = recorder(); }); @@ -61,6 +66,7 @@ afterEach(() => { delete (globalThis as Record).hpMeetingSense; appShell?.remove(); appShell = null; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -92,7 +98,9 @@ describe('closing an ended MeetingSense workspace', () => { expect(meetingConversation).toBeTruthy(); expect(meetingConversation).not.toBe('chat-before-meeting'); - // End the meeting; once finalization completes the recap exposes Close. + // End the meeting; once finalization completes the recap exposes Close. Stop is disabled + // until the start has fully settled, as for a person — click it once it is live. + await waitFor(() => expect(screen.getByTestId('ms-record-button')).not.toBeDisabled()); fireEvent.click(screen.getByTestId('ms-record-button')); const close = await screen.findByTestId('ms-workspace-close'); // The meeting workspace shares the app chrome with persistent phone/settings controls. diff --git a/frontend/src/test/messageMath.test.tsx b/frontend/src/test/messageMath.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ec914bbfa1d0c7a67e5d3c4b6d769c2e8973523e --- /dev/null +++ b/frontend/src/test/messageMath.test.tsx @@ -0,0 +1,39 @@ +/** + * Equations in chat replies: the delimiters models write (\( \), \[ \], $ and $$) render with + * KaTeX; prices stay prices; code stays code. + */ +import React from 'react' +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { MessageMarkdown, normalizeMath } from '../ui/components/MessageMarkdown' + +describe('normalizeMath', () => { + it('turns bracket delimiters into dollar math', () => { + expect(normalizeMath('Energy \\(E = mc^2\\) here')).toBe('Energy $E = mc^2$ here') + expect(normalizeMath('Then\n\\[ i\\hbar \\partial_t \\Psi = \\hat H \\Psi \\]\nDone')) + .toBe('Then\n\n$$\ni\\hbar \\partial_t \\Psi = \\hat H \\Psi\n$$\n\nDone') + }) + + it('keeps prices and code as written', () => { + expect(normalizeMath('It costs $5 and $10.')).toBe('It costs \\$5 and \\$10.') + expect(normalizeMath('Use `\\(x\\)` or\n```\n\\[y\\] $5\n```')).toBe('Use `\\(x\\)` or\n```\n\\[y\\] $5\n```') + expect(normalizeMath('$x^2$ and $$y$$')).toBe('$x^2$ and $$y$$') + }) +}) + +describe('MessageMarkdown math', () => { + it('renders inline and display equations with KaTeX', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('.katex').length).toBeGreaterThanOrEqual(3) + expect(container.querySelector('.katex-display')).not.toBeNull() + }) + + it('leaves a price as text and survives a malformed formula', () => { + const { container } = render() + expect(container.textContent).toContain('$5 and $10') + expect(container.querySelector('.katex-display')).toBeNull() + }) +}) diff --git a/frontend/src/test/modelAdvisor.test.tsx b/frontend/src/test/modelAdvisor.test.tsx index afccc8f9e26c2600f8eaf96db88ae052ae99a69d..b450a4206b6a933025d1d0dfc604f21d99242893 100644 --- a/frontend/src/test/modelAdvisor.test.tsx +++ b/frontend/src/test/modelAdvisor.test.tsx @@ -160,11 +160,13 @@ describe('ModelAdvisorCard', () => { render() await screen.findByText('sdxl') fireEvent.click(screen.getByRole('button', { name: 'Install' })) - expect(spy.mock.calls.some(([u]) => String(u).includes('/models/install'))).toBe(false) + // Nothing installed yet — the card only *reads* /models/install/progress when it mounts. + const installCall = () => spy.mock.calls.find(([u, init]) => String(u).endsWith('/models/install') && init?.method === 'POST') + expect(installCall()).toBeUndefined() expect(screen.getByText('Download ~6.94 GB?')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Install' })) await waitFor(() => expect(onInstalled).toHaveBeenCalled()) - const call = spy.mock.calls.find(([u]) => String(u).includes('/models/install')) + const call = installCall() expect(JSON.parse(String(call?.[1]?.body))).toMatchObject({ provider: 'comfyui', model_type: 'image', model_id: 'sdxl:tag' }) }) diff --git a/frontend/src/test/modelsAdvisorHidden.test.tsx b/frontend/src/test/modelsAdvisorHidden.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..764ba5957a34069f780d1ed96dfa395e558a4d31 --- /dev/null +++ b/frontend/src/test/modelsAdvisorHidden.test.tsx @@ -0,0 +1,46 @@ +/** + * Model suggestions switched off ("Hide suggestions", or Settings → Models) leave one quiet + * line on the Models page with the way back — the toast that said where to turn them on is + * long gone by the time someone wonders where the suggested image models went. + */ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ModelsView from '../ui/Models' +import { advisorEnabled, setAdvisorEnabled } from '../ui/modelAdvisor' + +const BACKEND = 'http://hp.test' + +beforeEach(() => { + localStorage.clear() + // Nothing on the page needs a real backend for this: every request is a 404 JSON answer. + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ detail: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } })) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('hidden model suggestions', () => { + it('say so on the Models page, and come back from there', async () => { + setAdvisorEnabled(false) + render() + + const line = await screen.findByTestId('advisor-hidden') + expect(line.textContent).toContain('Model suggestions for this computer are hidden.') + expect(screen.queryByText('Suggested for your GPU')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Show them' })) + expect(advisorEnabled()).toBe(true) + await waitFor(() => expect(screen.getByText('Suggested for your GPU')).toBeTruthy()) + expect(screen.queryByTestId('advisor-hidden')).toBeNull() + }) + + it('are not mentioned while they are on', async () => { + render() + await screen.findByText('Suggested for your GPU') + expect(screen.queryByTestId('advisor-hidden')).toBeNull() + }) +}) diff --git a/frontend/src/test/modelsInstallResume.test.tsx b/frontend/src/test/modelsInstallResume.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..143f312ed894991d6f8a5f6fa0ec93072a930308 --- /dev/null +++ b/frontend/src/test/modelsInstallResume.test.tsx @@ -0,0 +1,52 @@ +/** + * A model download outlives the Models page. Going to the chat and back used to show plain + * Install buttons while the backend was still downloading; the page now asks the backend what + * is running when it opens and shows that download again — its row, the toast's progress line, + * and the usual message when it ends (lib/installProgress.ts). + */ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ModelsView from '../ui/Models' + +const BACKEND = 'http://hp.test' +const SD15 = 'sd15.safetensors' +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('a download still running when the Models page opens', () => { + it('shows again in the toast and on its row, and ends with the usual message', async () => { + const perModel = [ + { model_id: SD15, state: 'running', percent: 14, done: '549M', total: '4.0G', rate: '10.0MB/s' }, + { model_id: SD15, state: 'done', percent: 100 }, + ] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/models/install/progress')) { + return json({ installs: { [SD15]: { state: 'running', percent: 14, done: '549M', total: '4.0G', rate: '10.0MB/s' } } }) + } + if (url.includes('/models/install/progress')) return json(perModel.length > 1 ? perModel.shift() : perModel[0]) + return json({ detail: 'not found' }, 404) // no advisor, no catalog service, nothing installed + }) + + render() + + expect(await screen.findByText(`Still downloading ${SD15}…`)).toBeTruthy() + await waitFor(() => expect(screen.getByTestId('install-toast-progress').textContent).toBe('Downloading 14% · 549 MB of 4.0 GB · 10.0 MB/s')) + + fireEvent.click(screen.getByRole('radio', { name: /Image/ })) + expect(await screen.findByText('Downloading 14%')).toBeTruthy() + + await waitFor(() => expect(screen.getByText(`Successfully installed ${SD15}`)).toBeTruthy(), { timeout: 4000 }) + expect(screen.queryByText('Downloading 14%')).toBeNull() + }) +}) diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index df09efdbf2f7c0a12c3262090a623d237930408e..2fab2059b2b8042db0ee069617575e91d3df5329 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Send, @@ -35,6 +35,8 @@ import { Zap, Lightbulb, LayoutGrid, + Telescope, + Square, } from 'lucide-react' import SettingsPanel, { type SettingsModelV2, type HardwarePresetUI } from './SettingsPanel' import { microphoneDebug, microphoneDebugError } from './media/microphoneDebug' @@ -57,6 +59,8 @@ import { import { abortWebSpeech, startWebSpeech, stopWebSpeech } from './media/webSpeechSession' import { isDeafTurn, planSttRecovery } from './media/sttTurnHealth' import { getDefaultBackendUrl, resolveBackendUrl, saveBackendUrl } from './lib/backendUrl' +import { isMissingRoute } from './lib/expertRoute' +import { fileAuthToken } from './lib/fileUrl' import { visionErrorMessage } from './lib/visionError' import { modeGroup, resetsConversation } from './lib/modeGroups' import { useBackToClose } from './lib/useBackToClose' @@ -68,6 +72,7 @@ import { assistantAvatarSlot, messageGapClass, useChatAppearance } from './chatA import { requestFocus, useAdvisorUpdates, type CurrentModels } from './modelAdvisor' import { AdvisorUpdateNotice } from './components/AdvisorUpdateNotice' import { LoadingDots, StatusText, StreamReveal, motionAllowed, useMotionPrefs } from './motion' +import { CompactingIndicator, PastedChips, isLongPaste, withPastes } from './components/ComposerExtras' // Account & Computers header pill (Batch 4) — ADDITIVE; renders null when the // Account & Computers flag is off, so the header is unchanged by default. import { ComputerStatusPill } from './account/ComputerStatusPill' @@ -109,6 +114,18 @@ import { } from './components/ChatSettingsPopover' import { MessageMarkdown } from './components/MessageMarkdown' import { WebSources, WebSearchToggleContext, linkCitations, parseWebSearch } from './components/WebSources' +import { ExpertSteps } from './components/ExpertSteps' +import { + applyDeepDone, applyExpertEvent, initialExpertState, parseExpertTrace, resumeDeepResearch, sourcesAsWeb, + streamDeepResearch, streamExpert, traceHasWork, type ExpertEvent, type ExpertStreamState, type ExpertTrace, +} from './lib/expertStream' +import { forgetDeepJob, pendingDeepJob, rememberDeepJob, type PendingDeepJob } from './lib/deepJobs' +import { + DEEP_RESEARCH_CAPTION, DeepResearchGateContext, UNKNOWN_GATE, deepGateFrom, deepOptionState, fetchExpertCapabilities, + type DeepResearchGate, +} from './lib/expertCapabilities' +import { ComposerAssistContext, type DeepHint } from './lib/composerAssist' +import { isTrivialPrompt } from './lib/promptHeuristics' import RouteBadge from './components/compute/RouteBadge' import { ChatEmptyState } from './components/ChatEmptyState' import { AgentIntent, INTENT_COPY } from './components/AgentIntentTiles' @@ -186,6 +203,10 @@ export type Msg = { role: 'user' | 'assistant' | 'system' text: string pending?: boolean + /** The reply streamed in live, so it is shown as it stands and never re-revealed. */ + streamed?: boolean + /** The work behind an Expert reply — steps, tool chips, sources, verdict (ExpertSteps). */ + expert?: ExpertTrace animate?: boolean // when true, show recovery UI and allow retry error?: boolean @@ -201,6 +222,8 @@ export type Msg = { interactive_preview?: boolean /** The web sources this answer was written from (`web_search.py`); read with parseWebSearch. */ web?: unknown + /** An Expert reply's trace, persisted with the message; read with parseExpertTrace. */ + expert?: unknown } | null // Compute routing diagnostics (P0-2): which device served this reply, and // whether it fell back. Raw shape from /chat (snake_case, not camelized). @@ -394,7 +417,7 @@ function cloudProviderApiKey(baseUrl?: string): string | undefined { if (isBffSessionEnabled()) return undefined try { return localStorage.getItem('homepilot_cloud_token') || undefined } catch { return undefined } } -type ChatReasoningMode = 'persona' | 'fast' | 'expert' | 'heavy' +type ChatReasoningMode = 'persona' | 'fast' | 'expert' | 'heavy' | 'deep' const CHAT_REASONING_OPTIONS: Array<{ id: Exclude @@ -402,13 +425,17 @@ const CHAT_REASONING_OPTIONS: Array<{ description: string icon: any }> = [ - { id: 'fast', label: 'Fast', description: 'Quick responses', icon: Zap }, - { id: 'expert', label: 'Expert', description: 'Deeper reasoning', icon: Lightbulb }, - { id: 'heavy', label: 'Heavy', description: 'Multiple expert perspectives', icon: LayoutGrid }, + { id: 'fast', label: 'Fast', description: 'Quick answers. Minimal overhead.', icon: Zap }, + { id: 'expert', label: 'Expert', description: 'Live tool-aware thinking and sources.', icon: Lightbulb }, + { id: 'heavy', label: 'Heavy', description: 'Deeper answer, validation and correction.', icon: LayoutGrid }, + // Offered only when the connection has a high-end model (DeepResearchGateContext). + { id: 'deep', label: 'Deep research', description: DEEP_RESEARCH_CAPTION, icon: Telescope }, ] function toThinkingMode(mode: ChatReasoningMode): 'auto' | 'fast' | 'think' | 'heavy' { if (mode === 'expert') return 'think' + // Deep research runs as its own job; when it cannot (no high-end model), Heavy stands in. + if (mode === 'deep') return 'heavy' // 'persona' never reaches this (canUseExpertInContext filters it out first), // but map to 'auto' as a safe fallback for the Expert backend contract. if (mode === 'persona') return 'auto' @@ -1692,7 +1719,9 @@ function QueryBar({ mode: Mode fileInputRef: React.RefObject canSend: boolean - onSend: () => void + /** Sends the message; `text` (when given) is what to send instead of `input` — the typed + * text with any long pastes the composer kept as chips. */ + onSend: (text?: string) => void onUpload: (file: File) => void placeholderOverride?: string pendingPreviewUrl?: string | null @@ -1705,6 +1734,10 @@ function QueryBar({ // ---- Drag-and-drop image support ---- const [isDragging, setIsDragging] = useState(false) const dragCounterRef = useRef(0) + // Whether "Deep research" can be picked here (measured by the backend; see expertCapabilities.ts). + const deepGate = useContext(DeepResearchGateContext) + // Stop for a streaming reply, and the one-time Deep research hint (lib/composerAssist.ts). + const assist = useContext(ComposerAssistContext) // Composer grows with what is typed (up to the 400px cap) instead of // scrolling inside a one-line box; resets when the message is sent. const composerRef = useRef(null) @@ -1723,12 +1756,33 @@ function QueryBar({ window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) + // Long pastes become chips above the field (as in ChatGPT and Claude) instead of a wall of + // text that stretches the composer over the conversation; they are sent with the message. + const [pastes, setPastes] = useState([]) + const [openPaste, setOpenPaste] = useState(null) + // The field grows to a cap (about 30 % of the window, at most 260 px) and then scrolls; once + // it holds more than a line the pill becomes a rounded rectangle with the controls below. + const [multiline, setMultiline] = useState(false) React.useLayoutEffect(() => { const el = composerRef.current if (!el) return el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 400)}px` + const cap = typeof window !== 'undefined' ? Math.max(120, Math.min(260, Math.round(window.innerHeight * 0.3))) : 260 + el.style.height = `${Math.min(el.scrollHeight, cap)}px` + setMultiline(input.includes('\n') || el.scrollHeight > 64) }, [input]) + const expanded = multiline || pastes.length > 0 || Boolean(pendingPreviewUrl) + const sendable = canSend || pastes.length > 0 + const sendNow = useCallback(() => { + if (!pastes.length) { + onSend() + return + } + const combined = withPastes(input, pastes) + setPastes([]) + setOpenPaste(null) + onSend(combined) + }, [pastes, input, onSend]) const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -1755,15 +1809,21 @@ function QueryBar({ // ---- Paste image from clipboard ---- const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items - if (!items) return - for (const item of Array.from(items)) { - if (item.type.startsWith('image/')) { - e.preventDefault() - const blob = item.getAsFile() - if (blob) onUpload(new File([blob], 'pasted-image.png', { type: blob.type })) - return + if (items) { + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const blob = item.getAsFile() + if (blob) onUpload(new File([blob], 'pasted-image.png', { type: blob.type })) + return + } } } + const text = e.clipboardData?.getData('text/plain') || '' + if (isLongPaste(text)) { + e.preventDefault() + setPastes((prev) => [...prev, text]) + } }, [onUpload]) // ---- Speech-to-text for the mic button ---- @@ -2210,6 +2270,35 @@ function QueryBar({ > {/* The privacy indicator the floating button used to be. Nothing while idle. */} + {assist.deepHint ? ( +
+ + + Deep research for a short message? It plans, searches and writes a report, which takes minutes. Fast is usually enough here. + + + + +
+ ) : null}
{isDragging && ( -
+
Drop image to attach
)} @@ -2233,7 +2324,7 @@ function QueryBar({ `+` replaces the paperclip rather than joining it: two buttons for one intent make the reader work out which is the superset. The file input below is untouched, and the menu's first item fires the same click the paperclip did. */} -
+
fileInputRef.current?.click()} /> {CHAT_REASONING_OPTIONS.map((opt) => { const selected = chatReasoningMode === opt.id - const personaUnavailable = false + // Deep research is disabled, with the reason, until a high-end model is connected. + const deepState = opt.id === 'deep' ? deepOptionState(deepGate) : null + const unavailable = Boolean(deepState?.disabled) + const description = deepState ? deepState.description : opt.description const Icon = opt.icon return ( @@ -2355,10 +2451,20 @@ function QueryBar({ )} - {canSend && !isListening && !micTranscribing ? ( + {assist.streaming ? ( + + ) : sendable && !isListening && !micTranscribing ? (