HomePilot Deploy Bot commited on
Commit
e5bf219
Β·
1 Parent(s): 0a15291

chore(hf): sync HomePilot to HF Space

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -3
  2. Dockerfile +9 -0
  3. backend/app/asset_registry.py +32 -16
  4. backend/app/background.py +4 -8
  5. backend/app/comfy.py +8 -27
  6. backend/app/download_script.py +32 -0
  7. backend/app/files.py +333 -130
  8. backend/app/intent_guard.py +28 -0
  9. backend/app/inventory.py +9 -2
  10. backend/app/main.py +138 -79
  11. backend/app/media_links.py +86 -0
  12. backend/app/media_ops.py +360 -0
  13. backend/app/media_quota.py +60 -0
  14. backend/app/media_storage/__init__.py +155 -0
  15. backend/app/media_storage/base.py +89 -0
  16. backend/app/media_storage/local.py +111 -0
  17. backend/app/media_storage/s3.py +265 -0
  18. backend/app/media_validate.py +81 -0
  19. backend/app/model_advisor/hardware.py +43 -5
  20. backend/app/model_resolver.py +90 -8
  21. backend/app/projects.py +5 -1
  22. backend/app/storage.py +32 -0
  23. backend/app/studio/bundles.py +2 -1
  24. backend/app/users.py +8 -16
  25. backend/requirements.txt +3 -0
  26. deploy/huggingface-space/hf_wrapper.py +40 -0
  27. expert/backend/app/expert/__init__.py +5 -0
  28. expert/backend/app/expert/capability.py +275 -0
  29. expert/backend/app/expert/compaction.py +168 -0
  30. expert/backend/app/expert/config.py +190 -0
  31. expert/backend/app/expert/deep.py +481 -0
  32. expert/backend/app/expert/eval_bench.py +30 -0
  33. expert/backend/app/expert/grounding.py +65 -0
  34. expert/backend/app/expert/heavy.py +487 -0
  35. expert/backend/app/expert/mcp_catalog.py +113 -0
  36. expert/backend/app/expert/mcp_gateway.py +197 -0
  37. expert/backend/app/expert/mcp_tools.py +56 -0
  38. expert/backend/app/expert/observability.py +33 -0
  39. expert/backend/app/expert/persistent_memory.py +65 -0
  40. expert/backend/app/expert/policies.py +126 -0
  41. expert/backend/app/expert/project_context.py +107 -0
  42. expert/backend/app/expert/providers.py +1282 -0
  43. expert/backend/app/expert/readiness.py +103 -0
  44. expert/backend/app/expert/router.py +521 -0
  45. expert/backend/app/expert/routes.py +865 -0
  46. expert/backend/app/expert/safety_policy.py +31 -0
  47. expert/backend/app/expert/schemas.py +230 -0
  48. expert/backend/app/expert/state_store.py +227 -0
  49. expert/backend/app/expert/target.py +92 -0
  50. expert/backend/app/expert/thinking.py +311 -0
.gitattributes CHANGED
@@ -1,9 +1,9 @@
1
- *.jpeg filter=lfs diff=lfs merge=lfs -text
2
  *.webp filter=lfs diff=lfs merge=lfs -text
3
  *.svg filter=lfs diff=lfs merge=lfs -text
 
4
  *.woff2 filter=lfs diff=lfs merge=lfs -text
5
  *.ico filter=lfs diff=lfs merge=lfs -text
6
  *.hpersona filter=lfs diff=lfs merge=lfs -text
7
- *.jpg filter=lfs diff=lfs merge=lfs -text
8
- *.woff filter=lfs diff=lfs merge=lfs -text
9
  *.png filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.webp filter=lfs diff=lfs merge=lfs -text
2
  *.svg filter=lfs diff=lfs merge=lfs -text
3
+ *.woff filter=lfs diff=lfs merge=lfs -text
4
  *.woff2 filter=lfs diff=lfs merge=lfs -text
5
  *.ico filter=lfs diff=lfs merge=lfs -text
6
  *.hpersona filter=lfs diff=lfs merge=lfs -text
7
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
 
8
  *.png filter=lfs diff=lfs merge=lfs -text
9
+ *.jpg filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -56,6 +56,15 @@ COPY backend/app ./app
56
  # Ensure __init__.py exists (created by deploy script)
57
  RUN touch ./app/__init__.py
58
 
 
 
 
 
 
 
 
 
 
59
  # Community persona samples
60
  COPY community/sample ./community/sample
61
 
 
56
  # Ensure __init__.py exists (created by deploy script)
57
  RUN touch ./app/__init__.py
58
 
59
+ # The model installer (POST /models/install) runs this for ComfyUI and Civitai downloads;
60
+ # the backend finds the copy next to its package (app/download_script.py).
61
+ COPY scripts/download.py ./scripts/download.py
62
+
63
+ # Expert β€” the chat's Fast / Expert / Heavy path (/v1/expert/*). The backend looks for it
64
+ # next to its package when there is no repo around (app/main.py). Without it every chat
65
+ # POST fell through to the frontend catch-all below and came back HTTP 405.
66
+ COPY expert/backend/app/expert ./expert
67
+
68
  # Community persona samples
69
  COPY community/sample ./community/sample
70
 
backend/app/asset_registry.py CHANGED
@@ -20,7 +20,9 @@ from datetime import datetime
20
  from pathlib import Path
21
  from typing import Any, Dict, List, Optional
22
 
23
- from .config import UPLOAD_DIR, SQLITE_PATH
 
 
24
 
25
  # Known filename patterns β†’ feature classification
26
  _FEATURE_PATTERNS = {
@@ -52,9 +54,7 @@ def _db() -> sqlite3.Connection:
52
 
53
 
54
  def _upload_root() -> Path:
55
- p = Path(UPLOAD_DIR)
56
- if not p.is_absolute():
57
- p = Path(__file__).resolve().parents[1] / "data" / "uploads"
58
  p.mkdir(parents=True, exist_ok=True)
59
  return p
60
 
@@ -106,19 +106,25 @@ def register_asset(
106
  sha256: str = "",
107
  width: int = 0,
108
  height: int = 0,
 
109
  ) -> str:
110
  """
111
  Register a new asset. Returns the asset_id.
112
- If an asset with the same storage_key already exists, returns its ID.
 
 
 
 
113
  """
 
114
  _ensure_assets_table()
115
  con = _db()
116
  cur = con.cursor()
117
 
118
  # Check if already exists
119
  cur.execute(
120
- "SELECT id FROM assets WHERE storage_backend = 'local' AND storage_key = ?",
121
- (storage_key,),
122
  )
123
  row = cur.fetchone()
124
  if row:
@@ -138,9 +144,9 @@ def register_asset(
138
  id, kind, mime, storage_backend, storage_key,
139
  size_bytes, width, height, sha256,
140
  origin, source_hint, feature, project_id, user_id
141
- ) VALUES (?,?,?,'local',?,?,?,?,?,?,?,?,?,?)
142
  """,
143
- (asset_id, kind, mime or "", storage_key,
144
  size_bytes, width, height, sha256 or "",
145
  origin, source_hint or "", feature or "",
146
  project_id or "", user_id or ""),
@@ -161,23 +167,25 @@ def get_asset(asset_id: str) -> Optional[Dict[str, Any]]:
161
  return dict(row) if row else None
162
 
163
 
164
- def find_asset_id_by_storage_key(storage_key: str) -> Optional[str]:
165
- """Reverse-lookup: storage_key β†’ asset_id.
166
 
167
  Used by the persona-library link phase to backfill the
168
  ``registry_asset_id`` field on older library rows that were
169
  saved before that field existed. The ``UNIQUE(storage_backend,
170
  storage_key)`` constraint on the assets table guarantees at
171
- most one row per storage_key, so this is a safe O(1) lookup.
 
172
  """
173
  if not storage_key:
174
  return None
 
175
  _ensure_assets_table()
176
  con = _db()
177
  cur = con.cursor()
178
  cur.execute(
179
- "SELECT id FROM assets WHERE storage_backend = 'local' AND storage_key = ?",
180
- (storage_key,),
181
  )
182
  row = cur.fetchone()
183
  con.close()
@@ -412,10 +420,14 @@ def register_comfy_output(
412
  project_id: str = "",
413
  user_id: str = "",
414
  workflow_name: str = "",
 
 
415
  ) -> str:
416
  """
417
  Register a file that was downloaded from ComfyUI.
418
- Call this right after saving the file to UPLOAD_DIR.
 
 
419
 
420
  Returns the asset_id.
421
  """
@@ -426,7 +438,10 @@ def register_comfy_output(
426
  except ValueError:
427
  rel_path = str(abs_path)
428
 
429
- size = abs_path.stat().st_size if abs_path.exists() else 0
 
 
 
430
  ext = abs_path.suffix.lower()
431
  kind = "video" if ext in VIDEO_EXTS else "image"
432
  mime = mimetypes.guess_type(str(abs_path))[0] or ""
@@ -444,4 +459,5 @@ def register_comfy_output(
444
  feature=feature,
445
  project_id=project_id,
446
  user_id=user_id,
 
447
  )
 
20
  from pathlib import Path
21
  from typing import Any, Dict, List, Optional
22
 
23
+ from .config import UPLOAD_DIR, SQLITE_PATH # noqa: F401 (UPLOAD_DIR: kept for importers)
24
+ from .media_storage import backend_name as _storage_backend_name
25
+ from .media_storage import upload_root as _storage_root
26
 
27
  # Known filename patterns β†’ feature classification
28
  _FEATURE_PATTERNS = {
 
54
 
55
 
56
  def _upload_root() -> Path:
57
+ p = _storage_root()
 
 
58
  p.mkdir(parents=True, exist_ok=True)
59
  return p
60
 
 
106
  sha256: str = "",
107
  width: int = 0,
108
  height: int = 0,
109
+ storage_backend: str = "",
110
  ) -> str:
111
  """
112
  Register a new asset. Returns the asset_id.
113
+ If an asset with the same (storage_backend, storage_key) already exists, returns its ID.
114
+
115
+ ``storage_backend`` is the MediaStorage backend holding the bytes β€” the configured one
116
+ when not given (``local`` today). The table always had the column; the queries assumed
117
+ 'local', which is what a second backend trips over.
118
  """
119
+ backend = (storage_backend or _storage_backend_name()).strip().lower()
120
  _ensure_assets_table()
121
  con = _db()
122
  cur = con.cursor()
123
 
124
  # Check if already exists
125
  cur.execute(
126
+ "SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?",
127
+ (backend, storage_key),
128
  )
129
  row = cur.fetchone()
130
  if row:
 
144
  id, kind, mime, storage_backend, storage_key,
145
  size_bytes, width, height, sha256,
146
  origin, source_hint, feature, project_id, user_id
147
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
148
  """,
149
+ (asset_id, kind, mime or "", backend, storage_key,
150
  size_bytes, width, height, sha256 or "",
151
  origin, source_hint or "", feature or "",
152
  project_id or "", user_id or ""),
 
167
  return dict(row) if row else None
168
 
169
 
170
+ def find_asset_id_by_storage_key(storage_key: str, storage_backend: str = "") -> Optional[str]:
171
+ """Reverse-lookup: (storage_backend, storage_key) β†’ asset_id.
172
 
173
  Used by the persona-library link phase to backfill the
174
  ``registry_asset_id`` field on older library rows that were
175
  saved before that field existed. The ``UNIQUE(storage_backend,
176
  storage_key)`` constraint on the assets table guarantees at
177
+ most one row per key and backend, so this is a safe O(1) lookup.
178
+ The configured backend when ``storage_backend`` is not given.
179
  """
180
  if not storage_key:
181
  return None
182
+ backend = (storage_backend or _storage_backend_name()).strip().lower()
183
  _ensure_assets_table()
184
  con = _db()
185
  cur = con.cursor()
186
  cur.execute(
187
+ "SELECT id FROM assets WHERE storage_backend = ? AND storage_key = ?",
188
+ (backend, storage_key),
189
  )
190
  row = cur.fetchone()
191
  con.close()
 
420
  project_id: str = "",
421
  user_id: str = "",
422
  workflow_name: str = "",
423
+ storage_backend: str = "local",
424
+ size_bytes: Optional[int] = None,
425
  ) -> str:
426
  """
427
  Register a file that was downloaded from ComfyUI.
428
+ Call this right after saving the file to media storage β€” ``dest_path`` is where it would
429
+ be on this disk (the key is its path relative to the upload root); ``storage_backend``
430
+ and ``size_bytes`` say where it actually went when that is not the local disk.
431
 
432
  Returns the asset_id.
433
  """
 
438
  except ValueError:
439
  rel_path = str(abs_path)
440
 
441
+ if size_bytes is not None:
442
+ size = int(size_bytes)
443
+ else:
444
+ size = abs_path.stat().st_size if abs_path.exists() else 0
445
  ext = abs_path.suffix.lower()
446
  kind = "video" if ext in VIDEO_EXTS else "image"
447
  mime = mimetypes.guess_type(str(abs_path))[0] or ""
 
459
  feature=feature,
460
  project_id=project_id,
461
  user_id=user_id,
462
+ storage_backend=storage_backend or "local",
463
  )
backend/app/background.py CHANGED
@@ -128,15 +128,11 @@ def _get_image_and_size(url: str) -> Tuple[bytes, Tuple[int, int]]:
128
 
129
 
130
  def _save_image_to_uploads(img_bytes: bytes, prefix: str = "bg", ext: str = "png") -> str:
131
- """Save image bytes to uploads directory and return the URL path."""
132
- filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{ext}"
133
- upload_path = Path(UPLOAD_DIR)
134
- upload_path.mkdir(parents=True, exist_ok=True)
135
-
136
- file_path = upload_path / filename
137
- with open(file_path, 'wb') as f:
138
- f.write(img_bytes)
139
 
 
 
140
  return f"/files/{filename}"
141
 
142
 
 
128
 
129
 
130
  def _save_image_to_uploads(img_bytes: bytes, prefix: str = "bg", ext: str = "png") -> str:
131
+ """Save image bytes to media storage (a flat key, as before) and return the URL path."""
132
+ from .media_storage import get_storage
 
 
 
 
 
 
133
 
134
+ filename = f"{prefix}_{uuid.uuid4().hex[:8]}.{ext}"
135
+ get_storage().put(filename, img_bytes)
136
  return f"/files/{filename}"
137
 
138
 
backend/app/comfy.py CHANGED
@@ -469,23 +469,10 @@ def _get_local_file_path(url: str) -> Path | None:
469
  if not file_ref or '..' in file_ref:
470
  return None
471
 
472
- # 1) Try direct file in UPLOAD_DIR (legacy flat layout)
473
- local_path = Path(UPLOAD_DIR) / file_ref
474
- if local_path.exists():
475
- return local_path
476
-
477
- # 2) Try resolving as an asset_id via the file_assets DB table
478
- try:
479
- from .files import get_asset, _upload_root
480
- asset = get_asset(file_ref)
481
- if asset and asset.get("rel_path"):
482
- asset_path = _upload_root() / asset["rel_path"]
483
- if asset_path.exists():
484
- return asset_path
485
- except Exception:
486
- pass
487
-
488
- return None
489
  except Exception:
490
  return None
491
 
@@ -602,16 +589,10 @@ def _preprocess_image_paths(variables: Dict[str, Any]) -> Dict[str, Any]:
602
  elif value.startswith('/files/'):
603
  try:
604
  file_ref = value.replace('/files/', '', 1).split('?')[0]
605
- # Try direct file first, then asset DB lookup
606
- local_path = Path(UPLOAD_DIR) / file_ref
607
- if not local_path.exists():
608
- try:
609
- from .files import get_asset, _upload_root
610
- asset = get_asset(file_ref)
611
- if asset and asset.get("rel_path"):
612
- local_path = _upload_root() / asset["rel_path"]
613
- except Exception:
614
- pass
615
 
616
  if local_path.exists():
617
  # Copy to ComfyUI's input directory
 
469
  if not file_ref or '..' in file_ref:
470
  return None
471
 
472
+ # A flat key or a file_assets id β€” on this disk, or fetched from the configured
473
+ # storage into a local cache when the media lives elsewhere (files.py).
474
+ from .files import resolve_local_file
475
+ return resolve_local_file(file_ref)
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  except Exception:
477
  return None
478
 
 
589
  elif value.startswith('/files/'):
590
  try:
591
  file_ref = value.replace('/files/', '', 1).split('?')[0]
592
+ # A flat key or a file_assets id, on this disk or fetched from the
593
+ # configured storage (files.py).
594
+ from .files import resolve_local_file
595
+ local_path = resolve_local_file(file_ref) or (Path(UPLOAD_DIR) / file_ref)
 
 
 
 
 
 
596
 
597
  if local_path.exists():
598
  # Copy to ComfyUI's input directory
backend/app/download_script.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Where ``scripts/download.py`` is.
3
+
4
+ The model installer (POST /models/install, Creator Studio bundles) runs the repo's downloader
5
+ as a subprocess. In the repo it is two levels above this package; a packaged image has no
6
+ repo around it β€” the Hugging Face Space copies ``backend/app`` to ``/app/app``, so the old
7
+ ``parents[2] / "scripts"`` was ``/scripts/download.py`` and every install there failed with
8
+ "Download script not found", Ollama pulls included. An image ships a copy next to the
9
+ package instead (``/app/scripts/download.py``), and ``HOMEPILOT_DOWNLOAD_SCRIPT`` names any
10
+ other location.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ _PACKAGE_DIR = Path(__file__).resolve().parent
19
+
20
+
21
+ def download_script(package_dir: Optional[Path] = None) -> Path:
22
+ """The downloader's path: the configured one, else the first that exists, else the repo's."""
23
+ configured = (os.getenv("HOMEPILOT_DOWNLOAD_SCRIPT") or "").strip()
24
+ if configured:
25
+ return Path(configured).expanduser()
26
+ pkg = (package_dir or _PACKAGE_DIR).resolve() # <repo>/backend/app, or /app/app
27
+ candidates = [pkg.parents[1] / "scripts" / "download.py", # <repo>/scripts
28
+ pkg.parent / "scripts" / "download.py"] # /app/scripts (packaged image)
29
+ for path in candidates:
30
+ if path.is_file():
31
+ return path
32
+ return candidates[0]
backend/app/files.py CHANGED
@@ -15,18 +15,24 @@ ADDITIVE ONLY β€” does not modify any existing module.
15
  """
16
  from __future__ import annotations
17
 
 
18
  import mimetypes
19
  import os
20
  import sqlite3
 
21
  import uuid
22
  from pathlib import Path
23
  from typing import Any, Dict, Optional
24
 
25
  from fastapi import APIRouter, Cookie, Header, HTTPException, Query, UploadFile, File
26
- from fastapi.responses import FileResponse, JSONResponse
27
 
28
- from .config import UPLOAD_DIR, MAX_UPLOAD_MB
29
- from .storage import _get_db_path
 
 
 
 
30
 
31
  # Cache headers for served files β€” prevents image flickering on React re-renders.
32
  # Uploaded images are immutable (content-addressed), so long max-age is safe.
@@ -46,31 +52,30 @@ def _db() -> sqlite3.Connection:
46
 
47
 
48
  def _upload_root() -> Path:
49
- """Resolve the absolute upload root directory."""
50
- p = Path(UPLOAD_DIR)
51
- if not p.is_absolute():
52
- p = Path(__file__).resolve().parents[1] / "data" / "uploads"
53
  p.mkdir(parents=True, exist_ok=True)
54
  return p
55
 
56
 
57
- def _ensure_user_dir(user_id: str, kind: str, project_id: str = "") -> Path:
58
- """
59
- Create and return the per-user storage directory.
60
- Layout: users/<user_id>/{uploads|images|projects/<project_id>}
61
- """
62
- root = _upload_root() / "users" / user_id
63
  if kind == "upload":
64
- p = root / "uploads"
65
  elif kind == "image":
66
- p = root / "images"
67
  elif kind == "project_asset":
68
- if not project_id:
69
- project_id = "unknown"
70
- p = root / "projects" / project_id
71
  else:
72
- p = root / "misc"
 
 
73
 
 
 
 
74
  p.mkdir(parents=True, exist_ok=True)
75
  return p
76
 
@@ -125,6 +130,30 @@ def _resolve_user(
125
  # Asset CRUD
126
  # ---------------------------------------------------------------------------
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def insert_asset(
129
  user_id: str,
130
  kind: str,
@@ -134,74 +163,103 @@ def insert_asset(
134
  original_name: str = "",
135
  project_id: str = "",
136
  conversation_id: str = "",
 
 
 
137
  ) -> str:
138
- """Register a file in file_assets. Returns the asset_id."""
 
 
 
 
 
139
  asset_id = f"f_{uuid.uuid4().hex[:20]}"
 
 
140
  con = _db()
 
141
  cur = con.cursor()
142
  cur.execute(
143
  """
144
  INSERT INTO file_assets(
145
  id, user_id, kind, rel_path, mime, size_bytes,
146
- original_name, project_id, conversation_id
147
  )
148
- VALUES (?,?,?,?,?,?,?,?,?)
149
  """,
150
  (asset_id, user_id, kind, rel_path, mime or "",
151
  int(size_bytes or 0), original_name or "",
152
- project_id or "", conversation_id or ""),
153
  )
154
  con.commit()
155
  con.close()
156
  return asset_id
157
 
158
 
159
- def get_asset(asset_id: str) -> Optional[Dict[str, Any]]:
160
- """Look up a file asset by id."""
161
  con = _db()
162
  cur = con.cursor()
163
  cur.execute("SELECT * FROM file_assets WHERE id = ?", (asset_id,))
164
  row = cur.fetchone()
165
  con.close()
166
- return dict(row) if row else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
- def delete_asset_and_file(asset_id: str) -> bool:
 
 
 
 
 
 
170
  """
171
- Delete a file_assets record AND its physical file from disk.
172
- Returns True if the DB record was removed.
 
 
173
  """
174
  if not asset_id:
175
  return False
176
  con = _db()
 
177
  cur = con.cursor()
178
- cur.execute("SELECT id, rel_path FROM file_assets WHERE id = ?", (asset_id,))
179
  row = cur.fetchone()
180
  if not row:
181
  con.close()
182
  return False
183
 
184
- rel_path = row["rel_path"]
185
- root = _upload_root()
186
-
187
- # Delete the physical file
188
- if rel_path:
189
- abs_path = root / rel_path
190
- try:
191
- if abs_path.is_file():
192
- abs_path.unlink()
193
- # Clean up empty parent dirs (but not the root)
194
- parent = abs_path.parent
195
- try:
196
- if parent != root and not any(parent.iterdir()):
197
- parent.rmdir()
198
- except Exception:
199
- pass
200
- except Exception as e:
201
- print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
202
-
203
- # Delete the DB record
204
- cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,))
205
  removed = cur.rowcount > 0
206
  con.commit()
207
  con.close()
@@ -224,13 +282,9 @@ def save_generated_image_as_asset(
224
  Returns dict with asset_id and url (/files/<asset_id>).
225
  """
226
  ext = "png" if "png" in (mime or "") else "jpg"
227
- folder = _ensure_user_dir(user_id, "image", project_id=project_id)
228
-
229
  fname = f"{uuid.uuid4().hex}.{ext}"
230
- abs_path = folder / fname
231
- abs_path.write_bytes(image_bytes)
232
-
233
- rel_path = str(abs_path.relative_to(_upload_root()))
234
 
235
  asset_id = insert_asset(
236
  user_id=user_id,
@@ -241,9 +295,10 @@ def save_generated_image_as_asset(
241
  original_name=fname,
242
  project_id=project_id,
243
  conversation_id=conversation_id,
 
244
  )
245
 
246
- return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png"}
247
 
248
 
249
  def save_generated_media_as_asset(
@@ -259,21 +314,21 @@ def save_generated_media_as_asset(
259
  extension = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip())
260
  allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
261
  extension = extension if extension in allowed_extensions else ".bin"
262
- folder = _ensure_user_dir(user_id, safe_kind, project_id=project_id)
263
  filename = f"{uuid.uuid4().hex}{extension}"
264
- absolute_path = folder / filename
265
- absolute_path.write_bytes(media_bytes)
266
  asset_id = insert_asset(
267
  user_id=user_id,
268
  kind=safe_kind,
269
- rel_path=str(absolute_path.relative_to(_upload_root())),
270
  mime=mime or "application/octet-stream",
271
  size_bytes=len(media_bytes),
272
  original_name=filename,
273
  project_id=project_id,
274
  conversation_id=conversation_id,
 
275
  )
276
- return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
277
 
278
 
279
  # ---------------------------------------------------------------------------
@@ -442,45 +497,32 @@ def delete_conversation_media(conversation_id: str) -> int:
442
  projects/<id>/persona/appearance/).
443
  """
444
  con = _db()
 
445
  cur = con.cursor()
446
  cur.execute(
447
- "SELECT id, rel_path FROM file_assets WHERE conversation_id = ?",
448
  (conversation_id,),
449
  )
450
  rows = cur.fetchall()
451
- root = _upload_root()
452
  deleted = 0
453
 
454
- for row in rows:
455
- asset_id = row["id"]
456
- rel_path = row["rel_path"]
457
-
458
- # Delete the file from disk
459
- abs_path = root / rel_path
460
- try:
461
- if abs_path.is_file():
462
- abs_path.unlink()
463
- deleted += 1
464
- # Clean up empty parent dirs (but not the root)
465
- parent = abs_path.parent
466
- try:
467
- if parent != root and not any(parent.iterdir()):
468
- parent.rmdir()
469
- except Exception:
470
- pass
471
- except Exception as e:
472
- print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
473
-
474
- # Delete all asset records for this conversation
475
- if rows:
476
- cur.execute(
477
- "DELETE FROM file_assets WHERE conversation_id = ?",
478
- (conversation_id,),
479
- )
480
  con.commit()
 
 
 
 
 
 
 
481
 
482
  con.close()
483
- print(f"[CLEANUP] Deleted {deleted} files for conversation {conversation_id}")
484
  return deleted
485
 
486
 
@@ -562,24 +604,27 @@ async def upload_file(
562
  if len(data) > max_bytes:
563
  raise HTTPException(413, f"File too large (max {MAX_UPLOAD_MB}MB)")
564
 
565
- # Choose extension
566
  original_name = file.filename or ""
567
  content_type = (file.content_type or "").lower()
 
 
 
 
 
 
568
  ext = ""
569
  if original_name and "." in original_name:
570
  ext = original_name.rsplit(".", 1)[1].lower()
571
  if not ext:
572
  ext = (mimetypes.guess_extension(content_type) or "").lstrip(".") or "bin"
573
 
574
- # Write file to per-user directory
575
- folder = _ensure_user_dir(user["id"], kind, project_id=project_id)
576
  fname = f"{uuid.uuid4().hex}.{ext}"
577
- abs_path = folder / fname
578
- abs_path.write_bytes(data)
579
 
580
  # Store metadata
581
- rel_path = str(abs_path.relative_to(_upload_root()))
582
- mime = content_type or mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
583
  asset_id = insert_asset(
584
  user_id=user["id"],
585
  kind=kind,
@@ -589,11 +634,43 @@ async def upload_file(
589
  original_name=original_name,
590
  project_id=project_id,
591
  conversation_id=conversation_id,
 
592
  )
593
 
594
  return {"ok": True, "asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
595
 
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  # ---------------------------------------------------------------------------
598
  # Secure Download / Serve Endpoint
599
  # ---------------------------------------------------------------------------
@@ -610,6 +687,52 @@ def _is_public_file(name: str) -> bool:
610
  return name.startswith(("avatar_", "outfit_", "thumb_"))
611
 
612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  def _find_persona_file(filename: str) -> Optional[Path]:
614
  """
615
  Search all project appearance directories for a file by name.
@@ -628,49 +751,128 @@ def _find_persona_file(filename: str) -> Optional[Path]:
628
  return None
629
 
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  @router.get("/files/{asset_id}")
632
  def download_file(
633
  asset_id: str,
634
  authorization: str = Header(default=""),
635
  homepilot_session: Optional[str] = Cookie(default=None),
636
  token: Optional[str] = Query(default=None, alias="token"),
 
 
637
  ):
638
  """
639
- Secure file serving.
640
- Requires auth and ownership check.
641
- This is the endpoint used by <img src="/files/...">.
642
- Supports ?token=<session_token> for <img> tags that can't set headers.
643
- Exception: persona appearance images (avatar_*, outfit_*, thumb_*) are public.
 
644
  """
645
- # Public persona images can be served without auth
646
- if _is_public_file(asset_id):
 
 
 
 
647
  safe = Path(asset_id)
648
  abs_path = _upload_root() / safe
649
  if abs_path.exists() and abs_path.is_file():
650
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
651
  return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
652
- raise HTTPException(404, "Not found")
653
 
654
  user = _resolve_user(authorization, homepilot_session, token_param=token)
655
  if not user:
656
  raise HTTPException(401, "Authentication required")
657
 
658
- asset = get_asset(asset_id)
659
  if asset:
660
  if asset.get("user_id") != user["id"]:
661
  raise HTTPException(404, "Not found")
662
-
663
- abs_path = _upload_root() / asset["rel_path"]
664
- if not abs_path.exists():
665
- raise HTTPException(404, "Not found")
666
-
667
- filename = asset.get("original_name") or os.path.basename(str(abs_path))
668
- return FileResponse(
669
- path=str(abs_path),
670
- media_type=asset.get("mime") or "application/octet-stream",
671
- filename=filename,
672
- headers=_FILE_CACHE_HEADERS,
673
- )
674
 
675
  # Fallback: serve from flat uploads dir (avatars, legacy files)
676
  safe = Path(asset_id)
@@ -681,7 +883,7 @@ def download_file(
681
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
682
  return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
683
 
684
- raise HTTPException(404, "Not found")
685
 
686
 
687
  # ---------------------------------------------------------------------------
@@ -696,19 +898,18 @@ def download_file_legacy(
696
  token: Optional[str] = Query(default=None, alias="token"),
697
  ):
698
  """
699
- Fallback for legacy /files/<filename> paths (pre-asset era).
700
- Serves files from the flat UPLOAD_DIR if the user is authenticated.
701
- This preserves backward compatibility for existing chat history images.
702
  Supports ?token=<session_token> for <img> tags that can't set headers.
703
- Exception: persona appearance images (avatar_*, outfit_*, thumb_*) are public.
704
  """
705
  # Prevent path traversal
706
- safe = Path(subpath)
707
  if ".." in safe.parts:
708
  raise HTTPException(400, "Invalid path")
709
 
710
- # Public persona images can be served without auth
711
- if _is_public_file(safe.name):
712
  abs_path = _upload_root() / safe
713
  if abs_path.exists() and abs_path.is_file():
714
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
@@ -719,18 +920,20 @@ def download_file_legacy(
719
  if found:
720
  mime = mimetypes.guess_type(str(found))[0] or "application/octet-stream"
721
  return FileResponse(path=str(found), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
722
- raise HTTPException(404, "Not found")
723
 
724
  user = _resolve_user(authorization, homepilot_session, token_param=token)
725
  if not user:
726
  raise HTTPException(401, "Authentication required")
 
 
727
 
728
  abs_path = _upload_root() / safe
729
  if not abs_path.exists() or not abs_path.is_file():
730
  # Fallback: search project appearance dirs for the file by name
731
  found = _find_persona_file(safe.name) if "persona" in safe.parts else None
732
  if not found:
733
- raise HTTPException(404, "Not found")
734
  abs_path = found
735
 
736
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
 
15
  """
16
  from __future__ import annotations
17
 
18
+ import hashlib
19
  import mimetypes
20
  import os
21
  import sqlite3
22
+ import time
23
  import uuid
24
  from pathlib import Path
25
  from typing import Any, Dict, Optional
26
 
27
  from fastapi import APIRouter, Cookie, Header, HTTPException, Query, UploadFile, File
28
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
29
 
30
+ from .config import UPLOAD_DIR, MAX_UPLOAD_MB # noqa: F401 (UPLOAD_DIR: kept for importers)
31
+ from . import media_links, media_ops, media_quota, media_validate
32
+ from .media_storage import (StorageKeyError, backend_name, get_storage, local_storage, normalize_key,
33
+ storage_for_read, storage_named)
34
+ from .media_storage import upload_root as _storage_root
35
+ from .storage import _get_db_path, ensure_file_assets_columns
36
 
37
  # Cache headers for served files β€” prevents image flickering on React re-renders.
38
  # Uploaded images are immutable (content-addressed), so long max-age is safe.
 
52
 
53
 
54
  def _upload_root() -> Path:
55
+ """The local upload root (media_storage.upload_root), created. The legacy path-based
56
+ routes below read this disk directly; everything registered goes through get_storage()."""
57
+ p = _storage_root()
 
58
  p.mkdir(parents=True, exist_ok=True)
59
  return p
60
 
61
 
62
+ def _user_key(user_id: str, kind: str, project_id: str = "") -> str:
63
+ """Where a user's files go, as a storage-key prefix:
64
+ users/<user_id>/{uploads|images|projects/<project_id>|misc}."""
 
 
 
65
  if kind == "upload":
66
+ sub = "uploads"
67
  elif kind == "image":
68
+ sub = "images"
69
  elif kind == "project_asset":
70
+ sub = f"projects/{project_id or 'unknown'}"
 
 
71
  else:
72
+ sub = "misc"
73
+ return f"users/{user_id}/{sub}"
74
+
75
 
76
+ def _ensure_user_dir(user_id: str, kind: str, project_id: str = "") -> Path:
77
+ """The per-user directory on the local disk (kept for callers that still write paths)."""
78
+ p = _upload_root() / _user_key(user_id, kind, project_id)
79
  p.mkdir(parents=True, exist_ok=True)
80
  return p
81
 
 
130
  # Asset CRUD
131
  # ---------------------------------------------------------------------------
132
 
133
+ _backend_column_checked = False
134
+
135
+
136
+ def _ensure_backend_column(con: sqlite3.Connection) -> None:
137
+ """The storage_backend column is added at startup (storage.init_db); a database opened
138
+ without that β€” a test, a script β€” gets it here, once per process."""
139
+ global _backend_column_checked
140
+ if not _backend_column_checked:
141
+ ensure_file_assets_columns(con)
142
+ _backend_column_checked = True
143
+
144
+
145
+ def _now() -> str:
146
+ """UTC, the way SQLite's CURRENT_TIMESTAMP writes it."""
147
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
148
+
149
+
150
+ def _store_for(backend: Optional[str], key: str):
151
+ """The storage holding ``key``: the one a registry row names, or β€” for a key with no row β€”
152
+ whichever has it (media_storage.storage_for_read). None when the row names a backend this
153
+ HomePilot is not configured for."""
154
+ return storage_named(backend) if backend else storage_for_read(key)
155
+
156
+
157
  def insert_asset(
158
  user_id: str,
159
  kind: str,
 
163
  original_name: str = "",
164
  project_id: str = "",
165
  conversation_id: str = "",
166
+ storage_backend: Optional[str] = None,
167
+ sha256: str = "",
168
+ visibility: str = "private",
169
  ) -> str:
170
+ """Register a file in file_assets. Returns the asset_id.
171
+
172
+ ``storage_backend`` is the media backend that holds the bytes β€” the configured one when
173
+ not given, which is where every writer in this module just put them. ``sha256`` is of
174
+ the bytes as written ('' when the caller did not hash them); ``visibility`` is
175
+ 'private' (owner, signed link or API key) or 'public' (anyone)."""
176
  asset_id = f"f_{uuid.uuid4().hex[:20]}"
177
+ backend = (storage_backend or get_storage().name).strip().lower()
178
+ visibility = "public" if visibility == "public" else "private"
179
  con = _db()
180
+ _ensure_backend_column(con)
181
  cur = con.cursor()
182
  cur.execute(
183
  """
184
  INSERT INTO file_assets(
185
  id, user_id, kind, rel_path, mime, size_bytes,
186
+ original_name, project_id, conversation_id, storage_backend, sha256, visibility
187
  )
188
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
189
  """,
190
  (asset_id, user_id, kind, rel_path, mime or "",
191
  int(size_bytes or 0), original_name or "",
192
+ project_id or "", conversation_id or "", backend, sha256 or "", visibility),
193
  )
194
  con.commit()
195
  con.close()
196
  return asset_id
197
 
198
 
199
+ def get_asset(asset_id: str, include_deleted: bool = False) -> Optional[Dict[str, Any]]:
200
+ """Look up a file asset by id. One in the trash (deleted_at set) is None unless asked for."""
201
  con = _db()
202
  cur = con.cursor()
203
  cur.execute("SELECT * FROM file_assets WHERE id = ?", (asset_id,))
204
  row = cur.fetchone()
205
  con.close()
206
+ if not row:
207
+ return None
208
+ asset = dict(row)
209
+ if asset.get("deleted_at") and not include_deleted:
210
+ return None
211
+ return asset
212
+
213
+
214
+ def _remove_object(rel_path: str, backend: Optional[str] = None) -> bool:
215
+ """Remove the bytes behind a file_assets row, from the backend the row names. Immediate,
216
+ as before: soft deletion with deferred removal is the multi-user phase
217
+ (docs/design/MEDIA_STORAGE.md)."""
218
+ if not rel_path:
219
+ return False
220
+ try:
221
+ storage = _store_for(backend, rel_path)
222
+ if storage is None:
223
+ print(f"[CLEANUP] {rel_path!r} is in media backend {backend!r}, which this HomePilot is not "
224
+ f"configured for (MEDIA_STORAGE_BACKEND={backend_name()}); the object was left in place.")
225
+ return False
226
+ return storage.delete(rel_path)
227
+ except Exception as e:
228
+ print(f"[CLEANUP] Failed to delete file {rel_path}: {e}")
229
+ return False
230
 
231
 
232
+ def _trash_on() -> bool:
233
+ """Deleted assets go to the trash first (MEDIA_TRASH_RETENTION_DAYS, 7 by default); 0 means
234
+ bytes and row go at once, as before Phase 4."""
235
+ return media_ops.trash_retention_days() > 0
236
+
237
+
238
+ def delete_asset_and_file(asset_id: str, *, purge: bool = False) -> bool:
239
  """
240
+ Delete a file asset. The row is marked deleted and disappears from every read; its bytes
241
+ stay until the trash is purged (media_ops.purge_trash, run by the backend), so a mistaken
242
+ delete is recoverable for the retention window. ``purge=True`` β€” or a retention of 0 β€”
243
+ removes the bytes and the row now. Returns True if the record was deleted either way.
244
  """
245
  if not asset_id:
246
  return False
247
  con = _db()
248
+ _ensure_backend_column(con)
249
  cur = con.cursor()
250
+ cur.execute("SELECT id, rel_path, storage_backend, deleted_at FROM file_assets WHERE id = ?", (asset_id,))
251
  row = cur.fetchone()
252
  if not row:
253
  con.close()
254
  return False
255
 
256
+ if purge or not _trash_on():
257
+ _remove_object(row["rel_path"], row["storage_backend"] or "local")
258
+ cur.execute("DELETE FROM file_assets WHERE id = ?", (asset_id,))
259
+ else:
260
+ stamp = _now()
261
+ cur.execute("UPDATE file_assets SET deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?",
262
+ (stamp, stamp, asset_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  removed = cur.rowcount > 0
264
  con.commit()
265
  con.close()
 
282
  Returns dict with asset_id and url (/files/<asset_id>).
283
  """
284
  ext = "png" if "png" in (mime or "") else "jpg"
 
 
285
  fname = f"{uuid.uuid4().hex}.{ext}"
286
+ rel_path = f"{_user_key(user_id, 'image', project_id=project_id)}/{fname}"
287
+ get_storage().put(rel_path, image_bytes)
 
 
288
 
289
  asset_id = insert_asset(
290
  user_id=user_id,
 
295
  original_name=fname,
296
  project_id=project_id,
297
  conversation_id=conversation_id,
298
+ sha256=hashlib.sha256(image_bytes).hexdigest(),
299
  )
300
 
301
+ return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime or "image/png", "rel_path": rel_path}
302
 
303
 
304
  def save_generated_media_as_asset(
 
314
  extension = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip())
315
  allowed_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
316
  extension = extension if extension in allowed_extensions else ".bin"
 
317
  filename = f"{uuid.uuid4().hex}{extension}"
318
+ rel_path = f"{_user_key(user_id, safe_kind, project_id=project_id)}/{filename}"
319
+ get_storage().put(rel_path, media_bytes)
320
  asset_id = insert_asset(
321
  user_id=user_id,
322
  kind=safe_kind,
323
+ rel_path=rel_path,
324
  mime=mime or "application/octet-stream",
325
  size_bytes=len(media_bytes),
326
  original_name=filename,
327
  project_id=project_id,
328
  conversation_id=conversation_id,
329
+ sha256=hashlib.sha256(media_bytes).hexdigest(),
330
  )
331
+ return {"asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime, "rel_path": rel_path}
332
 
333
 
334
  # ---------------------------------------------------------------------------
 
497
  projects/<id>/persona/appearance/).
498
  """
499
  con = _db()
500
+ _ensure_backend_column(con)
501
  cur = con.cursor()
502
  cur.execute(
503
+ "SELECT id, rel_path, storage_backend FROM file_assets WHERE conversation_id = ? AND deleted_at IS NULL",
504
  (conversation_id,),
505
  )
506
  rows = cur.fetchall()
 
507
  deleted = 0
508
 
509
+ if _trash_on():
510
+ # To the trash: gone from every read now, bytes removed when the retention has passed.
511
+ stamp = _now()
512
+ for row in rows:
513
+ cur.execute("UPDATE file_assets SET deleted_at = ?, updated_at = ? WHERE id = ?", (stamp, stamp, row["id"]))
514
+ deleted += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  con.commit()
516
+ else:
517
+ for row in rows:
518
+ if _remove_object(row["rel_path"], row["storage_backend"] or "local"):
519
+ deleted += 1
520
+ if rows:
521
+ cur.execute("DELETE FROM file_assets WHERE conversation_id = ?", (conversation_id,))
522
+ con.commit()
523
 
524
  con.close()
525
+ print(f"[CLEANUP] {'Trashed' if _trash_on() else 'Deleted'} {deleted} files for conversation {conversation_id}")
526
  return deleted
527
 
528
 
 
604
  if len(data) > max_bytes:
605
  raise HTTPException(413, f"File too large (max {MAX_UPLOAD_MB}MB)")
606
 
 
607
  original_name = file.filename or ""
608
  content_type = (file.content_type or "").lower()
609
+ # Before any byte is stored: what it claims to be, the owner's quota, the scanner if one is set.
610
+ media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type, original_name)
611
+ media_quota.check(user["id"], len(data))
612
+ media_validate.scan_bytes(data, original_name)
613
+
614
+ # Choose extension
615
  ext = ""
616
  if original_name and "." in original_name:
617
  ext = original_name.rsplit(".", 1)[1].lower()
618
  if not ext:
619
  ext = (mimetypes.guess_extension(content_type) or "").lstrip(".") or "bin"
620
 
621
+ # Write to the user's area in media storage
 
622
  fname = f"{uuid.uuid4().hex}.{ext}"
623
+ rel_path = f"{_user_key(user['id'], kind, project_id=project_id)}/{fname}"
624
+ get_storage().put(rel_path, data)
625
 
626
  # Store metadata
627
+ mime = content_type or mimetypes.guess_type(fname)[0] or "application/octet-stream"
 
628
  asset_id = insert_asset(
629
  user_id=user["id"],
630
  kind=kind,
 
634
  original_name=original_name,
635
  project_id=project_id,
636
  conversation_id=conversation_id,
637
+ sha256=hashlib.sha256(data).hexdigest(),
638
  )
639
 
640
  return {"ok": True, "asset_id": asset_id, "url": f"/files/{asset_id}", "mime": mime}
641
 
642
 
643
+ @router.get("/v1/files/usage")
644
+ def files_usage(
645
+ authorization: str = Header(default=""),
646
+ homepilot_session: Optional[str] = Cookie(default=None),
647
+ ):
648
+ """How much of their media quota the caller uses (quota_bytes 0: no limit)."""
649
+ user = _resolve_user(authorization, homepilot_session)
650
+ if not user:
651
+ raise HTTPException(401, "Authentication required")
652
+ return {"ok": True, **media_quota.usage(user["id"])}
653
+
654
+
655
+ @router.get("/v1/files/{asset_id}/link")
656
+ def file_link(
657
+ asset_id: str,
658
+ ttl: int = Query(default=media_links.DEFAULT_TTL_S, ge=1, le=media_links.MAX_TTL_S),
659
+ authorization: str = Header(default=""),
660
+ homepilot_session: Optional[str] = Cookie(default=None),
661
+ ):
662
+ """A short-lived link to one of the caller's files β€” for an <img> on another origin or a
663
+ share β€” that carries no account credential: /files/{id}?exp=…&sig=…"""
664
+ user = _resolve_user(authorization, homepilot_session)
665
+ if not user:
666
+ raise HTTPException(401, "Authentication required")
667
+ asset = get_asset(asset_id)
668
+ if not asset or asset.get("user_id") != user["id"]:
669
+ raise HTTPException(404, "Not found")
670
+ signed = media_links.sign(asset_id, ttl)
671
+ return {"ok": True, "asset_id": asset_id, "url": signed["url"], "expires_at": signed["exp"]}
672
+
673
+
674
  # ---------------------------------------------------------------------------
675
  # Secure Download / Serve Endpoint
676
  # ---------------------------------------------------------------------------
 
687
  return name.startswith(("avatar_", "outfit_", "thumb_"))
688
 
689
 
690
+ def _is_user_avatar(name: str) -> bool:
691
+ """Some account's current profile picture: the login screen shows those before anyone is
692
+ signed in, so they are public by design."""
693
+ try:
694
+ con = _db()
695
+ row = con.execute("SELECT 1 FROM users WHERE avatar_url = ? LIMIT 1", (f"/files/{name}",)).fetchone()
696
+ con.close()
697
+ return row is not None
698
+ except Exception:
699
+ return False
700
+
701
+
702
+ def _is_public_flat_file(name: str) -> bool:
703
+ """A flat file served without an account: a user's current avatar, or a persona visual that
704
+ also lives under a project's appearance folder. Any other avatar_*/outfit_*/thumb_* file
705
+ now needs an account, a signed link or the API key (audit item A1)."""
706
+ if not _is_public_file(name):
707
+ return False
708
+ return _is_user_avatar(name) or _find_persona_file(name) is not None
709
+
710
+
711
+ def _is_persona_visual_path(parts: tuple) -> bool:
712
+ """projects/<id>/persona/appearance/<avatar_|outfit_|thumb_…>: the persona's own pictures,
713
+ public by design (the gallery, OllaBridge's VR chatbot)."""
714
+ return (len(parts) >= 4 and parts[0] == "projects" and "persona" in parts and "appearance" in parts
715
+ and _is_public_file(parts[-1]))
716
+
717
+
718
+ def _owns_path(user: Dict[str, Any], parts: tuple) -> bool:
719
+ """A path under users/<id>/ belongs to that account alone (audit item A2); anything else on
720
+ the legacy routes is as before β€” flat root files, project folders."""
721
+ if len(parts) >= 2 and parts[0] == "users":
722
+ return parts[1] == str(user.get("id"))
723
+ return True
724
+
725
+
726
+ def _serve_asset(asset: Dict[str, Any]):
727
+ key = str(asset.get("rel_path") or "")
728
+ return _serve_object(
729
+ key,
730
+ asset.get("mime") or "application/octet-stream",
731
+ asset.get("original_name") or os.path.basename(key.replace("\\", "/")),
732
+ backend=asset.get("storage_backend") or "local",
733
+ )
734
+
735
+
736
  def _find_persona_file(filename: str) -> Optional[Path]:
737
  """
738
  Search all project appearance directories for a file by name.
 
751
  return None
752
 
753
 
754
+ def _serve_object(key: str, mime: str, filename: str, backend: Optional[str] = None):
755
+ """Serve an object from media storage β€” the backend its registry row names, or for a key
756
+ with no row whichever has it: straight off the disk when that is local (a FileResponse,
757
+ as before), streamed in 1 MB chunks otherwise."""
758
+ storage = _store_for(backend, key)
759
+ if storage is None:
760
+ print(f"[FILES] {key!r} is in media backend {backend!r}, which this HomePilot is not configured "
761
+ f"for (MEDIA_STORAGE_BACKEND={backend_name()}); configure it again to serve the file.")
762
+ raise HTTPException(404, "Not found")
763
+ try:
764
+ if not storage.exists(key):
765
+ raise HTTPException(404, "Not found")
766
+ local = storage.local_path(key)
767
+ if local is not None:
768
+ return FileResponse(path=str(local), media_type=mime, filename=filename, headers=_FILE_CACHE_HEADERS)
769
+ stream = storage.open(key)
770
+ except StorageKeyError:
771
+ raise HTTPException(404, "Not found")
772
+
773
+ def _chunks():
774
+ try:
775
+ while True:
776
+ chunk = stream.read(1024 * 1024)
777
+ if not chunk:
778
+ break
779
+ yield chunk
780
+ finally:
781
+ stream.close()
782
+
783
+ safe_name = filename.replace('"', "")
784
+ headers = {**_FILE_CACHE_HEADERS, "Content-Disposition": f'inline; filename="{safe_name}"'}
785
+ return StreamingResponse(_chunks(), media_type=mime, headers=headers)
786
+
787
+
788
+ def _object_or_404(key: str, filename: str):
789
+ """A path-based route's last resort: the object under ``key`` in the configured storage,
790
+ when that is not this disk (an avatar or background written after a switch to S3)."""
791
+ try:
792
+ storage = get_storage()
793
+ if storage.name != "local" and storage.exists(key):
794
+ return _serve_object(key, mimetypes.guess_type(filename)[0] or "application/octet-stream", filename)
795
+ except Exception:
796
+ pass
797
+ raise HTTPException(404, "Not found")
798
+
799
+
800
+ def resolve_local_file(file_ref: str) -> Optional[Path]:
801
+ """A readable path on this disk for a ``/files/<ref>`` reference β€” a flat key or a
802
+ file_assets id β€” for code that hands files to ComfyUI or copies them: the object itself
803
+ when it is on the disk, else a copy fetched from the configured storage into
804
+ ``<upload root>/.cache/media/<key>`` (kept; the same key resolves to the same path, and
805
+ re-fetched when the size differs). None when nothing is found."""
806
+ ref = (file_ref or "").split("?")[0].strip().strip("/")
807
+ if not ref or ".." in Path(ref.replace("\\", "/")).parts:
808
+ return None
809
+ candidates: list = [(ref, None)] # a flat key: whichever store has it
810
+ try:
811
+ asset = get_asset(ref)
812
+ except Exception:
813
+ asset = None
814
+ if asset and asset.get("rel_path"): # a registered asset: its own backend
815
+ candidates.insert(0, (str(asset["rel_path"]), asset.get("storage_backend") or "local"))
816
+ for key, backend in candidates:
817
+ try:
818
+ storage = _store_for(backend, key)
819
+ if storage is None or not storage.exists(key):
820
+ continue
821
+ local = storage.local_path(key)
822
+ if local is not None:
823
+ return local
824
+ cache_key = f".cache/media/{normalize_key(key)}"
825
+ disk = local_storage()
826
+ if not disk.exists(cache_key) or disk.size(cache_key) != storage.size(key):
827
+ with storage.open(key) as src:
828
+ disk.put(cache_key, src)
829
+ return disk.local_path(cache_key)
830
+ except StorageKeyError:
831
+ continue
832
+ except Exception as e:
833
+ print(f"[FILES] Could not resolve {key!r} to a local file: {e}")
834
+ continue
835
+ return None
836
+
837
+
838
  @router.get("/files/{asset_id}")
839
  def download_file(
840
  asset_id: str,
841
  authorization: str = Header(default=""),
842
  homepilot_session: Optional[str] = Cookie(default=None),
843
  token: Optional[str] = Query(default=None, alias="token"),
844
+ exp: Optional[str] = Query(default=None),
845
+ sig: Optional[str] = Query(default=None),
846
  ):
847
  """
848
+ Secure file serving β€” the endpoint behind <img src="/files/...">.
849
+
850
+ A registered asset is served to its owner (session, cookie, or ?token= for an <img> on
851
+ another origin), to anyone with a signed link for it (?exp=&sig=, GET /v1/files/{id}/link),
852
+ and to everyone when its visibility is public. A flat file is public only when it is a
853
+ user's current avatar or a persona visual; other flat files need an account.
854
  """
855
+ asset = get_asset(asset_id)
856
+ if asset and (asset.get("visibility") == "public" or media_links.verify(asset_id, exp, sig)):
857
+ return _serve_asset(asset)
858
+
859
+ # A public flat file: a login-screen avatar or a persona visual.
860
+ if not asset and _is_public_flat_file(asset_id):
861
  safe = Path(asset_id)
862
  abs_path = _upload_root() / safe
863
  if abs_path.exists() and abs_path.is_file():
864
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
865
  return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
866
+ return _object_or_404(asset_id, safe.name)
867
 
868
  user = _resolve_user(authorization, homepilot_session, token_param=token)
869
  if not user:
870
  raise HTTPException(401, "Authentication required")
871
 
 
872
  if asset:
873
  if asset.get("user_id") != user["id"]:
874
  raise HTTPException(404, "Not found")
875
+ return _serve_asset(asset)
 
 
 
 
 
 
 
 
 
 
 
876
 
877
  # Fallback: serve from flat uploads dir (avatars, legacy files)
878
  safe = Path(asset_id)
 
883
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
884
  return FileResponse(path=str(abs_path), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
885
 
886
+ return _object_or_404(asset_id, safe.name)
887
 
888
 
889
  # ---------------------------------------------------------------------------
 
898
  token: Optional[str] = Query(default=None, alias="token"),
899
  ):
900
  """
901
+ Fallback for legacy /files/<path> URLs (pre-asset era) and persona visuals.
902
+ A persona's own pictures (projects/<id>/persona/appearance/…) are public; a path under
903
+ users/<id>/ is served to that account only; other paths need an account, as before.
904
  Supports ?token=<session_token> for <img> tags that can't set headers.
 
905
  """
906
  # Prevent path traversal
907
+ safe = Path(subpath.replace("\\", "/"))
908
  if ".." in safe.parts:
909
  raise HTTPException(400, "Invalid path")
910
 
911
+ # Public without an account: a persona's own pictures, or a public flat file.
912
+ if _is_persona_visual_path(safe.parts) or (len(safe.parts) == 1 and _is_public_flat_file(safe.name)):
913
  abs_path = _upload_root() / safe
914
  if abs_path.exists() and abs_path.is_file():
915
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
 
920
  if found:
921
  mime = mimetypes.guess_type(str(found))[0] or "application/octet-stream"
922
  return FileResponse(path=str(found), media_type=mime, filename=safe.name, headers=_FILE_CACHE_HEADERS)
923
+ return _object_or_404(subpath, safe.name)
924
 
925
  user = _resolve_user(authorization, homepilot_session, token_param=token)
926
  if not user:
927
  raise HTTPException(401, "Authentication required")
928
+ if not _owns_path(user, safe.parts):
929
+ raise HTTPException(404, "Not found")
930
 
931
  abs_path = _upload_root() / safe
932
  if not abs_path.exists() or not abs_path.is_file():
933
  # Fallback: search project appearance dirs for the file by name
934
  found = _find_persona_file(safe.name) if "persona" in safe.parts else None
935
  if not found:
936
+ return _object_or_404(subpath, safe.name)
937
  abs_path = found
938
 
939
  mime = mimetypes.guess_type(str(abs_path))[0] or "application/octet-stream"
backend/app/intent_guard.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """When a keyword shortcut may answer instead of the model.
2
+
3
+ Persona chats have deterministic fast paths ("show me your back" β†’ the back view, "photo 2" β†’
4
+ the second photo) and prompt hints built from the same keywords. They scan for words such as
5
+ "back", "first", "front", "more" β€” which any long text contains. A user who pasted a work log
6
+ and asked "give a summary" got "I only have the front in my look for now. Here it is!": the
7
+ paste said "moved HomePilot back to 8002" and "the reply first shows…".
8
+
9
+ A display command is short. Anything longer β€” a question with context, a pasted document, a
10
+ composed message β€” goes to the model, which reads all of it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ MAX_COMMAND_CHARS = 160
15
+ MAX_COMMAND_WORDS = 24
16
+ MAX_COMMAND_LINES = 2
17
+
18
+
19
+ def is_short_command(message: str) -> bool:
20
+ """Whether ``message`` is short enough to be a display command a keyword shortcut may
21
+ answer: at most two lines, 160 characters and 24 words."""
22
+ text = (message or "").strip()
23
+ if not text:
24
+ return False
25
+ lines = [line for line in text.splitlines() if line.strip()]
26
+ if len(lines) > MAX_COMMAND_LINES:
27
+ return False
28
+ return len(text) <= MAX_COMMAND_CHARS and len(text.split()) <= MAX_COMMAND_WORDS
backend/app/inventory.py CHANGED
@@ -214,16 +214,23 @@ def _db_list_project_files(project_id: str, *, user_id: str = "") -> List[Dict[s
214
  if not conn:
215
  return []
216
  try:
 
 
 
 
 
 
 
217
  if user_id:
218
  cur = conn.execute(
219
  "SELECT id, kind, rel_path, mime, size_bytes, original_name "
220
- "FROM file_assets WHERE project_id = ? AND user_id = ?",
221
  (project_id, user_id),
222
  )
223
  else:
224
  cur = conn.execute(
225
  "SELECT id, kind, rel_path, mime, size_bytes, original_name "
226
- "FROM file_assets WHERE project_id = ?",
227
  (project_id,),
228
  )
229
  out: List[Dict[str, Any]] = []
 
214
  if not conn:
215
  return []
216
  try:
217
+ # Files in the trash (deleted_at set) are not inventory; a database from before that
218
+ # column is read as it was.
219
+ try:
220
+ conn.execute("SELECT deleted_at FROM file_assets LIMIT 0")
221
+ trash = " AND deleted_at IS NULL"
222
+ except sqlite3.OperationalError:
223
+ trash = ""
224
  if user_id:
225
  cur = conn.execute(
226
  "SELECT id, kind, rel_path, mime, size_bytes, original_name "
227
+ f"FROM file_assets WHERE project_id = ? AND user_id = ?{trash}",
228
  (project_id, user_id),
229
  )
230
  else:
231
  cur = conn.execute(
232
  "SELECT id, kind, rel_path, mime, size_bytes, original_name "
233
+ f"FROM file_assets WHERE project_id = ?{trash}",
234
  (project_id,),
235
  )
236
  out: List[Dict[str, Any]] = []
backend/app/main.py CHANGED
@@ -126,6 +126,9 @@ from .multimodal import analyze_image, is_vision_intent, VISION_MODEL_PATTERNS
126
  # OpenAI-compatible endpoint (additive β€” exposes personas as /v1/chat/completions)
127
  from .openai_compat_endpoint import router as openai_compat_router
128
 
 
 
 
129
  # ── Access-log noise filter ──────────────────────────────────────────────────
130
  # The Interactive runtime polls GET /v1/interactive/play/sessions/{id}/pending
131
  # once per 1.5–5 s to pick up scene-render updates. Uvicorn logs every hit at
@@ -403,10 +406,18 @@ try:
403
  import sys as _expert_sys
404
  from pathlib import Path as _ExpertPath
405
 
406
- _expert_pkg_dir = (
407
- _ExpertPath(__file__).resolve().parents[2]
408
- / "expert" / "backend" / "app" / "expert"
409
- )
 
 
 
 
 
 
 
 
410
  _expert_init = _expert_pkg_dir / "__init__.py"
411
  if not _expert_init.is_file():
412
  raise FileNotFoundError(f"Expert package not found at {_expert_init}")
@@ -1074,9 +1085,10 @@ def _extract_photo_number(msg: str) -> int | None:
1074
  if num_match:
1075
  return int(num_match.group(1))
1076
 
1077
- # Match patterns like "the second one", "second photo", "the 2nd"
 
1078
  for word, idx in _ORDINAL_MAP.items():
1079
- if word in m:
1080
  return idx
1081
 
1082
  # Match patterns like "the 2nd", "the 3rd", "the 1st"
@@ -1109,6 +1121,12 @@ def _is_photo_intent(msg: str) -> bool:
1109
  if not m:
1110
  return False
1111
 
 
 
 
 
 
 
1112
  # ---- PASS-THROUGH: let the LLM handle questions and conversation ----
1113
  # Questions starting with question words β†’ LLM reasons
1114
  if _re.search(r'^(?:what|explain|describe|tell|list|how many|which|why|who|where|when|do you|can you|have you|are you|could you|would you)\b', m):
@@ -1269,6 +1287,19 @@ def _startup() -> None:
1269
  startup_reconcile()
1270
  except Exception as e:
1271
  print(f"Warning: Asset registry reconcile failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
1272
  # Files mount
1273
  _ensure_static_mount()
1274
 
@@ -2465,9 +2496,16 @@ async def list_models(
2465
  remedy_extra: Dict[str, Any] = {}
2466
  if provider == "ollama":
2467
  from .model_resolver import active_runtime_url as _active_runtime_url
 
2468
  from .model_resolver import resolve as _resolve_model
2469
- if not base_url:
2470
- base_url = await _active_runtime_url()
 
 
 
 
 
 
2471
 
2472
  # OpenAI-compatible endpoints (an OllaBridge gateway) also say *what* each model is β€”
2473
  # a base model or an assistant built on one β€” which a picker needs to choose a
@@ -2864,17 +2902,9 @@ async def install_model(req: ModelInstallRequest) -> JSONResponse:
2864
  logger.addHandler(handler)
2865
 
2866
  try:
2867
- script_path = Path(__file__).parent.parent.parent / "scripts" / "download.py"
2868
-
2869
- if not script_path.exists():
2870
- logger.error(f"Download script not found at {script_path}")
2871
- return JSONResponse(
2872
- status_code=500,
2873
- content=_safe_err(
2874
- "Download script not found. Please ensure scripts/download.py exists.",
2875
- code="script_not_found",
2876
- ),
2877
- )
2878
 
2879
  logger.info(f"Starting model installation: provider={req.provider}, model={req.model_id}")
2880
 
@@ -2951,6 +2981,17 @@ async def install_model(req: ModelInstallRequest) -> JSONResponse:
2951
  )
2952
 
2953
  # Run download script for comfyui and civitai
 
 
 
 
 
 
 
 
 
 
 
2954
  # Pass Civitai API key via environment variable (secure - not visible in process list)
2955
  env = os.environ.copy()
2956
  if req.civitai_api_key and req.provider == "civitai":
@@ -3545,17 +3586,12 @@ def _resolve_local_file_to_upload_root(url: str, upload_root: Path) -> Optional[
3545
  if ".." in file_ref:
3546
  return None
3547
 
 
 
3548
  local_path = upload_root / file_ref
3549
  if not local_path.exists():
3550
- # Try resolving via file_assets table
3551
- try:
3552
- from .files import get_asset, _upload_root
3553
- asset = get_asset(file_ref)
3554
- if asset and asset.get("rel_path"):
3555
- local_path = _upload_root() / asset["rel_path"]
3556
- except Exception:
3557
- pass
3558
-
3559
  if not local_path.exists():
3560
  return None
3561
 
@@ -3638,12 +3674,20 @@ async def _download_comfy_image(url: str, upload_root: Path) -> str:
3638
  ) as client:
3639
  resp = await client.get(url)
3640
  resp.raise_for_status()
3641
- dest.write_bytes(resp.content)
 
 
 
 
 
 
 
3642
 
3643
  # Register in asset registry for durability
3644
  try:
3645
  from .asset_registry import register_comfy_output
3646
- register_comfy_output(filename=filename, dest_path=str(dest))
 
3647
  except Exception as e:
3648
  print(f"[ASSET_REGISTRY] Warning: failed to register {filename}: {e}")
3649
 
@@ -4299,36 +4343,45 @@ async def upload_project_item(
4299
  except Exception:
4300
  pass # Non-fatal: proceed with upload if dedup check fails
4301
 
4302
- # Save to disk
 
 
 
4303
  import mimetypes as _mt
 
 
 
 
 
 
 
 
4304
  save_ext = f".{ext}" if ext else ".bin"
4305
  save_name = f"{uuidlib.uuid4().hex}{save_ext}"
4306
- save_path = UPLOAD_PATH / save_name
4307
- save_path.write_bytes(data)
 
 
 
 
4308
 
4309
- mime = content_type or _mt.guess_type(str(save_path))[0] or "application/octet-stream"
4310
  base = _base_url_from_request(request)
4311
 
4312
  # Register as secure asset if user is authenticated
4313
  asset_id = ""
4314
- file_url = f"{base}/files/{save_name}"
4315
  if user_id:
4316
  try:
4317
- from .files import _ensure_user_dir, _upload_root, insert_asset
4318
- folder = _ensure_user_dir(user_id, "project_asset", project_id=project_id)
4319
- import shutil
4320
- dest = folder / save_name
4321
- shutil.move(str(save_path), str(dest))
4322
- save_path = dest
4323
- rel_path = str(dest.relative_to(_upload_root()))
4324
  asset_id = insert_asset(
4325
  user_id=user_id,
4326
  kind="project_asset",
4327
- rel_path=rel_path,
4328
  mime=mime,
4329
  size_bytes=len(data),
4330
  original_name=filename,
4331
  project_id=project_id,
 
4332
  )
4333
  file_url = f"{base}/files/{asset_id}"
4334
  except Exception as asset_err:
@@ -5584,65 +5637,71 @@ async def upload(request: Request, file: UploadFile = File(...), authorization:
5584
  except Exception:
5585
  pass
5586
 
 
 
5587
  if _uid:
5588
- # Secure path: store under per-user directory and register asset
5589
- from .files import _ensure_user_dir, _upload_root, insert_asset
5590
- import mimetypes as _mt
5591
- folder = _ensure_user_dir(_uid, "upload")
5592
- name = f"{uuidlib.uuid4().hex}{ext}"
5593
- path = folder / name
5594
  else:
5595
- # Legacy path: flat UPLOAD_PATH
5596
- name = f"{uuidlib.uuid4().hex}{ext}"
5597
- path = UPLOAD_PATH / name
5598
 
5599
  written = 0
5600
  chunk_size = 1024 * 1024 # 1MB
 
 
5601
 
5602
- # Atomic write: write to .tmp first, then rename on success
5603
- tmp_path = path.with_suffix(path.suffix + ".tmp")
 
 
5604
  try:
5605
- with tmp_path.open("wb") as f:
5606
- while True:
5607
- chunk = await file.read(chunk_size)
5608
- if not chunk:
5609
- break
5610
- written += len(chunk)
5611
- if written > max_bytes:
5612
- raise HTTPException(
5613
- status_code=413,
5614
- detail=f"File too large. Max {MAX_UPLOAD_MB}MB.",
5615
- )
5616
- f.write(chunk)
5617
- f.flush()
5618
- os.fsync(f.fileno())
5619
- # Atomic rename
5620
- tmp_path.rename(path)
 
 
 
 
 
 
5621
  except OSError as e:
5622
- # Clean up partial file
5623
- tmp_path.unlink(missing_ok=True)
5624
  raise HTTPException(
5625
  status_code=500,
5626
- detail=f"Upload storage error: {e}. Upload dir: {path.parent}",
5627
  )
5628
- finally:
5629
- await file.close()
5630
 
5631
  base = _base_url_from_request(request)
5632
 
5633
  if _uid:
5634
  # Register as a secure asset
5635
- from .files import _upload_root, insert_asset
5636
  import mimetypes as _mt
5637
- rel_path = str(path.relative_to(_upload_root()))
5638
- mime = _mt.guess_type(str(path))[0] or "application/octet-stream"
5639
  asset_id = insert_asset(
5640
  user_id=_uid,
5641
  kind="upload",
5642
- rel_path=rel_path,
5643
  mime=mime,
5644
  size_bytes=written,
5645
  original_name=filename,
 
5646
  )
5647
  return JSONResponse(status_code=201, content={"url": f"{base}/files/{asset_id}"})
5648
  else:
 
126
  # OpenAI-compatible endpoint (additive β€” exposes personas as /v1/chat/completions)
127
  from .openai_compat_endpoint import router as openai_compat_router
128
 
129
+ # Where scripts/download.py is β€” the repo's, or the copy a packaged image ships.
130
+ from .download_script import download_script
131
+
132
  # ── Access-log noise filter ──────────────────────────────────────────────────
133
  # The Interactive runtime polls GET /v1/interactive/play/sessions/{id}/pending
134
  # once per 1.5–5 s to pick up scene-render updates. Uvicorn logs every hit at
 
406
  import sys as _expert_sys
407
  from pathlib import Path as _ExpertPath
408
 
409
+ _expert_here = _ExpertPath(__file__).resolve()
410
+ _expert_candidates = [
411
+ _expert_here.parents[2] / "expert" / "backend" / "app" / "expert", # the repo
412
+ # A packaged image has no repo around it: the Hugging Face Space ships the
413
+ # package next to this one (/app/expert). Without it the chat's default path,
414
+ # POST /v1/expert/chat, did not exist there and every chat came back HTTP 405.
415
+ _expert_here.parents[1] / "expert",
416
+ ]
417
+ if os.getenv("EXPERT_PACKAGE_DIR"):
418
+ _expert_candidates.insert(0, _ExpertPath(os.environ["EXPERT_PACKAGE_DIR"]))
419
+ _expert_pkg_dir = next((p for p in _expert_candidates if (p / "__init__.py").is_file()),
420
+ _expert_candidates[0])
421
  _expert_init = _expert_pkg_dir / "__init__.py"
422
  if not _expert_init.is_file():
423
  raise FileNotFoundError(f"Expert package not found at {_expert_init}")
 
1085
  if num_match:
1086
  return int(num_match.group(1))
1087
 
1088
+ # Match patterns like "the second one", "second photo", "the 2nd" β€” as whole words, so
1089
+ # "firstly" or "a thirdparty tool" is not a photo number.
1090
  for word, idx in _ORDINAL_MAP.items():
1091
+ if _re.search(rf"\b{word}\b", m):
1092
  return idx
1093
 
1094
  # Match patterns like "the 2nd", "the 3rd", "the 1st"
 
1121
  if not m:
1122
  return False
1123
 
1124
+ # ---- PASS-THROUGH: anything longer than a command (a pasted document, a question with
1125
+ # context) goes to the LLM β€” its words ("back", "first", "more") are not a request.
1126
+ from .intent_guard import is_short_command
1127
+ if not is_short_command(m):
1128
+ return False
1129
+
1130
  # ---- PASS-THROUGH: let the LLM handle questions and conversation ----
1131
  # Questions starting with question words β†’ LLM reasons
1132
  if _re.search(r'^(?:what|explain|describe|tell|list|how many|which|why|who|where|when|do you|can you|have you|are you|could you|would you)\b', m):
 
1287
  startup_reconcile()
1288
  except Exception as e:
1289
  print(f"Warning: Asset registry reconcile failed: {e}")
1290
+ # Media storage: say which backend holds the bytes; reach a remote one now, off the loop,
1291
+ # so the first upload does not pay for loading its client (docs/design/MEDIA_STORAGE.md).
1292
+ try:
1293
+ from .media_storage import warm_up as _media_storage_warm_up
1294
+ _media_storage_warm_up()
1295
+ except Exception as e:
1296
+ print(f"Warning: media storage warm-up failed: {e}")
1297
+ # Deleted media waits in the trash for MEDIA_TRASH_RETENTION_DAYS; this thread purges it.
1298
+ try:
1299
+ from .media_ops import start_trash_gc as _start_trash_gc
1300
+ _start_trash_gc()
1301
+ except Exception as e:
1302
+ print(f"Warning: media trash purge not started: {e}")
1303
  # Files mount
1304
  _ensure_static_mount()
1305
 
 
2496
  remedy_extra: Dict[str, Any] = {}
2497
  if provider == "ollama":
2498
  from .model_resolver import active_runtime_url as _active_runtime_url
2499
+ from .model_resolver import ollama_root as _ollama_root
2500
  from .model_resolver import resolve as _resolve_model
2501
+ # An explicit Base URL that is not an Ollama (OllaBridge's /v1, say) falls back to
2502
+ # the Ollama that answers, and the response says where the models came from.
2503
+ asked = base_url
2504
+ base_url = await _active_runtime_url(base_url)
2505
+ if asked and base_url not in (_ollama_root(asked), asked.rstrip("/")):
2506
+ remedy_extra["note"] = (
2507
+ f"{asked} is not an Ollama runtime; listing the models of Ollama at {base_url}."
2508
+ )
2509
 
2510
  # OpenAI-compatible endpoints (an OllaBridge gateway) also say *what* each model is β€”
2511
  # a base model or an assistant built on one β€” which a picker needs to choose a
 
2902
  logger.addHandler(handler)
2903
 
2904
  try:
2905
+ # Only ComfyUI and Civitai installs run the script (checked below, once an Ollama pull
2906
+ # has returned): requiring it up front failed every install on the Space, pulls included.
2907
+ script_path = download_script()
 
 
 
 
 
 
 
 
2908
 
2909
  logger.info(f"Starting model installation: provider={req.provider}, model={req.model_id}")
2910
 
 
2981
  )
2982
 
2983
  # Run download script for comfyui and civitai
2984
+ if not script_path.is_file():
2985
+ logger.error(f"Download script not found at {script_path}")
2986
+ return JSONResponse(
2987
+ status_code=500,
2988
+ content=_safe_err(
2989
+ f"Download script not found at {script_path}. Set HOMEPILOT_DOWNLOAD_SCRIPT "
2990
+ "to the repo's scripts/download.py.",
2991
+ code="script_not_found",
2992
+ ),
2993
+ )
2994
+
2995
  # Pass Civitai API key via environment variable (secure - not visible in process list)
2996
  env = os.environ.copy()
2997
  if req.civitai_api_key and req.provider == "civitai":
 
3586
  if ".." in file_ref:
3587
  return None
3588
 
3589
+ # The folder we were handed first (as before), then the same reference as a flat key or
3590
+ # a file_assets id β€” on this disk, or fetched from the configured storage (files.py).
3591
  local_path = upload_root / file_ref
3592
  if not local_path.exists():
3593
+ from .files import resolve_local_file
3594
+ local_path = resolve_local_file(file_ref) or local_path
 
 
 
 
 
 
 
3595
  if not local_path.exists():
3596
  return None
3597
 
 
3674
  ) as client:
3675
  resp = await client.get(url)
3676
  resp.raise_for_status()
3677
+ # The flat key `filename`, written atomically through the storage interface. Every
3678
+ # caller passes UPLOAD_PATH, the media storage root; another folder (a test's temp dir)
3679
+ # is written as a local store of its own.
3680
+ from .media_storage import LocalStorage, get_storage, local_storage
3681
+ storage = get_storage()
3682
+ if Path(upload_root).resolve() != local_storage().root:
3683
+ storage = LocalStorage(upload_root)
3684
+ storage.put(filename, resp.content)
3685
 
3686
  # Register in asset registry for durability
3687
  try:
3688
  from .asset_registry import register_comfy_output
3689
+ register_comfy_output(filename=filename, dest_path=str(dest),
3690
+ storage_backend=storage.name, size_bytes=len(resp.content))
3691
  except Exception as e:
3692
  print(f"[ASSET_REGISTRY] Warning: failed to register {filename}: {e}")
3693
 
 
4343
  except Exception:
4344
  pass # Non-fatal: proceed with upload if dedup check fails
4345
 
4346
+ # Save through media storage: in the user's project area when there is a user (the
4347
+ # secure-asset layout), else the flat legacy key β€” one atomic write, not a flat write
4348
+ # and a move.
4349
+ import hashlib as _hashlib
4350
  import mimetypes as _mt
4351
+ from . import media_quota, media_validate
4352
+ from .files import _user_key, insert_asset
4353
+ from .media_storage import get_storage
4354
+ # Before any byte is stored: what it claims to be, the owner's quota, the scanner if set.
4355
+ media_validate.check_image_claim(data[:media_validate.HEAD_BYTES], content_type or "", filename)
4356
+ if user_id:
4357
+ media_quota.check(user_id, len(data))
4358
+ media_validate.scan_bytes(data, filename)
4359
  save_ext = f".{ext}" if ext else ".bin"
4360
  save_name = f"{uuidlib.uuid4().hex}{save_ext}"
4361
+ storage_key = (
4362
+ f"{_user_key(user_id, 'project_asset', project_id=project_id)}/{save_name}" if user_id else save_name
4363
+ )
4364
+ storage = get_storage()
4365
+ storage.put(storage_key, data)
4366
+ save_path = storage.local_path(storage_key) or (UPLOAD_PATH / storage_key)
4367
 
4368
+ mime = content_type or _mt.guess_type(save_name)[0] or "application/octet-stream"
4369
  base = _base_url_from_request(request)
4370
 
4371
  # Register as secure asset if user is authenticated
4372
  asset_id = ""
4373
+ file_url = f"{base}/files/{storage_key}"
4374
  if user_id:
4375
  try:
 
 
 
 
 
 
 
4376
  asset_id = insert_asset(
4377
  user_id=user_id,
4378
  kind="project_asset",
4379
+ rel_path=storage_key,
4380
  mime=mime,
4381
  size_bytes=len(data),
4382
  original_name=filename,
4383
  project_id=project_id,
4384
+ sha256=_hashlib.sha256(data).hexdigest(),
4385
  )
4386
  file_url = f"{base}/files/{asset_id}"
4387
  except Exception as asset_err:
 
5637
  except Exception:
5638
  pass
5639
 
5640
+ from .media_storage import get_storage
5641
+ name = f"{uuidlib.uuid4().hex}{ext}"
5642
  if _uid:
5643
+ # Secure path: the user's area, registered as an asset below
5644
+ from .files import _user_key
5645
+ key = f"{_user_key(_uid, 'upload')}/{name}"
 
 
 
5646
  else:
5647
+ # Legacy path: the flat root
5648
+ key = name
5649
+ storage = get_storage()
5650
 
5651
  written = 0
5652
  chunk_size = 1024 * 1024 # 1MB
5653
+ from . import media_quota, media_validate
5654
+ import hashlib as _hashlib
5655
 
5656
+ # Read it whole (bounded by MAX_UPLOAD_MB) and check it before any byte is stored: what it
5657
+ # claims to be, the owner's quota, the scanner if one is set. Then one atomic put.
5658
+ chunks: list = []
5659
+ digest = _hashlib.sha256()
5660
  try:
5661
+ while True:
5662
+ chunk = await file.read(chunk_size)
5663
+ if not chunk:
5664
+ break
5665
+ if not chunks:
5666
+ media_validate.check_image_claim(chunk[:media_validate.HEAD_BYTES], file.content_type or "", filename)
5667
+ written += len(chunk)
5668
+ if written > max_bytes:
5669
+ raise HTTPException(
5670
+ status_code=413,
5671
+ detail=f"File too large. Max {MAX_UPLOAD_MB}MB.",
5672
+ )
5673
+ digest.update(chunk)
5674
+ chunks.append(chunk)
5675
+ finally:
5676
+ await file.close()
5677
+ data = b"".join(chunks)
5678
+ if _uid:
5679
+ media_quota.check(_uid, written)
5680
+ media_validate.scan_bytes(data, filename)
5681
+ try:
5682
+ storage.put(key, data)
5683
  except OSError as e:
5684
+ local = storage.local_path(key)
 
5685
  raise HTTPException(
5686
  status_code=500,
5687
+ detail=f"Upload storage error: {e}. Upload dir: {local.parent if local else storage.name}",
5688
  )
 
 
5689
 
5690
  base = _base_url_from_request(request)
5691
 
5692
  if _uid:
5693
  # Register as a secure asset
5694
+ from .files import insert_asset
5695
  import mimetypes as _mt
5696
+ mime = _mt.guess_type(name)[0] or "application/octet-stream"
 
5697
  asset_id = insert_asset(
5698
  user_id=_uid,
5699
  kind="upload",
5700
+ rel_path=key,
5701
  mime=mime,
5702
  size_bytes=written,
5703
  original_name=filename,
5704
+ sha256=digest.hexdigest(),
5705
  )
5706
  return JSONResponse(status_code=201, content={"url": f"{base}/files/{asset_id}"})
5707
  else:
backend/app/media_links.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Signed links to one file β€” the grant for an <img> tag or a share that is not an account
3
+ credential (docs/design/MEDIA_STORAGE.md, audit item A3).
4
+
5
+ ``/files/<asset_id>?exp=<unix seconds>&sig=<hmac>``: valid for that asset until ``exp``,
6
+ nothing else. The key is ``MEDIA_SIGNING_KEY`` when set, else one generated once into
7
+ ``<data dir>/.media_signing_key`` (mode 600) next to the database.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import os
14
+ import secrets
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Dict, Optional
18
+
19
+ from .storage import _get_db_path
20
+
21
+ DEFAULT_TTL_S = 600
22
+ MAX_TTL_S = 7 * 24 * 3600
23
+ _key_cache: Optional[bytes] = None
24
+
25
+
26
+ def _key_file() -> Path:
27
+ return Path(_get_db_path()).parent / ".media_signing_key"
28
+
29
+
30
+ def signing_key() -> bytes:
31
+ global _key_cache
32
+ if _key_cache:
33
+ return _key_cache
34
+ env = (os.getenv("MEDIA_SIGNING_KEY") or "").strip()
35
+ if env:
36
+ _key_cache = env.encode("utf-8")
37
+ return _key_cache
38
+ path = _key_file()
39
+ try:
40
+ key = path.read_bytes().strip()
41
+ if key:
42
+ _key_cache = key
43
+ return key
44
+ except FileNotFoundError:
45
+ pass
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ key = secrets.token_hex(32).encode("ascii")
48
+ try:
49
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
50
+ with os.fdopen(fd, "wb") as f:
51
+ f.write(key + b"\n")
52
+ except FileExistsError:
53
+ key = path.read_bytes().strip()
54
+ _key_cache = key
55
+ return key
56
+
57
+
58
+ def reset_key_cache() -> None:
59
+ global _key_cache
60
+ _key_cache = None
61
+
62
+
63
+ def _signature(asset_id: str, exp: int) -> str:
64
+ message = f"{asset_id}|{int(exp)}".encode("utf-8")
65
+ return hmac.new(signing_key(), message, hashlib.sha256).hexdigest()
66
+
67
+
68
+ def sign(asset_id: str, ttl_s: int = DEFAULT_TTL_S, now: Optional[float] = None) -> Dict[str, object]:
69
+ """``{"exp": …, "sig": …, "url": "/files/<id>?exp=…&sig=…"}`` for ``asset_id``."""
70
+ ttl = max(1, min(int(ttl_s or DEFAULT_TTL_S), MAX_TTL_S))
71
+ exp = int((now if now is not None else time.time()) + ttl)
72
+ sig = _signature(asset_id, exp)
73
+ return {"exp": exp, "sig": sig, "url": f"/files/{asset_id}?exp={exp}&sig={sig}"}
74
+
75
+
76
+ def verify(asset_id: str, exp: Optional[str], sig: Optional[str], now: Optional[float] = None) -> bool:
77
+ """True when ``sig`` is this asset's signature for ``exp`` and ``exp`` is still ahead."""
78
+ if not asset_id or not exp or not sig:
79
+ return False
80
+ try:
81
+ exp_i = int(exp)
82
+ except (TypeError, ValueError):
83
+ return False
84
+ if exp_i < (now if now is not None else time.time()):
85
+ return False
86
+ return hmac.compare_digest(_signature(asset_id, exp_i), str(sig).strip())
backend/app/media_ops.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Operations on HomePilot's media (docs/design/MEDIA_STORAGE.md, Phases 3 and 4):
3
+
4
+ python -m app.media_ops verify every asset is where its row says, hashes match
5
+ python -m app.media_ops migrate [--to s3] copy assets into the configured backend, verify, flip the row
6
+ python -m app.media_ops backup <dir> database copy + every asset's bytes + a manifest
7
+ python -m app.media_ops restore <dir> put a backup's assets into the configured backend
8
+ python -m app.media_ops gc remove what has been in the trash longer than the retention
9
+
10
+ Nothing here deletes a source during a migration unless ``--prune-source`` says so, and a
11
+ copy is only recorded after it has been read back and its SHA-256 matched.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ import threading
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any, Callable, Dict, Iterator, List, Optional
26
+
27
+ from . import media_storage as _ms
28
+ from .media_storage import LocalStorage, MediaStorage
29
+ from .storage import _get_db_path, ensure_file_assets_columns
30
+
31
+
32
+ def get_storage() -> MediaStorage:
33
+ """Looked up on the module each time, so a configured (or test-substituted) backend is seen."""
34
+ return _ms.get_storage()
35
+
36
+
37
+ def storage_named(name: str) -> Optional[MediaStorage]:
38
+ return _ms.storage_named(name)
39
+
40
+ _CHUNK = 1024 * 1024
41
+ Log = Callable[[str], None]
42
+
43
+ ROW_COLUMNS = ("id", "user_id", "kind", "rel_path", "mime", "size_bytes", "original_name", "project_id",
44
+ "conversation_id", "storage_backend", "sha256", "visibility", "created_at")
45
+
46
+
47
+ def _db() -> sqlite3.Connection:
48
+ con = sqlite3.connect(_get_db_path())
49
+ con.row_factory = sqlite3.Row
50
+ ensure_file_assets_columns(con)
51
+ return con
52
+
53
+
54
+ def _rows(con: sqlite3.Connection, where: str = "deleted_at IS NULL", params: tuple = ()) -> List[sqlite3.Row]:
55
+ return con.execute(f"SELECT {', '.join(ROW_COLUMNS)}, deleted_at FROM file_assets WHERE {where} ORDER BY created_at",
56
+ params).fetchall()
57
+
58
+
59
+ def sha256_of(stream) -> str:
60
+ digest = hashlib.sha256()
61
+ while True:
62
+ chunk = stream.read(_CHUNK)
63
+ if not chunk:
64
+ break
65
+ digest.update(chunk)
66
+ return digest.hexdigest()
67
+
68
+
69
+ def copy_hashed(src: MediaStorage, dst: MediaStorage, key: str) -> str:
70
+ """Copy one object, hashing it on the way; the SHA-256 of what was read."""
71
+ digest = hashlib.sha256()
72
+ with src.open(key) as reader, dst.open_write(key) as writer:
73
+ while True:
74
+ chunk = reader.read(_CHUNK)
75
+ if not chunk:
76
+ break
77
+ digest.update(chunk)
78
+ writer.write(chunk)
79
+ return digest.hexdigest()
80
+
81
+
82
+ def read_back_hash(storage: MediaStorage, key: str) -> str:
83
+ with storage.open(key) as reader:
84
+ return sha256_of(reader)
85
+
86
+
87
+ # ── verify ─────────────────────────────────────────────────────────────────────
88
+
89
+
90
+ def verify(log: Log = print) -> Dict[str, int]:
91
+ """Every live asset: its backend configured, the object there, the hash as recorded."""
92
+ counts = {"ok": 0, "missing": 0, "mismatch": 0, "unconfigured": 0, "unhashed": 0}
93
+ con = _db()
94
+ try:
95
+ for row in _rows(con):
96
+ store = storage_named(row["storage_backend"] or "local")
97
+ if store is None:
98
+ counts["unconfigured"] += 1
99
+ log(f" ? {row['id']} {row['rel_path']}: backend {row['storage_backend']!r} is not configured")
100
+ continue
101
+ if not store.exists(row["rel_path"]):
102
+ counts["missing"] += 1
103
+ log(f" βœ— {row['id']} {row['rel_path']}: missing from {store.name}")
104
+ continue
105
+ if not row["sha256"]:
106
+ counts["unhashed"] += 1
107
+ counts["ok"] += 1
108
+ continue
109
+ if read_back_hash(store, row["rel_path"]) != row["sha256"]:
110
+ counts["mismatch"] += 1
111
+ log(f" βœ— {row['id']} {row['rel_path']}: SHA-256 differs from the recorded one")
112
+ continue
113
+ counts["ok"] += 1
114
+ finally:
115
+ con.close()
116
+ log(f"verify: {counts}")
117
+ return counts
118
+
119
+
120
+ # ── migrate ────────────────────────────────────────────────────────────────────
121
+
122
+
123
+ def migrate(target_name: Optional[str] = None, *, dry_run: bool = False, prune_source: bool = False,
124
+ log: Log = print) -> Dict[str, int]:
125
+ """Copy every live asset that is not in the target backend into it β€” read back, hash
126
+ checked β€” then flip its row. Sources stay unless ``prune_source``."""
127
+ target = get_storage() if not target_name else storage_named(target_name)
128
+ if target is None:
129
+ raise RuntimeError(f"target backend {target_name!r} is not configured")
130
+ counts = {"moved": 0, "already": 0, "missing_source": 0, "unconfigured_source": 0, "failed": 0}
131
+ con = _db()
132
+ try:
133
+ for row in _rows(con, "deleted_at IS NULL AND storage_backend != ?", (target.name,)):
134
+ key, src_name = row["rel_path"], row["storage_backend"] or "local"
135
+ src = storage_named(src_name)
136
+ if src is None:
137
+ counts["unconfigured_source"] += 1
138
+ log(f" ? {row['id']} {key}: source backend {src_name!r} is not configured")
139
+ continue
140
+ if not src.exists(key):
141
+ counts["missing_source"] += 1
142
+ log(f" βœ— {row['id']} {key}: not in {src_name}")
143
+ continue
144
+ if dry_run:
145
+ counts["moved"] += 1
146
+ log(f" β†’ {row['id']} {key}: would copy {src_name} β†’ {target.name}")
147
+ continue
148
+ try:
149
+ if target.exists(key) and read_back_hash(target, key) == (row["sha256"] or read_back_hash(src, key)):
150
+ digest = row["sha256"] or read_back_hash(src, key)
151
+ counts["already"] += 1
152
+ else:
153
+ digest = copy_hashed(src, target, key)
154
+ if read_back_hash(target, key) != digest:
155
+ raise RuntimeError("read-back hash differs from what was written")
156
+ if row["sha256"] and row["sha256"] != digest:
157
+ raise RuntimeError("the source no longer matches its recorded hash")
158
+ counts["moved"] += 1
159
+ con.execute("UPDATE file_assets SET storage_backend = ?, sha256 = ? WHERE id = ?",
160
+ (target.name, digest, row["id"]))
161
+ try:
162
+ con.execute("UPDATE assets SET storage_backend = ? WHERE storage_backend = ? AND storage_key = ?",
163
+ (target.name, src_name, key))
164
+ except sqlite3.Error:
165
+ pass # the registry row for this key already exists in the target backend
166
+ con.commit()
167
+ if prune_source and src is not target:
168
+ src.delete(key)
169
+ log(f" βœ“ {row['id']} {key}: {src_name} β†’ {target.name}")
170
+ except Exception as e:
171
+ counts["failed"] += 1
172
+ log(f" βœ— {row['id']} {key}: {e} β€” row left on {src_name}")
173
+ finally:
174
+ con.close()
175
+ log(f"migrate β†’ {target.name}{' (dry run)' if dry_run else ''}: {counts}")
176
+ return counts
177
+
178
+
179
+ # ── backup / restore ───────────────────────────────────────────────────────────
180
+
181
+
182
+ def backup(dest: Path, log: Log = print) -> Dict[str, Any]:
183
+ """``dest/homepilot.db`` (a consistent copy), ``dest/media/<key>`` for every live asset,
184
+ ``dest/manifest.json``. Portable: it does not depend on any backend's on-disk format."""
185
+ dest = Path(dest)
186
+ dest.mkdir(parents=True, exist_ok=True)
187
+ media = LocalStorage(dest / "media")
188
+ con = _db()
189
+ try:
190
+ db_copy = sqlite3.connect(dest / "homepilot.db")
191
+ with db_copy:
192
+ con.backup(db_copy)
193
+ db_copy.close()
194
+ manifest: Dict[str, Any] = {"created_at": datetime.now(timezone.utc).isoformat(), "assets": [], "skipped": []}
195
+ for row in _rows(con):
196
+ store = storage_named(row["storage_backend"] or "local")
197
+ key = row["rel_path"]
198
+ if store is None or not store.exists(key):
199
+ manifest["skipped"].append({"id": row["id"], "rel_path": key, "storage_backend": row["storage_backend"]})
200
+ log(f" βœ— {row['id']} {key}: not available from {row['storage_backend']}")
201
+ continue
202
+ digest = copy_hashed(store, media, key)
203
+ entry = {c: row[c] for c in ROW_COLUMNS}
204
+ entry["sha256"] = digest
205
+ manifest["assets"].append(entry)
206
+ (dest / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
207
+ finally:
208
+ con.close()
209
+ log(f"backup β†’ {dest}: {len(manifest['assets'])} assets, {len(manifest['skipped'])} skipped")
210
+ return manifest
211
+
212
+
213
+ def restore(src: Path, target_name: Optional[str] = None, log: Log = print) -> Dict[str, int]:
214
+ """Put a backup's assets into the configured backend (read back, hash checked) and make
215
+ sure each has its row, naming that backend. The database file itself is restored by
216
+ copying ``homepilot.db`` back while HomePilot is stopped β€” see the design doc."""
217
+ src = Path(src)
218
+ manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
219
+ media = LocalStorage(src / "media")
220
+ target = get_storage() if not target_name else storage_named(target_name)
221
+ if target is None:
222
+ raise RuntimeError(f"target backend {target_name!r} is not configured")
223
+ counts = {"restored": 0, "failed": 0}
224
+ con = _db()
225
+ try:
226
+ for entry in manifest["assets"]:
227
+ key = entry["rel_path"]
228
+ try:
229
+ digest = copy_hashed(media, target, key)
230
+ if digest != entry.get("sha256"):
231
+ raise RuntimeError("the backup copy does not match its manifest hash")
232
+ if read_back_hash(target, key) != digest:
233
+ raise RuntimeError("read-back hash differs from what was written")
234
+ con.execute(
235
+ """INSERT INTO file_assets(id, user_id, kind, rel_path, mime, size_bytes, original_name,
236
+ project_id, conversation_id, storage_backend, sha256, visibility, created_at)
237
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
238
+ ON CONFLICT(id) DO UPDATE SET storage_backend = excluded.storage_backend,
239
+ sha256 = excluded.sha256, deleted_at = NULL""",
240
+ (entry["id"], entry["user_id"], entry["kind"], key, entry.get("mime") or "",
241
+ int(entry.get("size_bytes") or 0), entry.get("original_name") or "", entry.get("project_id") or "",
242
+ entry.get("conversation_id") or "", target.name, digest, entry.get("visibility") or "private",
243
+ entry.get("created_at") or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
244
+ )
245
+ con.commit()
246
+ counts["restored"] += 1
247
+ except Exception as e:
248
+ counts["failed"] += 1
249
+ log(f" βœ— {entry['id']} {key}: {e}")
250
+ finally:
251
+ con.close()
252
+ log(f"restore ← {src} into {target.name}: {counts}")
253
+ return counts
254
+
255
+
256
+ # ── trash ──────────────────────────────────────────────────────────────────────
257
+
258
+
259
+ def trash_retention_days() -> float:
260
+ raw = (os.getenv("MEDIA_TRASH_RETENTION_DAYS") or "7").strip()
261
+ try:
262
+ return max(0.0, float(raw))
263
+ except ValueError:
264
+ return 7.0
265
+
266
+
267
+ def purge_trash(retention_days: Optional[float] = None, *, now: Optional[float] = None, log: Log = print) -> Dict[str, int]:
268
+ """Remove the bytes and rows of assets deleted longer ago than the retention. A row whose
269
+ backend is not configured is left β€” the object cannot be reached, so neither goes."""
270
+ days = trash_retention_days() if retention_days is None else float(retention_days)
271
+ cutoff = datetime.fromtimestamp((now if now is not None else time.time()) - days * 86400, tz=timezone.utc)
272
+ cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
273
+ counts = {"purged": 0, "kept": 0, "failed": 0}
274
+ con = _db()
275
+ try:
276
+ rows = con.execute("SELECT id, rel_path, storage_backend FROM file_assets WHERE deleted_at IS NOT NULL AND deleted_at <= ?",
277
+ (cutoff_s,)).fetchall()
278
+ for row in rows:
279
+ store = storage_named(row["storage_backend"] or "local")
280
+ if store is None:
281
+ counts["kept"] += 1
282
+ continue
283
+ try:
284
+ store.delete(row["rel_path"])
285
+ con.execute("DELETE FROM file_assets WHERE id = ?", (row["id"],))
286
+ con.commit()
287
+ counts["purged"] += 1
288
+ except Exception as e:
289
+ counts["failed"] += 1
290
+ log(f" βœ— {row['id']} {row['rel_path']}: {e}")
291
+ finally:
292
+ con.close()
293
+ if counts["purged"] or counts["failed"]:
294
+ log(f"trash: {counts} (retention {days:g} days)")
295
+ return counts
296
+
297
+
298
+ _gc_thread: Optional[threading.Thread] = None
299
+
300
+
301
+ def start_trash_gc(interval_s: float = 6 * 3600, first_delay_s: float = 60.0) -> None:
302
+ """A daemon thread that purges the trash shortly after startup and every ``interval_s``."""
303
+ global _gc_thread
304
+ if _gc_thread and _gc_thread.is_alive():
305
+ return
306
+
307
+ def _loop() -> None:
308
+ time.sleep(first_delay_s)
309
+ while True:
310
+ try:
311
+ purge_trash()
312
+ except Exception as e: # never let housekeeping take the thread down
313
+ print(f"[MEDIA] trash purge failed: {e}")
314
+ time.sleep(interval_s)
315
+
316
+ _gc_thread = threading.Thread(target=_loop, name="media-trash-gc", daemon=True)
317
+ _gc_thread.start()
318
+
319
+
320
+ # ── CLI ────────────────────────────────────────────────────────────────────────
321
+
322
+
323
+ def main(argv: Optional[List[str]] = None) -> int:
324
+ parser = argparse.ArgumentParser(prog="python -m app.media_ops", description=__doc__,
325
+ formatter_class=argparse.RawDescriptionHelpFormatter)
326
+ sub = parser.add_subparsers(dest="command", required=True)
327
+ sub.add_parser("verify")
328
+ p = sub.add_parser("migrate")
329
+ p.add_argument("--to", dest="target", default=None, help="backend name (default: the configured one)")
330
+ p.add_argument("--dry-run", action="store_true")
331
+ p.add_argument("--prune-source", action="store_true", help="delete each source object after its copy verified")
332
+ p = sub.add_parser("backup")
333
+ p.add_argument("dest")
334
+ p = sub.add_parser("restore")
335
+ p.add_argument("src")
336
+ p.add_argument("--to", dest="target", default=None)
337
+ p = sub.add_parser("gc")
338
+ p.add_argument("--retention-days", type=float, default=None)
339
+ args = parser.parse_args(argv)
340
+
341
+ if args.command == "verify":
342
+ counts = verify()
343
+ return 1 if counts["missing"] or counts["mismatch"] else 0
344
+ if args.command == "migrate":
345
+ counts = migrate(args.target, dry_run=args.dry_run, prune_source=args.prune_source)
346
+ return 1 if counts["failed"] else 0
347
+ if args.command == "backup":
348
+ manifest = backup(Path(args.dest))
349
+ return 1 if manifest["skipped"] else 0
350
+ if args.command == "restore":
351
+ counts = restore(Path(args.src), args.target)
352
+ return 1 if counts["failed"] else 0
353
+ if args.command == "gc":
354
+ purge_trash(args.retention_days)
355
+ return 0
356
+ return 2
357
+
358
+
359
+ if __name__ == "__main__":
360
+ sys.exit(main())
backend/app/media_quota.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-user media quota (docs/design/MEDIA_STORAGE.md, Phase 4).
3
+
4
+ Off by default: ``MEDIA_QUOTA_MB_PER_USER=0`` (unlimited) keeps a local HomePilot exactly as
5
+ it was. With a limit, an upload that would take a user past it is refused with 413 before any
6
+ bytes are stored; files in the trash no longer count.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sqlite3
12
+ from typing import Dict
13
+
14
+ from fastapi import HTTPException
15
+
16
+ from .storage import _get_db_path, ensure_file_assets_columns
17
+
18
+
19
+ def quota_bytes() -> int:
20
+ """The per-user limit in bytes, 0 for none. Read at call time so a restart is not needed."""
21
+ raw = (os.getenv("MEDIA_QUOTA_MB_PER_USER") or "0").strip()
22
+ try:
23
+ mb = float(raw)
24
+ except ValueError:
25
+ mb = 0.0
26
+ return int(mb * 1024 * 1024) if mb > 0 else 0
27
+
28
+
29
+ def usage_bytes(user_id: str) -> int:
30
+ if not user_id:
31
+ return 0
32
+ con = sqlite3.connect(_get_db_path())
33
+ try:
34
+ ensure_file_assets_columns(con)
35
+ row = con.execute(
36
+ "SELECT COALESCE(SUM(size_bytes), 0) FROM file_assets WHERE user_id = ? AND deleted_at IS NULL",
37
+ (user_id,),
38
+ ).fetchone()
39
+ return int(row[0] or 0)
40
+ finally:
41
+ con.close()
42
+
43
+
44
+ def usage(user_id: str) -> Dict[str, int]:
45
+ return {"used_bytes": usage_bytes(user_id), "quota_bytes": quota_bytes()}
46
+
47
+
48
+ def check(user_id: str, add_bytes: int) -> None:
49
+ """Raise 413 when storing ``add_bytes`` more would take ``user_id`` past the quota."""
50
+ limit = quota_bytes()
51
+ if not limit or not user_id:
52
+ return
53
+ used = usage_bytes(user_id)
54
+ if used + max(0, int(add_bytes or 0)) > limit:
55
+ mb = 1024 * 1024
56
+ raise HTTPException(
57
+ status_code=413,
58
+ detail=f"Storage quota reached: {used / mb:.0f} of {limit / mb:.0f} MB used. "
59
+ "Delete some files, or ask the operator to raise MEDIA_QUOTA_MB_PER_USER.",
60
+ )
backend/app/media_storage/__init__.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Where HomePilot's media bytes live.
3
+
4
+ One interface (``MediaStorage``) for uploads, generated images and videos, avatars and the
5
+ other persistent media, keyed by the relative path the registries already store β€” so every
6
+ existing asset resolves unchanged and nothing is moved. ``local`` (the default) is today's
7
+ layout under UPLOAD_DIR and needs no service; ``s3`` is any S3-compatible object store
8
+ (MEDIA_STORAGE_BACKEND=s3 plus S3_ENDPOINT_URL / S3_BUCKET / credentials). The backend is a
9
+ deployment setting, never a caller's decision.
10
+
11
+ from .media_storage import get_storage
12
+ get_storage().put("users/<id>/images/<file>.png", data)
13
+
14
+ Reads go through ``storage_for_read(key)``: the configured backend, else β€” when that is not
15
+ local β€” this machine's disk, where media from before a switch still is. Design and the phase
16
+ plan: docs/design/MEDIA_STORAGE.md.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from .. import config as _config
26
+ from .base import MediaStorage, StorageKeyError, StoredObject, normalize_key
27
+ from .local import LocalStorage
28
+ from .s3 import S3Settings, S3Storage
29
+
30
+ __all__ = [
31
+ "MediaStorage", "LocalStorage", "S3Settings", "S3Storage", "StorageKeyError", "StoredObject",
32
+ "AVAILABLE_BACKENDS", "backend_name", "get_storage", "local_storage", "normalize_key",
33
+ "reset_storage", "storage_for_read", "storage_named", "upload_root", "warm_up",
34
+ ]
35
+
36
+ DEFAULT_BACKEND = "local"
37
+ AVAILABLE_BACKENDS = ("local", "s3")
38
+
39
+ _lock = threading.Lock()
40
+ _current: Optional[MediaStorage] = None
41
+ _current_signature: Optional[tuple] = None
42
+ _local: Optional[LocalStorage] = None
43
+
44
+
45
+ def upload_root() -> Path:
46
+ """UPLOAD_DIR, or ``backend/data/uploads`` when it is relative β€” the rule files.py and
47
+ asset_registry.py each carried a copy of. Read at call time, so a repointed
48
+ ``app.config.UPLOAD_DIR`` (tests, DATA_DIR) is honoured."""
49
+ p = Path(_config.UPLOAD_DIR)
50
+ if not p.is_absolute():
51
+ p = Path(__file__).resolve().parents[2] / "data" / "uploads"
52
+ return p
53
+
54
+
55
+ def backend_name() -> str:
56
+ return (os.getenv("MEDIA_STORAGE_BACKEND") or DEFAULT_BACKEND).strip().lower() or DEFAULT_BACKEND
57
+
58
+
59
+ def local_storage() -> LocalStorage:
60
+ """This machine's disk under the upload root β€” the default backend, and where media
61
+ from before a switch to another backend still is."""
62
+ global _local
63
+ root = upload_root()
64
+ with _lock:
65
+ if _local is None or _local.root != root.resolve():
66
+ _local = LocalStorage(root)
67
+ return _local
68
+
69
+
70
+ def get_storage() -> MediaStorage:
71
+ """The configured storage β€” built once, rebuilt when the backend or its settings change.
72
+ Building it touches no network; the first operation does."""
73
+ global _current, _current_signature
74
+ name = backend_name()
75
+ s3_settings = S3Settings.from_env() if name == "s3" else None
76
+ signature = s3_settings.signature() if s3_settings else (name, str(upload_root()))
77
+ with _lock:
78
+ if _current is None or _current_signature != signature:
79
+ if name == "local":
80
+ _current = LocalStorage(upload_root())
81
+ elif s3_settings is not None:
82
+ _current = S3Storage(s3_settings)
83
+ else:
84
+ raise RuntimeError(
85
+ f"MEDIA_STORAGE_BACKEND={name!r} is not a backend this HomePilot has: "
86
+ f"one of {', '.join(AVAILABLE_BACKENDS)}."
87
+ )
88
+ _current_signature = signature
89
+ return _current
90
+
91
+
92
+ def storage_named(name: str) -> Optional[MediaStorage]:
93
+ """The storage called ``name`` as this HomePilot is configured: this disk for 'local', the
94
+ configured backend when that is its name β€” else None: the bytes are in a store this
95
+ HomePilot is not set up to reach (switched back to local after writing to S3)."""
96
+ name = (name or "local").strip().lower()
97
+ if name == "local":
98
+ return local_storage()
99
+ storage = get_storage()
100
+ return storage if storage.name == name else None
101
+
102
+
103
+ def storage_for_read(key: str) -> MediaStorage:
104
+ """For a key with no registry row (path-based legacy URLs): the configured backend when it
105
+ has the object (or is local), else this machine's disk β€” so switching to S3 never hides
106
+ the media written before it, and a remote store that is down still leaves the local ones
107
+ readable. Registered assets do not need this: their row names the backend."""
108
+ storage = get_storage()
109
+ if storage.name == "local":
110
+ return storage
111
+ try:
112
+ if storage.exists(key):
113
+ return storage
114
+ except StorageKeyError:
115
+ raise
116
+ except Exception:
117
+ pass # unreachable or misconfigured: fall through to the disk for this read
118
+ local = local_storage()
119
+ return local if local.exists(key) else storage
120
+
121
+
122
+ def warm_up() -> None:
123
+ """At startup: say which backend is in use and, when it is remote, load its client library
124
+ and reach the bucket on a background thread β€” so the first upload does not pay for it
125
+ (boto3 took 14 s to load from a venv on a Windows share; the second upload took 0.2 s)."""
126
+ try:
127
+ storage = get_storage()
128
+ except Exception as e:
129
+ print(f"[media-storage] not available: {e}")
130
+ return
131
+ if isinstance(storage, S3Storage):
132
+ s = storage.settings
133
+ print(f"[media-storage] backend=s3 endpoint={s.endpoint_url or 'aws'} bucket={s.bucket}"
134
+ + (f" prefix={s.prefix}" if s.prefix else "") + " (media from before the switch is read from the disk)")
135
+ else:
136
+ print(f"[media-storage] backend={storage.name} root={upload_root()}")
137
+ return
138
+
139
+ def _warm() -> None:
140
+ try:
141
+ storage.exists(".warm-up") # builds the client and makes sure the bucket is there
142
+ print("[media-storage] s3 ready")
143
+ except Exception as e:
144
+ print(f"[media-storage] s3 not reachable yet: {e}")
145
+
146
+ threading.Thread(target=_warm, name="media-storage-warm-up", daemon=True).start()
147
+
148
+
149
+ def reset_storage() -> None:
150
+ """Forget the built storages (tests that change UPLOAD_DIR or the backend)."""
151
+ global _current, _current_signature, _local
152
+ with _lock:
153
+ _current = None
154
+ _current_signature = None
155
+ _local = None
backend/app/media_storage/base.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The MediaStorage interface: where HomePilot keeps media bytes.
3
+
4
+ A key is a relative POSIX path under the storage root β€” the same string that
5
+ ``file_assets.rel_path`` and ``assets.storage_key`` already hold ("users/<id>/images/<f>.png",
6
+ "avatar_<id>_1a2b.png"), so every existing asset resolves unchanged. Which backend serves a
7
+ key is a deployment setting (``MEDIA_STORAGE_BACKEND``), never a caller's decision. Phase 1
8
+ ships ``local``; another backend is another subclass of this, run through the same contract
9
+ tests (backend/tests/test_media_storage.py). Design: docs/design/MEDIA_STORAGE.md.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import BinaryIO, ContextManager, Optional, Union
17
+
18
+ Bytes = Union[bytes, bytearray, memoryview]
19
+
20
+
21
+ class StorageKeyError(ValueError):
22
+ """A key that would leave the storage root, or names nothing."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class StoredObject:
27
+ backend: str
28
+ key: str
29
+ size_bytes: int
30
+
31
+
32
+ def normalize_key(key: str) -> str:
33
+ """The canonical form of a key, or StorageKeyError.
34
+
35
+ Backslashes are separators (keys written on Windows), repeated and trailing slashes
36
+ collapse, and nothing may climb out: no absolute paths, drive letters, "..", NUL.
37
+ """
38
+ raw = str(key or "").replace("\\", "/")
39
+ if not raw.strip() or "\x00" in raw:
40
+ raise StorageKeyError("empty key")
41
+ if raw.startswith("/") or (len(raw) > 1 and raw[1] == ":"):
42
+ raise StorageKeyError(f"an absolute path is not a key: {key!r}")
43
+ parts = [p for p in raw.split("/") if p not in ("", ".")]
44
+ if not parts or any(p == ".." for p in parts):
45
+ raise StorageKeyError(f"key leaves the storage root: {key!r}")
46
+ return "/".join(parts)
47
+
48
+
49
+ class MediaStorage(ABC):
50
+ """Bytes by key. Writes are atomic: a reader never sees a half-written object."""
51
+
52
+ name: str = "abstract"
53
+
54
+ @abstractmethod
55
+ def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
56
+ """Store bytes (or a readable stream) at ``key``. FileExistsError when it is
57
+ there and ``overwrite`` is False."""
58
+
59
+ @abstractmethod
60
+ def open_write(self, key: str, *, overwrite: bool = True) -> ContextManager[BinaryIO]:
61
+ """A writable stream for ``key``, committed when the block ends normally and
62
+ discarded entirely β€” nothing at ``key``, no leftovers β€” when it raises."""
63
+
64
+ @abstractmethod
65
+ def open(self, key: str) -> BinaryIO:
66
+ """A readable stream; FileNotFoundError when there is no such object."""
67
+
68
+ def read(self, key: str) -> bytes:
69
+ with self.open(key) as f:
70
+ return f.read()
71
+
72
+ @abstractmethod
73
+ def exists(self, key: str) -> bool: ...
74
+
75
+ @abstractmethod
76
+ def size(self, key: str) -> int:
77
+ """Size in bytes; FileNotFoundError when there is no such object."""
78
+
79
+ @abstractmethod
80
+ def delete(self, key: str) -> bool:
81
+ """Remove the object. True when something was removed, False when it was not there."""
82
+
83
+ @abstractmethod
84
+ def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject: ...
85
+
86
+ def local_path(self, key: str) -> Optional[Path]:
87
+ """The object's path on this machine's filesystem, when the backend has one (a
88
+ FileResponse can serve it directly); None for remote storage."""
89
+ return None
backend/app/media_storage/local.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LocalStorage β€” today's layout under UPLOAD_DIR, behind the MediaStorage interface.
3
+
4
+ Nothing moves: a key is the path relative to the root that the registries already store.
5
+ What is new is the discipline β€” every write lands atomically (temp file in the same
6
+ directory, then ``os.replace``), and no key can reach outside the root.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import BinaryIO, Iterator, Union
16
+
17
+ from .base import Bytes, MediaStorage, StorageKeyError, StoredObject, normalize_key
18
+
19
+ _COPY_CHUNK = 1024 * 1024
20
+
21
+
22
+ class LocalStorage(MediaStorage):
23
+ name = "local"
24
+
25
+ def __init__(self, root: Union[str, Path]) -> None:
26
+ self.root = Path(root).resolve()
27
+ self.root.mkdir(parents=True, exist_ok=True)
28
+
29
+ def _path(self, key: str) -> Path:
30
+ path = self.root / normalize_key(key)
31
+ resolved = path.resolve()
32
+ if resolved != self.root and self.root not in resolved.parents:
33
+ raise StorageKeyError(f"key leaves the storage root: {key!r}")
34
+ return path
35
+
36
+ # ── writing ────────────────────────────────────────────────────────────────
37
+
38
+ @contextmanager
39
+ def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]:
40
+ path = self._path(key)
41
+ if not overwrite and path.exists():
42
+ raise FileExistsError(normalize_key(key))
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ # Same directory as the target, so the final rename is atomic on every filesystem.
45
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".part", dir=str(path.parent))
46
+ tmp = Path(tmp_name)
47
+ handle = os.fdopen(fd, "wb")
48
+ try:
49
+ yield handle
50
+ handle.flush()
51
+ os.fsync(handle.fileno())
52
+ handle.close()
53
+ os.replace(tmp, path)
54
+ except BaseException:
55
+ try:
56
+ handle.close()
57
+ except Exception:
58
+ pass
59
+ tmp.unlink(missing_ok=True)
60
+ raise
61
+
62
+ def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
63
+ with self.open_write(key, overwrite=overwrite) as out:
64
+ if hasattr(data, "read"):
65
+ shutil.copyfileobj(data, out, _COPY_CHUNK) # type: ignore[arg-type]
66
+ else:
67
+ out.write(data) # type: ignore[arg-type]
68
+ return StoredObject(self.name, normalize_key(key), self._path(key).stat().st_size)
69
+
70
+ def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject:
71
+ with self.open(src_key) as src:
72
+ return self.put(dst_key, src, overwrite=overwrite)
73
+
74
+ # ── reading ────────────────────────────────────────────────────────────────
75
+
76
+ def open(self, key: str) -> BinaryIO:
77
+ path = self._path(key)
78
+ if not path.is_file():
79
+ raise FileNotFoundError(normalize_key(key))
80
+ return path.open("rb")
81
+
82
+ def exists(self, key: str) -> bool:
83
+ return self._path(key).is_file()
84
+
85
+ def size(self, key: str) -> int:
86
+ path = self._path(key)
87
+ if not path.is_file():
88
+ raise FileNotFoundError(normalize_key(key))
89
+ return path.stat().st_size
90
+
91
+ def local_path(self, key: str) -> Path:
92
+ return self._path(key)
93
+
94
+ # ── removing ───────────────────────────────────────────────────────────────
95
+
96
+ def delete(self, key: str) -> bool:
97
+ path = self._path(key)
98
+ if not path.is_file():
99
+ return False
100
+ path.unlink()
101
+ # Empty folders the object leaves behind go too β€” never the root itself.
102
+ parent = path.parent
103
+ while parent != self.root and self.root in parent.parents:
104
+ try:
105
+ if any(parent.iterdir()):
106
+ break
107
+ parent.rmdir()
108
+ except OSError:
109
+ break
110
+ parent = parent.parent
111
+ return True
backend/app/media_storage/s3.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ S3Storage β€” media bytes in any S3-compatible object store, behind MediaStorage.
3
+
4
+ S3 is the protocol, not a vendor: MinIO or AIStor Free, Garage, SeaweedFS, RustFS, AWS, a
5
+ Hugging Face bucket's S3 layer β€” anything that answers SigV4 PUT/GET/HEAD/DELETE/COPY.
6
+ boto3 is imported only here, when this backend is selected, so a local HomePilot needs
7
+ neither the library nor a server.
8
+
9
+ Keys are the same relative paths as the local backend (an optional S3_PREFIX in front), so
10
+ the registries do not change when the backend does. A PUT is atomic on every S3 server β€” an
11
+ object appears only once its upload completed β€” which is the ``open_write`` contract: a
12
+ stream is spooled locally and uploaded when its block ends, dropped when it raises.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import mimetypes
17
+ import os
18
+ import tempfile
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass
21
+ from typing import Any, BinaryIO, Iterator, Mapping, Optional, Union
22
+
23
+ from .base import Bytes, MediaStorage, StoredObject, normalize_key
24
+
25
+ _MISSING_CODES = {"404", "NoSuchKey", "NotFound"}
26
+ _SPOOL_MAX = 8 * 1024 * 1024
27
+
28
+
29
+ def normalize_prefix(prefix: str) -> str:
30
+ """"media/" for "media", "/media//" and so on; "" for none."""
31
+ clean = "/".join(p for p in (prefix or "").replace("\\", "/").split("/") if p and p != ".")
32
+ return f"{clean}/" if clean else ""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class S3Settings:
37
+ bucket: str
38
+ endpoint_url: Optional[str] = None # None: AWS; "http://127.0.0.1:9400": a self-hosted server
39
+ region: str = "us-east-1"
40
+ access_key: Optional[str] = None # None: boto3's own credential chain (env, profile, role)
41
+ secret_key: Optional[str] = None
42
+ addressing_style: str = "path" # path for self-hosted servers; virtual for AWS-style hosts
43
+ prefix: str = "" # key prefix inside the bucket, "" or "name/"
44
+ create_bucket: bool = True # make the bucket on first use when it is missing
45
+ verify_tls: bool = True
46
+
47
+ def __post_init__(self) -> None:
48
+ # "media", "/media//" and "media/" are the same prefix; a frozen dataclass sets it so.
49
+ object.__setattr__(self, "prefix", normalize_prefix(self.prefix))
50
+ object.__setattr__(self, "bucket", (self.bucket or "").strip())
51
+
52
+ @classmethod
53
+ def from_env(cls, env: Mapping[str, str] = os.environ) -> "S3Settings":
54
+ def get(name: str, default: str = "") -> str:
55
+ return (env.get(name) or default).strip()
56
+
57
+ def flag(name: str, default: str = "1") -> bool:
58
+ return get(name, default).lower() not in ("0", "false", "no", "off")
59
+
60
+ return cls(
61
+ bucket=get("S3_BUCKET", "homepilot-media"),
62
+ endpoint_url=get("S3_ENDPOINT_URL") or None,
63
+ region=get("S3_REGION", "us-east-1") or "us-east-1",
64
+ access_key=get("S3_ACCESS_KEY_ID") or get("AWS_ACCESS_KEY_ID") or None,
65
+ secret_key=get("S3_SECRET_ACCESS_KEY") or get("AWS_SECRET_ACCESS_KEY") or None,
66
+ addressing_style=(get("S3_ADDRESSING_STYLE", "path").lower() or "path"),
67
+ prefix=normalize_prefix(get("S3_PREFIX")),
68
+ create_bucket=flag("S3_CREATE_BUCKET"),
69
+ verify_tls=flag("S3_VERIFY_TLS"),
70
+ )
71
+
72
+ def signature(self) -> tuple:
73
+ """What distinguishes one configured store from another (credentials excluded)."""
74
+ return ("s3", self.endpoint_url or "", self.bucket, self.prefix, self.region, self.addressing_style)
75
+
76
+
77
+ def _is_missing(error: BaseException) -> bool:
78
+ response = getattr(error, "response", None) or {}
79
+ code = str((response.get("Error") or {}).get("Code") or "")
80
+ status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode")
81
+ return code in _MISSING_CODES or status == 404
82
+
83
+
84
+ def _content_type(key: str) -> str:
85
+ return mimetypes.guess_type(key)[0] or "application/octet-stream"
86
+
87
+
88
+ def _build_client(settings: S3Settings) -> Any:
89
+ try:
90
+ import boto3
91
+ from botocore.config import Config
92
+ except ImportError as e: # pragma: no cover - depends on the install
93
+ raise RuntimeError(
94
+ "MEDIA_STORAGE_BACKEND=s3 needs boto3: in backend/, `uv pip install -e '.[s3]'` "
95
+ "(or `pip install 'boto3>=1.34'`)."
96
+ ) from e
97
+ config = Config(
98
+ signature_version="s3v4",
99
+ s3={"addressing_style": settings.addressing_style},
100
+ retries={"max_attempts": 3, "mode": "standard"},
101
+ connect_timeout=5,
102
+ read_timeout=60,
103
+ )
104
+ return boto3.client(
105
+ "s3",
106
+ endpoint_url=settings.endpoint_url,
107
+ region_name=settings.region,
108
+ aws_access_key_id=settings.access_key,
109
+ aws_secret_access_key=settings.secret_key,
110
+ config=config,
111
+ verify=settings.verify_tls,
112
+ )
113
+
114
+
115
+ class _Body:
116
+ """A GET body as a plain binary stream (boto3's StreamingBody is not a context manager)."""
117
+
118
+ def __init__(self, body: Any) -> None:
119
+ self._body = body
120
+
121
+ def read(self, size: int = -1) -> bytes:
122
+ return self._body.read() if size is None or size < 0 else self._body.read(size)
123
+
124
+ def close(self) -> None:
125
+ close = getattr(self._body, "close", None)
126
+ if close:
127
+ close()
128
+
129
+ def __enter__(self) -> "_Body":
130
+ return self
131
+
132
+ def __exit__(self, *exc: object) -> None:
133
+ self.close()
134
+
135
+ def __iter__(self) -> Iterator[bytes]:
136
+ while True:
137
+ chunk = self.read(1024 * 1024)
138
+ if not chunk:
139
+ return
140
+ yield chunk
141
+
142
+
143
+ class S3Storage(MediaStorage):
144
+ name = "s3"
145
+
146
+ def __init__(self, settings: S3Settings, *, client: Any = None) -> None:
147
+ if not settings.bucket:
148
+ raise ValueError("S3_BUCKET is required for MEDIA_STORAGE_BACKEND=s3")
149
+ self.settings = settings
150
+ self._client = client # injected in tests; built lazily otherwise
151
+ self._bucket_ready = False
152
+
153
+ # ── plumbing ───────────────────────────────────────────────────────────────
154
+
155
+ @property
156
+ def client(self) -> Any:
157
+ if self._client is None:
158
+ self._client = _build_client(self.settings)
159
+ return self._client
160
+
161
+ @property
162
+ def bucket(self) -> str:
163
+ return self.settings.bucket
164
+
165
+ def _ensure_bucket(self) -> None:
166
+ if self._bucket_ready:
167
+ return
168
+ try:
169
+ self.client.head_bucket(Bucket=self.bucket)
170
+ except Exception as e:
171
+ if not (_is_missing(e) and self.settings.create_bucket):
172
+ raise
173
+ kwargs: dict = {"Bucket": self.bucket}
174
+ if not self.settings.endpoint_url and self.settings.region != "us-east-1":
175
+ kwargs["CreateBucketConfiguration"] = {"LocationConstraint": self.settings.region}
176
+ self.client.create_bucket(**kwargs)
177
+ self._bucket_ready = True
178
+
179
+ def _key(self, key: str) -> str:
180
+ return self.settings.prefix + normalize_key(key)
181
+
182
+ def _head(self, object_key: str) -> Optional[dict]:
183
+ try:
184
+ return self.client.head_object(Bucket=self.bucket, Key=object_key)
185
+ except Exception as e:
186
+ if _is_missing(e):
187
+ return None
188
+ raise
189
+
190
+ # ── writing ────────────────────────────────────────────────────────────────
191
+
192
+ def put(self, key: str, data: Union[Bytes, BinaryIO], *, overwrite: bool = True) -> StoredObject:
193
+ self._ensure_bucket()
194
+ object_key = self._key(key)
195
+ if not overwrite and self._head(object_key) is not None:
196
+ raise FileExistsError(normalize_key(key))
197
+ content_type = _content_type(key)
198
+ if hasattr(data, "read"):
199
+ self.client.upload_fileobj(data, self.bucket, object_key, ExtraArgs={"ContentType": content_type})
200
+ size = int((self._head(object_key) or {}).get("ContentLength") or 0)
201
+ else:
202
+ payload = bytes(data) # type: ignore[arg-type]
203
+ self.client.put_object(Bucket=self.bucket, Key=object_key, Body=payload, ContentType=content_type)
204
+ size = len(payload)
205
+ return StoredObject(self.name, normalize_key(key), size)
206
+
207
+ @contextmanager
208
+ def open_write(self, key: str, *, overwrite: bool = True) -> Iterator[BinaryIO]:
209
+ self._ensure_bucket()
210
+ object_key = self._key(key)
211
+ if not overwrite and self._head(object_key) is not None:
212
+ raise FileExistsError(normalize_key(key))
213
+ spool = tempfile.SpooledTemporaryFile(max_size=_SPOOL_MAX)
214
+ try:
215
+ yield spool # type: ignore[misc]
216
+ spool.seek(0)
217
+ self.client.upload_fileobj(spool, self.bucket, object_key, ExtraArgs={"ContentType": _content_type(key)})
218
+ finally:
219
+ spool.close()
220
+
221
+ def copy(self, src_key: str, dst_key: str, *, overwrite: bool = True) -> StoredObject:
222
+ self._ensure_bucket()
223
+ src, dst = self._key(src_key), self._key(dst_key)
224
+ if self._head(src) is None:
225
+ raise FileNotFoundError(normalize_key(src_key))
226
+ if not overwrite and self._head(dst) is not None:
227
+ raise FileExistsError(normalize_key(dst_key))
228
+ self.client.copy_object(
229
+ Bucket=self.bucket, Key=dst, CopySource={"Bucket": self.bucket, "Key": src},
230
+ ContentType=_content_type(dst_key), MetadataDirective="REPLACE",
231
+ )
232
+ return StoredObject(self.name, normalize_key(dst_key), int((self._head(dst) or {}).get("ContentLength") or 0))
233
+
234
+ # ── reading ────────────────────────────────────────────────────────────────
235
+
236
+ def open(self, key: str) -> BinaryIO:
237
+ self._ensure_bucket()
238
+ try:
239
+ response = self.client.get_object(Bucket=self.bucket, Key=self._key(key))
240
+ except Exception as e:
241
+ if _is_missing(e):
242
+ raise FileNotFoundError(normalize_key(key)) from None
243
+ raise
244
+ return _Body(response["Body"]) # type: ignore[return-value]
245
+
246
+ def exists(self, key: str) -> bool:
247
+ self._ensure_bucket()
248
+ return self._head(self._key(key)) is not None
249
+
250
+ def size(self, key: str) -> int:
251
+ self._ensure_bucket()
252
+ head = self._head(self._key(key))
253
+ if head is None:
254
+ raise FileNotFoundError(normalize_key(key))
255
+ return int(head.get("ContentLength") or 0)
256
+
257
+ # ── removing ───────────────────────────────────────────────────────────────
258
+
259
+ def delete(self, key: str) -> bool:
260
+ self._ensure_bucket()
261
+ object_key = self._key(key)
262
+ if self._head(object_key) is None:
263
+ return False
264
+ self.client.delete_object(Bucket=self.bucket, Key=object_key)
265
+ return True
backend/app/media_validate.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ What an upload must be before it is stored (docs/design/MEDIA_STORAGE.md, Phase 4).
3
+
4
+ - A file presented as an image (by MIME type or extension) must start like one: PNG, JPEG,
5
+ GIF, WebP or BMP. Anything else posing as an image is refused with 400 β€” before any bytes
6
+ reach storage.
7
+ - ``MEDIA_SCAN_COMMAND`` (unset by default) names a scanner, e.g. ``clamdscan --no-summary``:
8
+ it runs on a temporary copy with the path appended, and a non-zero exit refuses the file.
9
+ That is the quarantine: nothing is stored until the scanner has said yes.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shlex
15
+ import subprocess
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from fastapi import HTTPException
21
+
22
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
23
+ _SIGNATURES = (
24
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
25
+ (b"\xff\xd8\xff", "image/jpeg"),
26
+ (b"GIF87a", "image/gif"),
27
+ (b"GIF89a", "image/gif"),
28
+ (b"BM", "image/bmp"),
29
+ )
30
+ HEAD_BYTES = 16
31
+
32
+
33
+ def sniff_image(head: bytes) -> Optional[str]:
34
+ """The image type the first bytes say, or None."""
35
+ head = bytes(head or b"")
36
+ for magic, mime in _SIGNATURES:
37
+ if head.startswith(magic):
38
+ return mime
39
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
40
+ return "image/webp"
41
+ return None
42
+
43
+
44
+ def claims_image(declared_mime: str, filename: str) -> bool:
45
+ mime = (declared_mime or "").split(";", 1)[0].strip().lower()
46
+ ext = Path(filename or "").suffix.lower()
47
+ return mime.startswith("image/") or ext in IMAGE_EXTS
48
+
49
+
50
+ def check_image_claim(head: bytes, declared_mime: str, filename: str) -> None:
51
+ """400 when the file says it is an image and its bytes say it is not."""
52
+ if claims_image(declared_mime, filename) and sniff_image(head) is None:
53
+ raise HTTPException(
54
+ status_code=400,
55
+ detail=f"{filename or 'The file'} is not the image it claims to be; PNG, JPEG, GIF, WebP and BMP are accepted.",
56
+ )
57
+
58
+
59
+ def scan_command() -> list:
60
+ raw = (os.getenv("MEDIA_SCAN_COMMAND") or "").strip()
61
+ return shlex.split(raw) if raw else []
62
+
63
+
64
+ def scan_bytes(data: bytes, filename: str = "") -> None:
65
+ """Run the configured scanner on a temporary copy; 400 when it objects. No-op when none is set."""
66
+ command = scan_command()
67
+ if not command:
68
+ return
69
+ suffix = Path(filename or "").suffix[:16]
70
+ with tempfile.NamedTemporaryFile(prefix="hp-scan-", suffix=suffix, delete=False) as tmp:
71
+ tmp.write(data)
72
+ path = tmp.name
73
+ try:
74
+ result = subprocess.run(command + [path], capture_output=True, text=True, timeout=120)
75
+ except (OSError, subprocess.TimeoutExpired) as e:
76
+ os.unlink(path)
77
+ raise HTTPException(status_code=503, detail=f"The upload scanner could not run: {e}") from e
78
+ os.unlink(path)
79
+ if result.returncode != 0:
80
+ print(f"[MEDIA] scanner refused {filename!r}: exit {result.returncode} {result.stdout.strip()[:200]}")
81
+ raise HTTPException(status_code=400, detail=f"{filename or 'The file'} was refused by the upload scanner.")
backend/app/model_advisor/hardware.py CHANGED
@@ -81,7 +81,14 @@ def _nvidia() -> Optional[Dict[str, Any]]:
81
  "note": "" if bw else "Unlisted GPU β€” speed estimates are unavailable; fit is still exact."}
82
 
83
 
84
- def _total_ram_gb() -> float:
 
 
 
 
 
 
 
85
  try:
86
  import psutil
87
  return psutil.virtual_memory().total / 1e9
@@ -89,6 +96,30 @@ def _total_ram_gb() -> float:
89
  return 0.0
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def _apple() -> Optional[Dict[str, Any]]:
93
  if platform.system() != "Darwin" or platform.machine() != "arm64":
94
  return None
@@ -108,10 +139,17 @@ def detect() -> Dict[str, Any]:
108
  if hw:
109
  return {**hw, "detected": True}
110
  ram = _total_ram_gb()
111
- return {"kind": "cpu", "name": platform.processor() or platform.machine() or "CPU",
112
- "vram_gb": 0.0, "ram_gb": round(ram, 1), "bandwidth_gbs": CPU_BANDWIDTH_GBS,
113
- "profile_id": "cpu-only", "detected": True,
114
- "note": "No GPU found β€” chat models are sized for system RAM; image and video need a GPU."}
 
 
 
 
 
 
 
115
 
116
 
117
  def with_override(hw: Dict[str, Any], vram_gb: Optional[float]) -> Dict[str, Any]:
 
81
  "note": "" if bw else "Unlisted GPU β€” speed estimates are unavailable; fit is still exact."}
82
 
83
 
84
+ # The memory limit of the container HomePilot runs in (Docker, Kubernetes, a Hugging Face
85
+ # Space) β€” cgroup v2, then v1. psutil reports the host's RAM: a 16 GB Space read 132.9 GB,
86
+ # and FitLab called a 20B model a fit for it.
87
+ CGROUP_ROOT = Path("/sys/fs/cgroup")
88
+ _CGROUP_LIMIT_FILES = ("memory.max", "memory/memory.limit_in_bytes")
89
+
90
+
91
+ def _host_ram_gb() -> float:
92
  try:
93
  import psutil
94
  return psutil.virtual_memory().total / 1e9
 
96
  return 0.0
97
 
98
 
99
+ def _cgroup_limit_gb(root: Optional[Path] = None) -> Optional[float]:
100
+ """The memory limit of this process's cgroup, in GB; None when there is none."""
101
+ for name in _CGROUP_LIMIT_FILES:
102
+ try:
103
+ raw = ((root or CGROUP_ROOT) / name).read_text(encoding="utf-8").strip()
104
+ except OSError:
105
+ continue
106
+ if not raw.isdigit(): # "max": no limit at this level
107
+ continue
108
+ limit = int(raw)
109
+ if 0 < limit < 1 << 60: # v1 writes a near-2^63 sentinel when unlimited
110
+ return limit / 1e9
111
+ return None
112
+
113
+
114
+ def _total_ram_gb() -> float:
115
+ """Memory this HomePilot can use: the host's RAM, or the container's limit when smaller."""
116
+ host = _host_ram_gb()
117
+ limit = _cgroup_limit_gb()
118
+ if limit is not None and (not host or limit < host):
119
+ return limit
120
+ return host
121
+
122
+
123
  def _apple() -> Optional[Dict[str, Any]]:
124
  if platform.system() != "Darwin" or platform.machine() != "arm64":
125
  return None
 
139
  if hw:
140
  return {**hw, "detected": True}
141
  ram = _total_ram_gb()
142
+ host = _host_ram_gb()
143
+ cpu = {"kind": "cpu", "name": platform.processor() or platform.machine() or "CPU",
144
+ "vram_gb": 0.0, "ram_gb": round(ram, 1), "bandwidth_gbs": CPU_BANDWIDTH_GBS,
145
+ "profile_id": "cpu-only", "detected": True,
146
+ "note": "No GPU found β€” chat models are sized for system RAM; image and video need a GPU."}
147
+ if host and ram < host - 0.5:
148
+ # In a container: its limit is what counts, and the page says so.
149
+ cpu.update(host_ram_gb=round(host, 1), ram_source="container",
150
+ note=f"No GPU found β€” chat models are sized for this container's {ram:.0f} GB memory "
151
+ f"limit (the host has {host:.0f} GB); image and video need a GPU.")
152
+ return cpu
153
 
154
 
155
  def with_override(hw: Dict[str, Any], vram_gb: Optional[float]) -> Dict[str, Any]:
backend/app/model_resolver.py CHANGED
@@ -37,6 +37,7 @@ import dataclasses
37
  import logging
38
  import os
39
  import re
 
40
  import time
41
  from dataclasses import dataclass, field
42
  from typing import Any, Dict, List, Literal, Optional, Tuple
@@ -50,8 +51,14 @@ log = logging.getLogger("homepilot.models")
50
  Role = Literal["chat", "fast", "vision", "embed"]
51
  ROLES: Tuple[str, ...] = ("chat", "fast", "vision", "embed")
52
 
53
- PROBE_TIMEOUT_S = float(os.getenv("MODEL_DISCOVERY_TIMEOUT_S", "1.5"))
 
 
 
54
  CACHE_TTL_S = float(os.getenv("MODEL_DISCOVERY_TTL_S", "30"))
 
 
 
55
 
56
  # ── Model classification (single source of truth; llm.py re-exports) ──────────
57
  VISION_ONLY_HINTS = (
@@ -224,6 +231,29 @@ def is_docker() -> bool:
224
  return os.path.exists("/.dockerenv") or "docker" in _read_text("/proc/1/cgroup")
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def _default_gateway_from_proc() -> str:
228
  """Default route gateway from ``/proc/net/route`` (little-endian hex)."""
229
  text = _read_text("/proc/net/route")
@@ -279,7 +309,9 @@ def candidate_urls(explicit: Optional[str] = None) -> List[Tuple[str, str]]:
279
  add("configured", OLLAMA_BASE_URL)
280
  add("loopback", "http://127.0.0.1:11434")
281
  add("loopback", "http://localhost:11434")
282
- if is_wsl():
 
 
283
  for ip in wsl_host_ips():
284
  add("wsl-host", f"http://{ip}:11434")
285
  if is_docker():
@@ -379,14 +411,21 @@ def invalidate(reason: str = "") -> None:
379
  log.info("model discovery cache invalidated: %s", reason)
380
 
381
 
 
 
 
 
 
 
 
382
  async def discover(force: bool = False) -> List[Runtime]:
383
- """All candidate runtimes with their probe results, cached ``CACHE_TTL_S``."""
384
  now = time.monotonic()
385
- if not force and _cache["runtimes"] and now - _cache["ts"] < CACHE_TTL_S:
386
  return list(_cache["runtimes"])
387
  async with _get_lock():
388
  now = time.monotonic()
389
- if not force and _cache["runtimes"] and now - _cache["ts"] < CACHE_TTL_S:
390
  return list(_cache["runtimes"])
391
  cands = candidate_urls()
392
  results = await asyncio.gather(*(probe(u, label) for label, u in cands))
@@ -407,7 +446,7 @@ async def discover_url(url: str, force: bool = False) -> Runtime:
407
  key = url.rstrip("/")
408
  now = time.monotonic()
409
  hit = _url_cache.get(key)
410
- if hit and not force and now - hit[0] < CACHE_TTL_S:
411
  return hit[1]
412
  rt = await probe(key, "explicit")
413
  _url_cache[key] = (time.monotonic(), rt)
@@ -429,10 +468,35 @@ def pick_runtime(runtimes: List[Runtime]) -> Optional[Runtime]:
429
  return reachable[0]
430
 
431
 
 
 
 
 
 
 
 
 
 
 
 
432
  async def active_runtime(base_url: Optional[str] = None, force: bool = False) -> Tuple[Optional[Runtime], List[Runtime]]:
 
 
 
 
433
  if base_url:
434
- rt = await discover_url(base_url, force=force)
435
- return (rt if rt.reachable else None), [rt]
 
 
 
 
 
 
 
 
 
 
436
  runtimes = await discover(force=force)
437
  return pick_runtime(runtimes), runtimes
438
 
@@ -543,6 +607,21 @@ def build_remedy(role: str, runtime: Optional[Runtime], runtimes: List[Runtime],
543
  }
544
  probed = [r.url for r in runtimes]
545
  wsl_hosts = [r for r in runtimes if r.label == "wsl-host"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  if is_wsl() and wsl_hosts:
547
  return {
548
  "kind": "wsl_bridge",
@@ -596,6 +675,9 @@ async def resolve(role: str, configured: str = "", *, base_url: Optional[str] =
596
  model, source = "", "none"
597
  urls = ", ".join(r.url for r in runtimes) or (base_url or OLLAMA_BASE_URL)
598
  reason = f"No Ollama runtime is reachable (tried {urls})."
 
 
 
599
  res = Resolution(
600
  role=role,
601
  configured=cfg,
 
37
  import logging
38
  import os
39
  import re
40
+ import subprocess
41
  import time
42
  from dataclasses import dataclass, field
43
  from typing import Any, Dict, List, Literal, Optional, Tuple
 
51
  Role = Literal["chat", "fast", "vision", "embed"]
52
  ROLES: Tuple[str, ...] = ("chat", "fast", "vision", "embed")
53
 
54
+ # 3 s: Ollama answers /api/tags in tens of milliseconds, but not while it loads a model or the
55
+ # machine is short of memory (ComfyUI loading a checkpoint) β€” 1.5 s reported it missing then.
56
+ # The candidates are probed in parallel, so a down Ollama still costs one timeout, not several.
57
+ PROBE_TIMEOUT_S = float(os.getenv("MODEL_DISCOVERY_TIMEOUT_S", "3"))
58
  CACHE_TTL_S = float(os.getenv("MODEL_DISCOVERY_TTL_S", "30"))
59
+ # "Nothing answered" is remembered briefly, so a hiccup clears itself on the next look instead
60
+ # of holding the "Ollama unavailable" banner for the full cache lifetime.
61
+ NEGATIVE_TTL_S = float(os.getenv("MODEL_DISCOVERY_NEGATIVE_TTL_S", "5"))
62
 
63
  # ── Model classification (single source of truth; llm.py re-exports) ──────────
64
  VISION_ONLY_HINTS = (
 
231
  return os.path.exists("/.dockerenv") or "docker" in _read_text("/proc/1/cgroup")
232
 
233
 
234
+ _WSL_MODE: Dict[str, str] = {}
235
+
236
+
237
+ def wsl_networking_mode() -> str:
238
+ """``mirrored`` or ``nat`` (WSL 2), ``""`` outside WSL or when it cannot be told. In
239
+ mirrored mode WSL shares Windows' loopback, so Ollama on Windows' 127.0.0.1 is reachable as
240
+ 127.0.0.1 here and the "enable mirrored networking" advice does not apply."""
241
+ if "mode" in _WSL_MODE:
242
+ return _WSL_MODE["mode"]
243
+ mode = ""
244
+ if is_wsl():
245
+ try:
246
+ out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=2)
247
+ mode = (out.stdout or "").strip().lower()
248
+ except (OSError, subprocess.SubprocessError):
249
+ mode = ""
250
+ if mode not in ("mirrored", "nat"):
251
+ # Mirrored mode puts the host's loopback alias 10.255.255.254 on lo.
252
+ mode = "mirrored" if "10.255.255.254" in _read_text("/proc/net/fib_trie") else "nat"
253
+ _WSL_MODE["mode"] = mode
254
+ return mode
255
+
256
+
257
  def _default_gateway_from_proc() -> str:
258
  """Default route gateway from ``/proc/net/route`` (little-endian hex)."""
259
  text = _read_text("/proc/net/route")
 
309
  add("configured", OLLAMA_BASE_URL)
310
  add("loopback", "http://127.0.0.1:11434")
311
  add("loopback", "http://localhost:11434")
312
+ if is_wsl() and wsl_networking_mode() != "mirrored":
313
+ # NAT mode only: with mirrored networking Windows' Ollama is 127.0.0.1 here, and the
314
+ # gateway / nameserver addresses are the LAN router, which never runs Ollama.
315
  for ip in wsl_host_ips():
316
  add("wsl-host", f"http://{ip}:11434")
317
  if is_docker():
 
411
  log.info("model discovery cache invalidated: %s", reason)
412
 
413
 
414
+ def _fresh(ts: float, runtimes: List[Runtime], now: float) -> bool:
415
+ """A cached probe result still holds: ``CACHE_TTL_S`` when something answered,
416
+ ``NEGATIVE_TTL_S`` when nothing did (a hiccup must not stick)."""
417
+ ttl = CACHE_TTL_S if any(r.reachable for r in runtimes) else NEGATIVE_TTL_S
418
+ return now - ts < ttl
419
+
420
+
421
  async def discover(force: bool = False) -> List[Runtime]:
422
+ """All candidate runtimes with their probe results, cached (see :func:`_fresh`)."""
423
  now = time.monotonic()
424
+ if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now):
425
  return list(_cache["runtimes"])
426
  async with _get_lock():
427
  now = time.monotonic()
428
+ if not force and _cache["runtimes"] and _fresh(_cache["ts"], _cache["runtimes"], now):
429
  return list(_cache["runtimes"])
430
  cands = candidate_urls()
431
  results = await asyncio.gather(*(probe(u, label) for label, u in cands))
 
446
  key = url.rstrip("/")
447
  now = time.monotonic()
448
  hit = _url_cache.get(key)
449
+ if hit and not force and _fresh(hit[0], [hit[1]], now):
450
  return hit[1]
451
  rt = await probe(key, "explicit")
452
  _url_cache[key] = (time.monotonic(), rt)
 
468
  return reachable[0]
469
 
470
 
471
+ def ollama_root(url: str) -> str:
472
+ """The Ollama root a URL points at: Ollama serves its own API (``/api/tags``) and the
473
+ OpenAI-compatible one (``/v1``) from the same root, so ``http://host:11434/v1`` β€” what an
474
+ OpenAI-style "Base URL" field holds β€” means ``http://host:11434``."""
475
+ u = (url or "").strip().rstrip("/")
476
+ for suffix in ("/v1", "/api"):
477
+ if u.lower().endswith(suffix):
478
+ u = u[: -len(suffix)]
479
+ return u
480
+
481
+
482
  async def active_runtime(base_url: Optional[str] = None, force: bool = False) -> Tuple[Optional[Runtime], List[Runtime]]:
483
+ """The Ollama to use. An explicit ``base_url`` wins when it is an Ollama (or answers like one,
484
+ a proxy that lists no tags). When it does not answer as Ollama at all β€” an OpenAI-compatible
485
+ gateway such as OllaBridge (404 on ``/api/tags``), or nothing β€” the usual places are searched
486
+ instead of reporting Ollama missing while it runs on its own port."""
487
  if base_url:
488
+ root = ollama_root(base_url)
489
+ rt = await discover_url(root, force=force)
490
+ if rt.reachable:
491
+ return rt, [rt]
492
+ if os.getenv("OLLAMA_DISCOVERY", "auto").strip().lower() in ("explicit", "strict"):
493
+ return None, [rt]
494
+ runtimes = [r for r in await discover(force=force) if r.url != rt.url]
495
+ found = pick_runtime(runtimes)
496
+ if found is not None:
497
+ log.info("model discovery: %s is not an Ollama runtime (%s); using Ollama at %s",
498
+ base_url, rt.error or "no /api/tags", found.url)
499
+ return found, [rt, *runtimes]
500
  runtimes = await discover(force=force)
501
  return pick_runtime(runtimes), runtimes
502
 
 
607
  }
608
  probed = [r.url for r in runtimes]
609
  wsl_hosts = [r for r in runtimes if r.label == "wsl-host"]
610
+ if is_wsl() and wsl_networking_mode() == "mirrored":
611
+ # WSL shares Windows' loopback: a Windows Ollama would have answered on 127.0.0.1, so
612
+ # it is stopped, starting, or too busy to answer β€” not unreachable.
613
+ return {
614
+ "kind": "start",
615
+ "message": (
616
+ "Ollama did not answer. WSL uses mirrored networking, so an Ollama running on Windows "
617
+ "is reachable here: it is probably stopped, still starting, or busy loading a model. "
618
+ "Start the Ollama app on Windows (if `ollama serve` says the port is already in use, "
619
+ "it is running), wait a few seconds, then press Check again."
620
+ ),
621
+ "command": "ollama serve",
622
+ "probed": probed,
623
+ "suggested": suggested,
624
+ }
625
  if is_wsl() and wsl_hosts:
626
  return {
627
  "kind": "wsl_bridge",
 
675
  model, source = "", "none"
676
  urls = ", ".join(r.url for r in runtimes) or (base_url or OLLAMA_BASE_URL)
677
  reason = f"No Ollama runtime is reachable (tried {urls})."
678
+ elif base_url and runtime.url != ollama_root(base_url):
679
+ reason = (f"{base_url} is not an Ollama runtime (an OpenAI-compatible gateway such as "
680
+ f"OllaBridge?); found Ollama at {runtime.url}. " + reason)
681
  res = Resolution(
682
  role=role,
683
  configured=cfg,
backend/app/projects.py CHANGED
@@ -1150,7 +1150,11 @@ You have access to the project's context. When relevant context from the knowled
1150
 
1151
  # 5b. Hybrid intent detection β€” inject dynamic hint for photo-related messages
1152
  # so the LLM gets precise guidance on what to do, without bypassing its reasoning.
1153
- if project_data and project_data.get("project_type") == "persona":
 
 
 
 
1154
  import re as _hint_re
1155
  _user_msg = (payload.get("message") or "").lower().strip()
1156
 
 
1150
 
1151
  # 5b. Hybrid intent detection β€” inject dynamic hint for photo-related messages
1152
  # so the LLM gets precise guidance on what to do, without bypassing its reasoning.
1153
+ # Short messages only: a pasted document's "back" or "first" is not a request to turn
1154
+ # around or show photo 1 (intent_guard.py).
1155
+ from .intent_guard import is_short_command as _is_short_command
1156
+ if (project_data and project_data.get("project_type") == "persona"
1157
+ and _is_short_command(payload.get("message") or "")):
1158
  import re as _hint_re
1159
  _user_msg = (payload.get("message") or "").lower().strip()
1160
 
backend/app/storage.py CHANGED
@@ -277,11 +277,43 @@ def init_db():
277
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user ON file_assets(user_id)")
278
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user_kind ON file_assets(user_id, kind)")
279
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_project ON file_assets(project_id)")
 
280
 
281
  con.commit()
282
  con.close()
283
 
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  def ensure_conversation_owner(conversation_id: str, user_id: str) -> None:
286
  """Set owner on first use (idempotent)."""
287
  if not conversation_id or not user_id:
 
277
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user ON file_assets(user_id)")
278
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_user_kind ON file_assets(user_id, kind)")
279
  cur.execute("CREATE INDEX IF NOT EXISTS idx_file_assets_project ON file_assets(project_id)")
280
+ ensure_file_assets_columns(con)
281
 
282
  con.commit()
283
  con.close()
284
 
285
 
286
+ # Columns file_assets gained after its first shape (docs/design/MEDIA_STORAGE.md). Each is added
287
+ # idempotently with a default that is true for every row written before it existed.
288
+ _FILE_ASSET_COLUMNS = (
289
+ ("storage_backend", "TEXT NOT NULL DEFAULT 'local'"), # which media backend holds the bytes (Phase 3a)
290
+ ("sha256", "TEXT NOT NULL DEFAULT ''"), # of the bytes as written; '' when unknown (Phase 3)
291
+ ("visibility", "TEXT NOT NULL DEFAULT 'private'"), # 'private' | 'public' (Phase 4)
292
+ ("deleted_at", "TEXT"), # soft deletion; bytes go when the trash is purged (Phase 4)
293
+ )
294
+
295
+
296
+ def ensure_file_assets_columns(con: sqlite3.Connection) -> list:
297
+ """Add whichever of the later file_assets columns this database lacks. Returns their names."""
298
+ cur = con.cursor()
299
+ present = {row[1] for row in cur.execute("PRAGMA table_info(file_assets)").fetchall()}
300
+ if not present:
301
+ return []
302
+ added = []
303
+ for name, spec in _FILE_ASSET_COLUMNS:
304
+ if name not in present:
305
+ cur.execute(f"ALTER TABLE file_assets ADD COLUMN {name} {spec}")
306
+ added.append(name)
307
+ if added:
308
+ con.commit()
309
+ return added
310
+
311
+
312
+ def ensure_file_assets_backend_column(con: sqlite3.Connection) -> bool:
313
+ """Phase 3a's entry point, kept: True when storage_backend was just added."""
314
+ return "storage_backend" in ensure_file_assets_columns(con)
315
+
316
+
317
  def ensure_conversation_owner(conversation_id: str, user_id: str) -> None:
318
  """Set owner on first use (idempotent)."""
319
  if not conversation_id or not user_id:
backend/app/studio/bundles.py CHANGED
@@ -300,7 +300,8 @@ def bundle_install_status(bundle: EssayVideoBundle) -> Dict[str, Any]:
300
  # ── Installation (reinstall-safe: download.py skips files on disk) ──────────
301
 
302
  def _download_script() -> Path:
303
- return Path(__file__).resolve().parents[3] / "scripts" / "download.py"
 
304
 
305
 
306
  def install_plan(bundle: EssayVideoBundle) -> List[List[str]]:
 
300
  # ── Installation (reinstall-safe: download.py skips files on disk) ──────────
301
 
302
  def _download_script() -> Path:
303
+ from ..download_script import download_script # the repo's, or a packaged image's copy
304
+ return download_script()
305
 
306
 
307
  def install_plan(bundle: EssayVideoBundle) -> List[List[str]]:
backend/app/users.py CHANGED
@@ -1131,19 +1131,13 @@ async def upload_avatar(
1131
  if len(contents) > 5 * 1024 * 1024:
1132
  raise HTTPException(400, "Avatar image must be under 5MB")
1133
 
1134
- # Determine upload directory (reuse existing upload path logic)
1135
- upload_dir = UPLOAD_DIR
1136
- if not os.path.isabs(upload_dir):
1137
- import pathlib
1138
- _backend_dir = pathlib.Path(__file__).resolve().parents[1]
1139
- upload_dir = str(_backend_dir / "data" / "uploads")
1140
- os.makedirs(upload_dir, exist_ok=True)
1141
-
1142
- # Save with unique name
1143
- filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
1144
- filepath = os.path.join(upload_dir, filename)
1145
- with open(filepath, "wb") as f:
1146
- f.write(contents)
1147
 
1148
  # Build URL (relative to /files/ static mount)
1149
  avatar_url = f"/files/{filename}"
@@ -1151,10 +1145,8 @@ async def upload_avatar(
1151
  # Delete old avatar file if it exists (cleanup)
1152
  old_url = user.get("avatar_url", "")
1153
  if old_url and old_url.startswith("/files/"):
1154
- old_path = os.path.join(upload_dir, old_url.replace("/files/", "", 1))
1155
  try:
1156
- if os.path.exists(old_path):
1157
- os.remove(old_path)
1158
  except Exception:
1159
  pass # Non-fatal
1160
 
 
1131
  if len(contents) > 5 * 1024 * 1024:
1132
  raise HTTPException(400, "Avatar image must be under 5MB")
1133
 
1134
+ # Save with a unique name. The flat key keeps today's /files/<name> URL β€” and its
1135
+ # public-by-prefix serving (docs/design/MEDIA_STORAGE.md, audit).
1136
+ from .media_storage import get_storage
1137
+ storage = get_storage()
1138
+ # A random name: the picture is public on the login screen, the account id is not its business.
1139
+ filename = f"avatar_{uuid.uuid4().hex[:16]}{ext}"
1140
+ storage.put(filename, contents)
 
 
 
 
 
 
1141
 
1142
  # Build URL (relative to /files/ static mount)
1143
  avatar_url = f"/files/{filename}"
 
1145
  # Delete old avatar file if it exists (cleanup)
1146
  old_url = user.get("avatar_url", "")
1147
  if old_url and old_url.startswith("/files/"):
 
1148
  try:
1149
+ storage.delete(old_url.replace("/files/", "", 1))
 
1150
  except Exception:
1151
  pass # Non-fatal
1152
 
backend/requirements.txt CHANGED
@@ -24,3 +24,6 @@ faster-whisper==1.2.1
24
  # PyAV 19 dropped an argument faster-whisper 1.2.1 passes; see requirements/speech-cpu.txt.
25
  av==18.1.0
26
  ctranslate2==4.8.2
 
 
 
 
24
  # PyAV 19 dropped an argument faster-whisper 1.2.1 passes; see requirements/speech-cpu.txt.
25
  av==18.1.0
26
  ctranslate2==4.8.2
27
+ # Media in an S3-compatible object store (MEDIA_STORAGE_BACKEND=s3; docs/design/MEDIA_STORAGE.md).
28
+ # Imported only when that backend is selected; CI and the packaged images install this file.
29
+ boto3>=1.34
deploy/huggingface-space/hf_wrapper.py CHANGED
@@ -18,6 +18,7 @@ import uvicorn
18
  from fastapi import Request
19
  from fastapi.responses import FileResponse, JSONResponse
20
  from fastapi.staticfiles import StaticFiles
 
21
 
22
  # Ensure the app module is importable.
23
  sys.path.insert(0, "/app")
@@ -155,9 +156,48 @@ def frontend_catchall(full_path: str, request: Request):
155
  return FileResponse(FRONTEND_INDEX)
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # ── Run ──────────────────────────────────────────────────
159
 
160
  if __name__ == "__main__":
 
 
161
  port = int(os.environ.get("PORT", "7860"))
162
  uvicorn.run(
163
  app,
 
18
  from fastapi import Request
19
  from fastapi.responses import FileResponse, JSONResponse
20
  from fastapi.staticfiles import StaticFiles
21
+ from starlette.routing import Match
22
 
23
  # Ensure the app module is importable.
24
  sys.path.insert(0, "/app")
 
156
  return FileResponse(FRONTEND_INDEX)
157
 
158
 
159
+ @app.api_route("/{full_path:path}", methods=["POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
160
+ def api_catchall(full_path: str):
161
+ """An API route that is not there is a 404. Without this, a POST to a path only the GET
162
+ catch-all above knew came back "405 Method Not Allowed" β€” which is how a missing Expert
163
+ package showed up in the chat as `HTTP 405` after "Hello"."""
164
+ return JSONResponse({"detail": "Not Found", "path": f"/{full_path}"}, status_code=404)
165
+
166
+
167
+ # ── Route check ──────────────────────────────────────────
168
+ # What the UI and external clients call. A missing one is logged at boot, instead of being
169
+ # found by a user as an HTTP error (backend/tests/test_required_routes.py checks the same).
170
+
171
+ REQUIRED_ROUTES = (
172
+ ("GET", "/health"),
173
+ ("POST", "/chat"),
174
+ ("POST", "/v1/chat/completions"),
175
+ ("GET", "/v1/models"),
176
+ ("POST", "/models/install"),
177
+ ("GET", "/models/install/progress"),
178
+ ("POST", "/v1/expert/chat"),
179
+ )
180
+
181
+
182
+ def missing_routes(application=app):
183
+ """Required routes the app does not serve β€” asked of the router's own matching, so routers
184
+ FastAPI includes lazily count too, and the two catch-alls above do not."""
185
+ catchalls = {frontend_catchall, api_catchall}
186
+ missing = []
187
+ for method, path in REQUIRED_ROUTES:
188
+ scope = {"type": "http", "method": method, "path": path, "root_path": "",
189
+ "query_string": b"", "headers": [], "path_params": {}, "app": application}
190
+ if not any(getattr(r, "endpoint", None) not in catchalls and r.matches(scope)[0] == Match.FULL
191
+ for r in application.router.routes):
192
+ missing.append(f"{method} {path}")
193
+ return missing
194
+
195
+
196
  # ── Run ──────────────────────────────────────────────────
197
 
198
  if __name__ == "__main__":
199
+ for _route in missing_routes():
200
+ print(f"[routes] ⚠ missing: {_route}", flush=True)
201
  port = int(os.environ.get("PORT", "7860"))
202
  uvicorn.run(
203
  app,
expert/backend/app/expert/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # expert β€” multi-provider AI intelligence add-on for HomePilot
2
+ # Additive module: no existing files modified except 2-line patch in main.py
3
+ from .routes import router
4
+
5
+ __all__ = ["router"]
expert/backend/app/expert/capability.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What the connected models can carry β€” the gate for Deep research and Heavy's escalation.
2
+
3
+ A deep-agent run (plan, tool calls over many rounds, long synthesis) needs a strong,
4
+ tool-calling model; a 3B local model loops and hallucinates calls. So the strength of the
5
+ models on the user's connection is measured, not assumed:
6
+
7
+ * Ollama: ``/api/tags`` says each model's parameter count.
8
+ * OpenAI-compatible gateways (OllaBridge and friends): ``/models`` lists ids; the names carry
9
+ the hints β€” a size (``gemma4-31b``, ``gpt-oss-120b``) or a frontier family (``claude``,
10
+ ``gemini``, ``gpt``, ``glm-5``, ``kimi``, ``deepseek-v4``).
11
+
12
+ Tiers: ``high`` (frontier family, or β‰₯ EXPERT_DEEP_MIN_PARAMS_B parameters), ``medium``
13
+ (7B and up), ``low``. Deep research is offered when the best model on the connection is
14
+ ``high`` (``EXPERT_DEEP_RESEARCH=auto``); ``always`` / ``off`` override.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import time
21
+ from dataclasses import asdict, dataclass
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from .config import EXPERT_CAPABILITY_TTL_S, EXPERT_DEEP_MIN_PARAMS_B, EXPERT_DEEP_RESEARCH, EXPERT_LOCAL_MODEL
25
+ from . import providers
26
+ from . import state_store
27
+ from . import target as target_mod
28
+ from .target import ChatTarget
29
+
30
+ TIER_RANK = {"low": 0, "medium": 1, "high": 2}
31
+
32
+ # Frontier families: strong and tool-calling whatever the size suffix says.
33
+ _FRONTIER = re.compile(
34
+ r"(claude|openai|gpt-?[45o]|gpt-oss|\bo[134]\b|gemini|grok|kimi|glm-?[45]|deepseek-?v[3-9]|"
35
+ r"mistral-large|command-?a|llama-?4|qwen3?-?(?:235|max|coder-?480)|minimax|muse)",
36
+ re.I,
37
+ )
38
+ # Families that call tools reliably once they are big enough.
39
+ _TOOL_FAMILIES = re.compile(
40
+ r"(qwen2\.5|qwen3|llama-?3\.[1-9]|llama-?4|mistral|mixtral|gpt-oss|command|hermes|granite|"
41
+ r"phi-?4|deepseek-?v[3-9]|glm|gemma-?[34]|kimi|claude|openai|gpt|gemini|grok|muse)",
42
+ re.I,
43
+ )
44
+ # Reasoning distills that answer well but do not call tools (Ollama rejects the field).
45
+ _NO_TOOLS = re.compile(r"deepseek-r1", re.I)
46
+ _SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*b\b", re.I)
47
+
48
+ _CACHE: Dict[str, Dict[str, Any]] = {}
49
+ _CACHE_TTL_S = 30.0
50
+
51
+
52
+ @dataclass
53
+ class ModelProfile:
54
+ name: str
55
+ params_b: Optional[float]
56
+ tier: str
57
+ tools_likely: bool
58
+ reason: str
59
+
60
+ @property
61
+ def rank(self) -> int:
62
+ return TIER_RANK.get(self.tier, 0)
63
+
64
+ def as_dict(self) -> Dict[str, Any]:
65
+ d = asdict(self)
66
+ d["rank"] = self.rank
67
+ return d
68
+
69
+
70
+ def params_from_name(name: str) -> Optional[float]:
71
+ """``internal-qwen3-8-27b`` β†’ 27, ``gpt-oss-120b`` β†’ 120, ``deepseek-r1:latest`` β†’ None."""
72
+ hits = [float(m.group(1)) for m in _SIZE.finditer(name or "")]
73
+ return max(hits) if hits else None
74
+
75
+
76
+ def profile(name: str, params_b: Optional[float] = None) -> ModelProfile:
77
+ params = params_b if params_b is not None else params_from_name(name)
78
+ frontier = bool(_FRONTIER.search(name or ""))
79
+ no_tools = bool(_NO_TOOLS.search(name or ""))
80
+ tools_likely = (frontier or bool(_TOOL_FAMILIES.search(name or ""))) and not no_tools
81
+ if frontier and (params is None or params >= EXPERT_DEEP_MIN_PARAMS_B or not _SIZE.search(name or "")):
82
+ return ModelProfile(name, params, "high", tools_likely, "frontier family")
83
+ if params is not None and params >= EXPERT_DEEP_MIN_PARAMS_B:
84
+ return ModelProfile(name, params, "high", tools_likely, f"{params:g}B parameters")
85
+ if params is not None and params >= 7:
86
+ return ModelProfile(name, params, "medium", tools_likely, f"{params:g}B parameters")
87
+ if params is None:
88
+ tier = "medium" if tools_likely else "low"
89
+ return ModelProfile(name, None, tier, tools_likely, "size unknown")
90
+ return ModelProfile(name, params, "low", tools_likely, f"{params:g}B parameters")
91
+
92
+
93
+ def best_of(profiles: List[ModelProfile]) -> Optional[ModelProfile]:
94
+ if not profiles:
95
+ return None
96
+ return max(profiles, key=lambda p: (p.rank, 1 if p.tools_likely else 0, p.params_b or 0.0))
97
+
98
+
99
+ def deep_research_enabled(best: Optional[ModelProfile], verified: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
100
+ """``verified``: the probe's findings for ``best`` (see :func:`probe`); a model known to
101
+ reject tools cannot research, whatever its size."""
102
+ mode = EXPERT_DEEP_RESEARCH
103
+ if mode == "always":
104
+ return {"enabled": True, "reason": "EXPERT_DEEP_RESEARCH=always", "model": best.name if best else None}
105
+ if mode == "off":
106
+ return {"enabled": False, "reason": "Deep research is turned off (EXPERT_DEEP_RESEARCH=off).", "model": None}
107
+ if best is None:
108
+ return {"enabled": False, "reason": "No model answered on this connection.", "model": None}
109
+ if best.tier == "high":
110
+ if verified and verified.get("tools") == "no":
111
+ # The harness runs the searches for a model that cannot call tools (deep.py).
112
+ return {"enabled": True, "model": best.name,
113
+ "reason": f"{best.name} is a high-end model ({best.reason}); it cannot call tools, so HomePilot runs the searches for it."}
114
+ return {"enabled": True, "reason": f"{best.name} is a high-end model ({best.reason}).", "model": best.name}
115
+ return {
116
+ "enabled": False,
117
+ "reason": (
118
+ f"Deep research needs a high-end model (a frontier family or β‰₯ {EXPERT_DEEP_MIN_PARAMS_B:g}B); "
119
+ f"the strongest here is {best.name} ({best.reason})."
120
+ ),
121
+ "model": None,
122
+ }
123
+
124
+
125
+ def _connection_key(target: Optional[ChatTarget]) -> str:
126
+ if target is None:
127
+ return "ollama|"
128
+ return f"{target.provider}|{target.base_url}"
129
+
130
+
131
+ async def list_profiles(target: Optional[ChatTarget]) -> List[ModelProfile]:
132
+ """Every model on the connection ``target`` names (the default Ollama when None)."""
133
+ if target is None or target.is_ollama:
134
+ rows = await providers.ollama_models_detail(target.base_url if target and target.base_url else None)
135
+ return [profile(r["name"], r.get("params_b")) for r in rows if r.get("name")]
136
+ names = await providers.openai_compat_models(target.base_url, target.api_key)
137
+ return [profile(n) for n in names]
138
+
139
+
140
+ # ── Probes: measured facts, cached in the state store ────────────────────────
141
+
142
+ PING_TOOL = [{
143
+ "type": "function",
144
+ "function": {
145
+ "name": "ping",
146
+ "description": "Reports that tool calling works. Call it with the given word.",
147
+ "parameters": {"type": "object", "properties": {"word": {"type": "string"}}, "required": ["word"]},
148
+ },
149
+ }]
150
+
151
+
152
+ def probe_key(target: Optional[ChatTarget], model: Optional[str]) -> str:
153
+ from .tool_loop import capability_key # the loop's learned facts use the same key
154
+ return capability_key(target, "local", model)
155
+
156
+
157
+ def verified_for(target: Optional[ChatTarget], model: Optional[str]) -> Optional[Dict[str, Any]]:
158
+ """The probe's findings for this connection and model, when they are on record. A fact the
159
+ tool loop learned by itself (a refusal, a successful call) counts as well."""
160
+ key = probe_key(target, model)
161
+ found = state_store.kv_get(f"probe:{key}")
162
+ if found:
163
+ return found
164
+ learned = state_store.kv_get(f"tools:{key}")
165
+ if learned is not None and isinstance(learned.get("ok"), bool):
166
+ return {"tools": "yes" if learned["ok"] else "no", "json": "unknown", "source": learned.get("source", "observed"),
167
+ "probed_at": learned.get("at")}
168
+ return None
169
+
170
+
171
+ async def probe(target: Optional[ChatTarget], model: Optional[str], *, force: bool = False) -> Dict[str, Any]:
172
+ """Two short requests, once per model: does it return a structured tool call, and does it
173
+ follow a tiny JSON instruction. Findings are kept for EXPERT_CAPABILITY_TTL_S."""
174
+ from .router import dispatch
175
+ from .providers import ToolsUnsupported
176
+ from .tool_loop import remember_tools
177
+
178
+ key = probe_key(target, model)
179
+ if not force:
180
+ cached = state_store.kv_get(f"probe:{key}")
181
+ if cached:
182
+ return cached
183
+ result: Dict[str, Any] = {"tools": "unclear", "json": "no", "source": "probe", "probed_at": time.time(), "errors": []}
184
+ with target_mod.using(target):
185
+ try:
186
+ raw = await dispatch(
187
+ [{"role": "system", "content": "You have one tool, ping. When asked, call it; do not answer in prose."},
188
+ {"role": "user", "content": "Call ping with the word \"pong\"."}],
189
+ "local", model=model, temperature=0.0, max_tokens=64, tools=PING_TOOL,
190
+ )
191
+ calls = (((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("tool_calls") or []
192
+ result["tools"] = "yes" if calls else "unclear"
193
+ except ToolsUnsupported as exc:
194
+ result["tools"] = "no"
195
+ result["errors"].append(str(exc)[:200])
196
+ except Exception as exc: # noqa: BLE001
197
+ result["tools"] = "error"
198
+ result["errors"].append(f"{type(exc).__name__}: {exc}"[:200])
199
+ try:
200
+ raw = await dispatch(
201
+ [{"role": "user", "content": 'Reply with exactly this JSON and nothing else: {"ok": true}'}],
202
+ "local", model=model, temperature=0.0, max_tokens=32,
203
+ )
204
+ text = str((((raw.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
205
+ m = re.search(r"\{.*\}", text, flags=re.S)
206
+ result["json"] = "yes" if m and json.loads(m.group(0)).get("ok") is True else "no"
207
+ except Exception as exc: # noqa: BLE001
208
+ result["errors"].append(f"{type(exc).__name__}: {exc}"[:200])
209
+ if result["tools"] in ("yes", "no"):
210
+ remember_tools(key, result["tools"] == "yes", source="probe")
211
+ if result["tools"] != "error":
212
+ state_store.kv_set(f"probe:{key}", result, ttl_s=EXPERT_CAPABILITY_TTL_S)
213
+ return result
214
+
215
+
216
+ async def assess(target: Optional[ChatTarget], selected_model: Optional[str] = None, *, run_probe: bool = False) -> Dict[str, Any]:
217
+ """The selected model's profile, the best one on the connection, and the Deep research gate.
218
+ ``run_probe`` measures the selected model's tool calling once (cached) instead of guessing."""
219
+ key = _connection_key(target)
220
+ now = time.monotonic()
221
+ cached = _CACHE.get(key)
222
+ if cached and now - cached["ts"] < _CACHE_TTL_S:
223
+ profiles: List[ModelProfile] = cached["profiles"]
224
+ else:
225
+ profiles = await list_profiles(target)
226
+ _CACHE[key] = {"ts": now, "profiles": profiles}
227
+
228
+ selected_name = selected_model or (target.model if target else "") or EXPERT_LOCAL_MODEL
229
+ by_name = {p.name: p for p in profiles}
230
+ selected = by_name.get(selected_name)
231
+ if selected is None:
232
+ stem = selected_name.split(":")[0]
233
+ selected = next((p for p in profiles if p.name.split(":")[0] == stem), None) or profile(selected_name)
234
+ best = best_of(profiles) or selected
235
+
236
+ verified = verified_for(target, selected.name)
237
+ if run_probe and verified is None:
238
+ try:
239
+ verified = await probe(target, selected.name)
240
+ except Exception: # noqa: BLE001
241
+ verified = None
242
+ if verified and verified.get("tools") in ("yes", "no"):
243
+ selected = ModelProfile(selected.name, selected.params_b, selected.tier, verified["tools"] == "yes", selected.reason)
244
+ # The gate judges the model the user chose: it is the model for every mode, Deep research
245
+ # included. ``best`` stays in the answer as information.
246
+ gate = deep_research_enabled(selected, verified)
247
+ return {
248
+ "connection": "ollama" if (target is None or target.is_ollama) else (target.provider or "openai_compat"),
249
+ "selected": selected.as_dict(),
250
+ "best": best.as_dict() if best else None,
251
+ "tier": selected.tier,
252
+ "verified": verified,
253
+ "models": [p.as_dict() for p in sorted(profiles, key=lambda p: (-p.rank, -(p.params_b or 0)))][:40],
254
+ "deep_research": gate,
255
+ }
256
+
257
+
258
+ def stronger(best: Optional[Dict[str, Any]], selected: Dict[str, Any]) -> bool:
259
+ """Whether ``best`` is a meaningful step up from ``selected`` (Heavy's escalation, Deep
260
+ research's model choice): a higher tier, or β€” below the high tier β€” the same tier with at
261
+ least twice the known parameters. A high-end model the user picked is never swapped for
262
+ another high-end one."""
263
+ if not best or best.get("name") == selected.get("name"):
264
+ return False
265
+ best_rank, sel_rank = best.get("rank", 0), selected.get("rank", 0)
266
+ if best_rank > sel_rank:
267
+ return True
268
+ if best_rank < sel_rank or sel_rank >= TIER_RANK["high"]:
269
+ return False
270
+ bp, sp = best.get("params_b"), selected.get("params_b")
271
+ return bool(bp and sp and bp >= 2 * sp)
272
+
273
+
274
+ def reset_cache() -> None:
275
+ _CACHE.clear()
expert/backend/app/expert/compaction.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compaction: turns older than the window become a running note instead of vanishing.
2
+
3
+ What ChatGPT and Claude do when a thread outgrows the context: keep the recent turns
4
+ verbatim, and carry the rest as a short memory note β€” facts, decisions, preferences, open
5
+ questions β€” refreshed incrementally. One small model call per overflow, cached per session.
6
+ The note travels like grounding does: a context variable that ``build_messages`` appends to
7
+ the system prompt of every pipeline step.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import logging
13
+ import re
14
+ import time
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar
17
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
18
+
19
+ from .config import (
20
+ EXPERT_COMPACTION_ENABLED,
21
+ EXPERT_COMPACTION_MAX_INPUT_CHARS,
22
+ EXPERT_COMPACTION_MAX_TOKENS,
23
+ )
24
+
25
+ logger = logging.getLogger("expert.compaction")
26
+
27
+ _CURRENT: ContextVar[str] = ContextVar("expert_compaction_note", default="")
28
+ _CACHE: Dict[str, Dict[str, Any]] = {}
29
+ _CACHE_MAX = 500
30
+
31
+ SUMMARY_SYSTEM = (
32
+ "You maintain a compact memory note of a conversation. Update the note with the new turns: "
33
+ "keep facts, decisions, preferences, names, numbers, open questions and the user's goals; "
34
+ "drop chit-chat. Write in the third person, at most 250 words, no preamble."
35
+ )
36
+
37
+
38
+ def apply(system_prompt: str) -> str:
39
+ """``system_prompt`` with this request's note appended (unchanged when there is none)."""
40
+ note = _CURRENT.get()
41
+ if not note:
42
+ return system_prompt
43
+ return f"{system_prompt}\n\nSummary of the earlier conversation (turns not shown):\n{note}"
44
+
45
+
46
+ @contextmanager
47
+ def using(note: Optional[str]) -> Iterator[str]:
48
+ token = _CURRENT.set((note or "").strip())
49
+ try:
50
+ yield _CURRENT.get()
51
+ finally:
52
+ _CURRENT.reset(token)
53
+
54
+
55
+ def _digest(turns: List[Dict[str, Any]]) -> str:
56
+ h = hashlib.sha1()
57
+ for t in turns:
58
+ h.update(f"{t.get('role')}\x1f{t.get('content')}\x1e".encode("utf-8", "replace"))
59
+ return h.hexdigest()
60
+
61
+
62
+ def split(history: Optional[List[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
63
+ """``(older, window)``: the window exactly as the pipelines will see it, and the clean turns
64
+ before it."""
65
+ from .router import clean_turns, history_window
66
+
67
+ clean = clean_turns(history)
68
+ window = history_window(clean)
69
+ older = clean[: len(clean) - len(window)] if window else clean
70
+ return older, window
71
+
72
+
73
+ def render(turns: List[Dict[str, Any]], max_chars: int) -> str:
74
+ """The turns as a transcript, newest kept when the budget cuts."""
75
+ lines = [f"{'User' if t.get('role') == 'user' else 'Assistant'}: {t.get('content', '')}" for t in turns]
76
+ text = "\n".join(lines)
77
+ if len(text) > max_chars:
78
+ text = "…" + text[-max_chars:]
79
+ return text
80
+
81
+
82
+ def _clean(summary: str) -> str:
83
+ summary = re.sub(r"<think>.*?</think>", "", summary or "", flags=re.S)
84
+ return summary.strip()
85
+
86
+
87
+ async def summarize(previous: str, transcript: str, provider: str, model: Optional[str], temperature: float) -> str:
88
+ from .router import dispatch
89
+
90
+ messages = [
91
+ {"role": "system", "content": SUMMARY_SYSTEM},
92
+ {"role": "user", "content": f"Current note:\n{previous or '(none yet)'}\n\nNew turns:\n{transcript}\n\nUpdated note:"},
93
+ ]
94
+ raw = await dispatch(messages, provider, model=model, temperature=temperature, max_tokens=EXPERT_COMPACTION_MAX_TOKENS)
95
+ try:
96
+ return _clean(raw["choices"][0]["message"]["content"] or "")
97
+ except (KeyError, IndexError, TypeError):
98
+ return ""
99
+
100
+
101
+ def _remember(session_id: str, key: str, count: int, note: str) -> None:
102
+ if len(_CACHE) >= _CACHE_MAX:
103
+ oldest = min(_CACHE.items(), key=lambda kv: kv[1]["ts"])[0]
104
+ _CACHE.pop(oldest, None)
105
+ _CACHE[session_id] = {"key": key, "count": count, "note": note, "ts": time.monotonic()}
106
+
107
+
108
+ def cached_note(session_id: Optional[str]) -> Optional[str]:
109
+ entry = _CACHE.get(session_id or "")
110
+ return entry["note"] if entry else None
111
+
112
+
113
+ def needs_summary(history: Optional[List[Dict[str, Any]]], session_id: Optional[str]) -> int:
114
+ """How many older turns :func:`prepare` would summarize with a model call now (0: nothing
115
+ overflowed, or the session's note already covers them). No model call β€” the stream uses it
116
+ to show "Compacting the conversation…" before the summary starts."""
117
+ if not EXPERT_COMPACTION_ENABLED:
118
+ return 0
119
+ older, _window = split(history)
120
+ if not older:
121
+ return 0
122
+ key = _digest(older)
123
+ cached = _CACHE.get(session_id or "")
124
+ if cached and cached["key"] == key:
125
+ return 0
126
+ if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]:
127
+ return len(older) - cached["count"]
128
+ return len(older)
129
+
130
+
131
+ async def prepare(
132
+ history: Optional[List[Dict[str, Any]]],
133
+ *,
134
+ session_id: Optional[str],
135
+ provider: str,
136
+ model: Optional[str] = None,
137
+ temperature: float = 0.2,
138
+ ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str]]:
139
+ """``(window, note, notice)``: the turns to send verbatim, the note for the ones before
140
+ them (None when nothing overflowed), and a user-facing notice when summarizing failed.
141
+
142
+ Incremental per session: a thread whose overflow grew by two turns summarizes those two
143
+ on top of the previous note, not the whole history again."""
144
+ older, window = split(history)
145
+ if not EXPERT_COMPACTION_ENABLED or not older:
146
+ return window, None, None
147
+ key = _digest(older)
148
+ cached = _CACHE.get(session_id or "")
149
+ if cached and cached["key"] == key:
150
+ return window, cached["note"], None
151
+ previous, new_turns = "", older
152
+ if cached and cached["count"] < len(older) and _digest(older[: cached["count"]]) == cached["key"]:
153
+ previous, new_turns = cached["note"], older[cached["count"]:]
154
+ transcript = render(new_turns, EXPERT_COMPACTION_MAX_INPUT_CHARS)
155
+ try:
156
+ note = await summarize(previous, transcript, provider, model, temperature)
157
+ except Exception as exc: # noqa: BLE001
158
+ logger.warning("compaction: summarizing %d older turns failed: %s", len(new_turns), exc)
159
+ notice = (
160
+ f"The earlier part of this conversation could not be summarized ({type(exc).__name__}); "
161
+ f"only the last {len(window)} turns are in context."
162
+ )
163
+ return window, previous or None, notice
164
+ if not note:
165
+ return window, previous or None, None
166
+ if session_id:
167
+ _remember(session_id, key, len(older), note)
168
+ return window, note, None
expert/backend/app/expert/config.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/config.py
2
+ # All Expert module env vars β€” purely additive, no conflict with existing config.py
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ # ── xAI Grok ────────────────────────────────────────────────────────────────
8
+ GROK_API_KEY: str = os.getenv("GROK_API_KEY", "").strip()
9
+ GROK_BASE_URL: str = os.getenv("GROK_BASE_URL", "https://api.x.ai/v1").rstrip("/")
10
+ GROK_MODEL: str = os.getenv("GROK_MODEL", "grok-3").strip()
11
+
12
+ # ── Groq (ultra-fast open-model inference, free tier available) ──────────────
13
+ GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "").strip()
14
+ GROQ_BASE_URL: str = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/")
15
+ GROQ_MODEL: str = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip()
16
+
17
+ # ── Google Gemini ────────────────────────────────────────────────────────────
18
+ GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "").strip()
19
+ GEMINI_BASE_URL: str = os.getenv(
20
+ "GEMINI_BASE_URL",
21
+ "https://generativelanguage.googleapis.com/v1beta/openai"
22
+ ).rstrip("/")
23
+ GEMINI_MODEL: str = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip()
24
+
25
+ # ── Local Ollama (free, on-premises) ─────────────────────────────────────────
26
+ # Reuses existing OLLAMA_BASE_URL from main config if not overridden
27
+ EXPERT_OLLAMA_URL: str = os.getenv(
28
+ "EXPERT_OLLAMA_URL",
29
+ os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
30
+ ).rstrip("/")
31
+ # Default chosen so out-of-the-box Expert runs on a 12 GB consumer GPU.
32
+ # Operators with larger VRAM (or a cloud deploy) can point this at
33
+ # ``deepseek-r1:32b`` or similar for top-tier reasoning quality.
34
+ # If the configured model isn't pulled, chat_local falls back to any
35
+ # installed model via EXPERT_LOCAL_AUTO_FALLBACK β€” Expert never hard-fails
36
+ # on "model not found" as long as Ollama has at least one model.
37
+ EXPERT_LOCAL_MODEL: str = os.getenv("EXPERT_LOCAL_MODEL", "deepseek-r1:latest").strip()
38
+ EXPERT_LOCAL_FAST_MODEL: str = os.getenv("EXPERT_LOCAL_FAST_MODEL", "llama3.2:3b").strip()
39
+
40
+ # ── Routing thresholds ────────────────────────────────────────────────────────
41
+ # Complexity scores (0-10) that determine which tier to use
42
+ EXPERT_LOCAL_THRESHOLD: int = int(os.getenv("EXPERT_LOCAL_THRESHOLD", "3")) # ≀3 β†’ local fast
43
+ EXPERT_GROQ_THRESHOLD: int = int(os.getenv("EXPERT_GROQ_THRESHOLD", "6")) # ≀6 β†’ Groq 70B
44
+ # >6 β†’ Grok / Gemini / best available cloud
45
+
46
+ # ── Expert system identity ────────────────────────────────────────────────────
47
+ # "Expert" is the name of this pipeline (Fast / Think / Heavy), not a persona. The old
48
+ # prompt opened "You are Expert, an advanced AI assistant integrated into HomePilot", and
49
+ # models took it literally: a plain "hello" came back as "Hello! I am Expert, your HomePilot
50
+ # assistant. How can I help you today?". The assistant should sound like a capable colleague,
51
+ # not announce a product name.
52
+ EXPERT_SYSTEM_PROMPT: str = os.getenv(
53
+ "EXPERT_SYSTEM_PROMPT",
54
+ "You are a knowledgeable, professional assistant. Talk the way a thoughtful colleague "
55
+ "would: natural, warm and direct.\n"
56
+ "\n"
57
+ "- Reply in the language the user writes in.\n"
58
+ "- Fit the length to the request. A greeting or a quick question gets a short, friendly "
59
+ "reply; a complex problem gets a clear, well-structured answer that reasons it through.\n"
60
+ "- Do not introduce yourself, give yourself a name or describe what you are unless the "
61
+ "user asks.\n"
62
+ "- Skip filler and formula: no \"Great question!\", no \"As an AI…\", no stock sign-off "
63
+ "at the end of every answer.\n"
64
+ "- Be accurate. If something is uncertain or depends on information you do not have, say "
65
+ "so briefly instead of guessing. Never invent facts, figures or sources.\n"
66
+ "- Use Markdown (lists, headings, code blocks) only when it makes the answer easier to "
67
+ "read."
68
+ )
69
+
70
+ # ── Streaming ────────────────────────────────────────────────────────────────
71
+ EXPERT_STREAM_CHUNK_SIZE: int = int(os.getenv("EXPERT_STREAM_CHUNK_SIZE", "64"))
72
+ EXPERT_MAX_TOKENS: int = int(os.getenv("EXPERT_MAX_TOKENS", "2048"))
73
+ EXPERT_TEMPERATURE: float = float(os.getenv("EXPERT_TEMPERATURE", "0.7"))
74
+ # Conversation window handed to every pipeline call β€” fast, think and heavy alike: the newest
75
+ # turns that fit both budgets. The chat sends its last 8 turns; API callers may send any number.
76
+ EXPERT_HISTORY_MAX_TURNS: int = int(os.getenv("EXPERT_HISTORY_MAX_TURNS", "12"))
77
+ EXPERT_HISTORY_MAX_CHARS: int = int(os.getenv("EXPERT_HISTORY_MAX_CHARS", "16000"))
78
+ # Compaction: turns older than the window are summarized into a running note instead of being
79
+ # dropped (compaction.py). One short model call per overflow, cached per session.
80
+ EXPERT_COMPACTION_ENABLED: bool = os.getenv("EXPERT_COMPACTION_ENABLED", "1").lower() in ("1", "true", "yes")
81
+ EXPERT_COMPACTION_MAX_TOKENS: int = int(os.getenv("EXPERT_COMPACTION_MAX_TOKENS", "400"))
82
+ EXPERT_COMPACTION_MAX_INPUT_CHARS: int = int(os.getenv("EXPERT_COMPACTION_MAX_INPUT_CHARS", "24000"))
83
+
84
+ # ── Tools (tools.py, tool_loop.py) ───────────────────────────────────────────
85
+ # The per-turn tool loop: the model may call a curated set of HomePilot's MCP tools (web search,
86
+ # page fetch, workspace and notes search) while answering, ChatGPT-style. Servers speak JSON-RPC
87
+ # tools/list + tools/call on /rpc β€” HomePilot's own agentic servers by default; Context Forge's
88
+ # /rpc speaks the same protocol and can be added for federated tools.
89
+ EXPERT_TOOLS_ENABLED: bool = os.getenv("EXPERT_TOOLS_ENABLED", "1").lower() in ("1", "true", "yes")
90
+ EXPERT_TOOL_SERVERS: str = os.getenv(
91
+ "EXPERT_TOOL_SERVERS",
92
+ "http://127.0.0.1:9105/rpc,http://127.0.0.1:9102/rpc,http://127.0.0.1:9101/rpc",
93
+ )
94
+ # Native tool names offered to the model (comma-separated; '*' for every tool the servers list).
95
+ EXPERT_TOOL_ALLOWLIST: str = os.getenv(
96
+ "EXPERT_TOOL_ALLOWLIST",
97
+ "hp.web.search,hp.web.fetch,hp.search_workspace,hp.get_document,hp.personal.search",
98
+ )
99
+ EXPERT_TOOL_MAX_ROUNDS: int = int(os.getenv("EXPERT_TOOL_MAX_ROUNDS", "4"))
100
+ EXPERT_TOOL_MAX_CALLS_PER_ROUND: int = int(os.getenv("EXPERT_TOOL_MAX_CALLS_PER_ROUND", "4"))
101
+ EXPERT_TOOL_TIMEOUT_S: float = float(os.getenv("EXPERT_TOOL_TIMEOUT_S", "45"))
102
+ EXPERT_TOOL_RESULT_MAX_CHARS: int = int(os.getenv("EXPERT_TOOL_RESULT_MAX_CHARS", "6000"))
103
+
104
+ # ── Heavy: escalate to the strongest model on the same connection (routes.py) ─
105
+ # Off by default: the model chosen in the chat is the model for every mode. Set to 1 to let
106
+ # Heavy move to a clearly stronger model of the same connection, with a notice.
107
+ EXPERT_HEAVY_ESCALATE: bool = os.getenv("EXPERT_HEAVY_ESCALATE", "0").lower() in ("1", "true", "yes")
108
+
109
+ # ── Effort profiles (policies.py): what each mode may spend, not what it must ─
110
+ # A mode sets the ceiling; the request's complexity decides how much of it is used. A greeting
111
+ # in Heavy is one generation; a hard question gets the full pipeline.
112
+ EXPERT_FAST_TEMPERATURE: float = float(os.getenv("EXPERT_FAST_TEMPERATURE", "0.7"))
113
+ EXPERT_FAST_MAX_TOKENS: int = int(os.getenv("EXPERT_FAST_MAX_TOKENS", "1024"))
114
+ EXPERT_FAST_TOOL_ROUNDS: int = int(os.getenv("EXPERT_FAST_TOOL_ROUNDS", "2"))
115
+ EXPERT_THINK_TEMPERATURE: float = float(os.getenv("EXPERT_THINK_TEMPERATURE", "0.4"))
116
+ EXPERT_THINK_MAX_TOKENS: int = int(os.getenv("EXPERT_THINK_MAX_TOKENS", "2048"))
117
+ EXPERT_THINK_TOOL_ROUNDS: int = int(os.getenv("EXPERT_THINK_TOOL_ROUNDS", "4"))
118
+ EXPERT_THINK_PLAN_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_THINK_PLAN_MIN_COMPLEXITY", "4"))
119
+ EXPERT_HEAVY_TEMPERATURE: float = float(os.getenv("EXPERT_HEAVY_TEMPERATURE", "0.3"))
120
+ EXPERT_HEAVY_MAX_TOKENS: int = int(os.getenv("EXPERT_HEAVY_MAX_TOKENS", "3072"))
121
+ EXPERT_HEAVY_TOOL_ROUNDS: int = int(os.getenv("EXPERT_HEAVY_TOOL_ROUNDS", "4"))
122
+ EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY: int = int(os.getenv("EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY", "5"))
123
+ EXPERT_DEEP_TEMPERATURE: float = float(os.getenv("EXPERT_DEEP_TEMPERATURE", "0.3"))
124
+
125
+ # ── Durable state (state_store.py): capability facts, Deep research jobs ─────
126
+ # Default: <DATA_DIR or backend/data>/expert_state.sqlite.
127
+ EXPERT_STATE_DB: str = os.getenv("EXPERT_STATE_DB", "").strip()
128
+ EXPERT_CAPABILITY_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_TTL_S", str(7 * 24 * 3600)))
129
+ # A learned refusal ("this connection rejects the tools field") is re-checked sooner than a
130
+ # success: gateways get fixed, and re-learning costs one rejected request plus a retry.
131
+ EXPERT_CAPABILITY_NEGATIVE_TTL_S: float = float(os.getenv("EXPERT_CAPABILITY_NEGATIVE_TTL_S", "3600"))
132
+ # `: keep-alive` SSE comment after this many silent seconds on /stream (0 disables): a long model
133
+ # call or a slow tool no longer looks like a dead connection to proxies, browsers, or the reader.
134
+ EXPERT_SSE_HEARTBEAT_S: float = float(os.getenv("EXPERT_SSE_HEARTBEAT_S", "10"))
135
+
136
+ # ── Adaptive pacing and retries for OpenAI-compatible endpoints (providers.py) ─
137
+ # One gateway is never flooded: at most MAX_INFLIGHT requests at a time, and a gap between
138
+ # request starts, per base URL. The gap starts at MIN_INTERVAL_S, doubles on every rate limit
139
+ # (up to MAX_INTERVAL_S, and every caller waits out the cooldown) and shrinks back 15 % per
140
+ # success β€” Heavy's agents and Deep research's sub-agents adapt to the gateway's real limit.
141
+ # Requests beyond MAX_INFLIGHT wait in a queue, a person's ahead of background work (Deep
142
+ # research). A 429, a body that says "rate limit", or a 502/503/504 is retried with exponential
143
+ # backoff and jitter (about 2 s, 4 s, 8 s, 16 s, 20 s…; Retry-After is a floor), always before
144
+ # the first token, for up to WAIT_S seconds in a chat turn and BACKGROUND_WAIT_S in a background
145
+ # job (RETRIES caps the tries). Meanwhile the chat shows "The model provider is busy; retrying…".
146
+ EXPERT_PROVIDER_MAX_INFLIGHT: int = int(os.getenv("EXPERT_PROVIDER_MAX_INFLIGHT", "2"))
147
+ EXPERT_PROVIDER_MIN_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MIN_INTERVAL_S", "1.0"))
148
+ EXPERT_PROVIDER_MAX_INTERVAL_S: float = float(os.getenv("EXPERT_PROVIDER_MAX_INTERVAL_S", "15"))
149
+ EXPERT_PROVIDER_RETRIES: int = int(os.getenv("EXPERT_PROVIDER_RETRIES", "8"))
150
+ EXPERT_PROVIDER_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_WAIT_S", "90"))
151
+ EXPERT_PROVIDER_BACKGROUND_WAIT_S: float = float(os.getenv("EXPERT_PROVIDER_BACKGROUND_WAIT_S", "300"))
152
+ # The usage meter's rolling window. On a rate limit the endpoint's cap is learned as the
153
+ # successes this window took; new requests then wait their turn to stay under it, and the cap
154
+ # grows by one after three quiet windows (GET /v1/expert/usage shows it all).
155
+ EXPERT_PROVIDER_WINDOW_S: float = float(os.getenv("EXPERT_PROVIDER_WINDOW_S", "60"))
156
+
157
+ # ── Deep research (deep.py, capability.py) ───────────────────────────────────
158
+ # auto: offered when the connection has a high-end model (capability.py); always / off override.
159
+ EXPERT_DEEP_RESEARCH: str = os.getenv("EXPERT_DEEP_RESEARCH", "auto").strip().lower() or "auto"
160
+ EXPERT_DEEP_MIN_PARAMS_B: float = float(os.getenv("EXPERT_DEEP_MIN_PARAMS_B", "20"))
161
+ EXPERT_DEEP_TIME_BUDGET_S: float = float(os.getenv("EXPERT_DEEP_TIME_BUDGET_S", "300"))
162
+ EXPERT_DEEP_MAX_ITEMS: int = int(os.getenv("EXPERT_DEEP_MAX_ITEMS", "5"))
163
+ EXPERT_DEEP_ROUNDS_PER_ITEM: int = int(os.getenv("EXPERT_DEEP_ROUNDS_PER_ITEM", "3"))
164
+ EXPERT_DEEP_JOB_TTL_S: float = float(os.getenv("EXPERT_DEEP_JOB_TTL_S", "3600"))
165
+ # 120s accommodates CPU-only Ollama setups with mid-size thinking models
166
+ # (e.g. deepseek-r1 / phi4) where a single analysis pass can easily exceed
167
+ # 45s. Think mode chains three such calls, so keeping the per-call timeout
168
+ # tight caused ``[expert chat] TimeoutError β†’ 502`` on local dev boxes even
169
+ # when the model was making progress. Override via env for prod deploys
170
+ # where faster providers enforce a shorter SLA.
171
+ EXPERT_PROVIDER_TIMEOUT_S: float = float(os.getenv("EXPERT_PROVIDER_TIMEOUT_S", "120"))
172
+
173
+
174
+ def available_expert_providers() -> list[str]:
175
+ """Return which expert providers are configured (have API keys or local URL)."""
176
+ providers = []
177
+ # Local is always available if Ollama is reachable (no key needed)
178
+ providers.append("local")
179
+ if GROQ_API_KEY:
180
+ providers.append("groq")
181
+ if GROK_API_KEY:
182
+ providers.append("grok")
183
+ if GEMINI_API_KEY:
184
+ providers.append("gemini")
185
+ # Claude / OpenAI reuse existing HomePilot keys β€” check them
186
+ if os.getenv("ANTHROPIC_API_KEY", "").strip():
187
+ providers.append("claude")
188
+ if os.getenv("OPENAI_API_KEY", "").strip():
189
+ providers.append("openai")
190
+ return providers
expert/backend/app/expert/deep.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deep research β€” a bounded deep-agent run: plan, delegate, gather with tools, write.
2
+
3
+ The harness primitives behind ChatGPT's and Claude's "deep research" modes, kept small and
4
+ local-first, with no extra dependency:
5
+
6
+ * a **plan** the model writes first (a few sub-questions), shown to the user as it runs;
7
+ * one **sub-agent per plan item**, each with a clean context and the per-turn tool loop
8
+ (web search, page fetch, workspace and notes, the project's documents), reporting findings
9
+ with [n] citations;
10
+ * a shared **source book** that renumbers every sub-agent's citations into one list;
11
+ * a **writer** pass that turns findings and sources into a cited report, streamed;
12
+ * a **time budget** and per-item call budget that end the run gracefully;
13
+ * a **durable job**: every event is numbered and written to the state store with the job's
14
+ snapshot, so a client can reconnect after a refresh (``?after=<seq>``) and a finished
15
+ report survives a restart. A job interrupted by a restart is marked so, never left running.
16
+
17
+ Gate: ``capability.py`` β€” offered only when the connection has a high-end model.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import re
25
+ import time
26
+ import uuid
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from .config import (
31
+ EXPERT_DEEP_JOB_TTL_S,
32
+ EXPERT_DEEP_MAX_ITEMS,
33
+ EXPERT_DEEP_ROUNDS_PER_ITEM,
34
+ EXPERT_DEEP_TEMPERATURE,
35
+ EXPERT_DEEP_TIME_BUDGET_S,
36
+ EXPERT_MAX_TOKENS,
37
+ EXPERT_SYSTEM_PROMPT,
38
+ )
39
+ from .policies import is_trivial
40
+ from .providers import mark_background
41
+ from .router import dispatch, step_messages, stream_provider_events
42
+ from .tool_loop import SourceBook, ThinkFilter, capability_key, stream_with_tools, tools_known
43
+ from . import compaction
44
+ from . import grounding
45
+ from . import state_store
46
+ from . import target as target_mod
47
+
48
+ logger = logging.getLogger("expert.deep")
49
+
50
+ PLANNER_SYSTEM = (
51
+ "You are the lead researcher. Break the user's request into the few sub-questions whose "
52
+ "answers, together, settle it. Each must be self-contained and searchable. Return JSON only: "
53
+ '{{"questions": ["...", "..."]}} with 2 to {max_items} items.'
54
+ )
55
+ RESEARCHER_SYSTEM = (
56
+ "You are a research sub-agent. Answer the sub-question with facts found with your tools: "
57
+ "search, read the best pages, and report findings with the [n] numbers of the results you "
58
+ "relied on. Be concrete β€” names, numbers, dates. Say when sources disagree or when nothing "
59
+ "reliable was found. Keep it under 300 words."
60
+ )
61
+ WRITER_SYSTEM = (
62
+ "You are the writer. From the findings and the numbered sources, write the report the user "
63
+ "asked for: a short answer first, then sections with headings, specific facts, and [n] "
64
+ "citations right after the sentences they support. Note gaps and disagreements plainly. "
65
+ "Never invent a source or a number."
66
+ )
67
+
68
+ FINAL_STATES = ("done", "failed", "cancelled")
69
+
70
+
71
+ def _with_operator(system: str, operator_prompt: Optional[str]) -> str:
72
+ """A role prompt plus the operator's context (a project's instructions) when given."""
73
+ if operator_prompt and operator_prompt.strip():
74
+ return f"{system}\n\nContext from the operator:\n{operator_prompt.strip()}"
75
+ return system
76
+
77
+
78
+ @dataclass
79
+ class PlanItem:
80
+ id: str
81
+ title: str
82
+ status: str = "pending" # pending | running | done | skipped
83
+ finding: str = ""
84
+ sources: List[int] = field(default_factory=list)
85
+
86
+ def as_dict(self) -> Dict[str, Any]:
87
+ return {"id": self.id, "title": self.title, "status": self.status, "sources": list(self.sources),
88
+ "summary": self.finding[:300]}
89
+
90
+ @classmethod
91
+ def from_dict(cls, d: Dict[str, Any]) -> "PlanItem":
92
+ return cls(id=str(d.get("id") or ""), title=str(d.get("title") or ""), status=str(d.get("status") or "pending"),
93
+ finding=str(d.get("summary") or d.get("finding") or ""), sources=list(d.get("sources") or []))
94
+
95
+
96
+ @dataclass
97
+ class DeepJob:
98
+ id: str
99
+ query: str
100
+ provider: str
101
+ model: Optional[str]
102
+ session_id: Optional[str] = None
103
+ status: str = "queued" # queued | planning | researching | writing | done | failed | cancelled
104
+ plan: List[PlanItem] = field(default_factory=list)
105
+ report: str = ""
106
+ sources: List[Dict[str, Any]] = field(default_factory=list)
107
+ notices: List[str] = field(default_factory=list)
108
+ error: str = ""
109
+ created: float = field(default_factory=time.time)
110
+ finished: Optional[float] = None
111
+ seq: int = 0
112
+ events: List[Dict[str, Any]] = field(default_factory=list)
113
+ subscribers: List["asyncio.Queue[Dict[str, Any]]"] = field(default_factory=list)
114
+ task: Optional["asyncio.Task[None]"] = None
115
+
116
+ # ── events: numbered, fanned out, written down ──────────────────────────
117
+ def emit(self, event: Dict[str, Any]) -> None:
118
+ self.seq += 1
119
+ event = {**event, "seq": self.seq}
120
+ self.events.append(event)
121
+ state_store.event_append(self.id, self.seq, event)
122
+ if event["type"] != "token":
123
+ self.persist()
124
+ for q in list(self.subscribers):
125
+ q.put_nowait(event)
126
+
127
+ def subscribe(self, after: int = 0) -> "asyncio.Queue[Dict[str, Any]]":
128
+ q: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
129
+ for e in self.events:
130
+ if int(e.get("seq", 0)) > after:
131
+ q.put_nowait(e)
132
+ self.subscribers.append(q)
133
+ return q
134
+
135
+ def unsubscribe(self, q: "asyncio.Queue[Dict[str, Any]]") -> None:
136
+ if q in self.subscribers:
137
+ self.subscribers.remove(q)
138
+
139
+ @property
140
+ def finished_state(self) -> bool:
141
+ return self.status in FINAL_STATES
142
+
143
+ def cancel(self) -> None:
144
+ if self.task and not self.task.done():
145
+ self.task.cancel()
146
+
147
+ def snapshot(self) -> Dict[str, Any]:
148
+ end = self.finished or time.time()
149
+ return {
150
+ "job_id": self.id, "status": self.status, "query": self.query, "model": self.model,
151
+ "provider": self.provider, "session_id": self.session_id,
152
+ "plan": [p.as_dict() for p in self.plan], "report": self.report, "sources": self.sources,
153
+ "notices": list(self.notices), "error": self.error, "elapsed_s": round(end - self.created, 1),
154
+ "seq": self.seq, "created": self.created, "finished": self.finished,
155
+ }
156
+
157
+ def persist(self) -> None:
158
+ state_store.job_save(self.id, self.status, self.snapshot(), created=self.created)
159
+
160
+ @classmethod
161
+ def from_snapshot(cls, snap: Dict[str, Any], events: List[Dict[str, Any]]) -> "DeepJob":
162
+ job = cls(
163
+ id=str(snap.get("job_id") or ""), query=str(snap.get("query") or ""),
164
+ provider=str(snap.get("provider") or "local"), model=snap.get("model"), session_id=snap.get("session_id"),
165
+ status=str(snap.get("status") or "failed"),
166
+ plan=[PlanItem.from_dict(p) for p in snap.get("plan") or []],
167
+ report=str(snap.get("report") or ""), sources=list(snap.get("sources") or []),
168
+ notices=list(snap.get("notices") or []), error=str(snap.get("error") or ""),
169
+ created=float(snap.get("created") or time.time()), finished=snap.get("finished"),
170
+ )
171
+ job.events = list(events)
172
+ job.seq = max([int(e.get("seq", 0)) for e in events] + [int(snap.get("seq") or 0)])
173
+ return job
174
+
175
+
176
+ JOBS: Dict[str, DeepJob] = {}
177
+ _RECOVERED = False
178
+
179
+
180
+ def recover_interrupted() -> int:
181
+ """Jobs the store says were running when the process last stopped: there is no task
182
+ behind them any more, so they are closed as failed, with the reason on record."""
183
+ global _RECOVERED
184
+ if _RECOVERED:
185
+ return 0
186
+ _RECOVERED = True
187
+ count = 0
188
+ for job_id in state_store.jobs_unfinished():
189
+ row = state_store.job_load(job_id)
190
+ if not row:
191
+ continue
192
+ job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0))
193
+ job.status = "failed"
194
+ job.error = "Interrupted: HomePilot restarted while this research was running."
195
+ job.finished = time.time()
196
+ job.seq += 1
197
+ event = {"type": "error", "error": job.error, "seq": job.seq}
198
+ job.events.append(event)
199
+ state_store.event_append(job.id, job.seq, event)
200
+ job.persist()
201
+ count += 1
202
+ if count:
203
+ logger.info("deep research: closed %d job(s) interrupted by a restart", count)
204
+ return count
205
+
206
+
207
+ def get_job(job_id: str) -> Optional[DeepJob]:
208
+ """A live job, or a finished one read back from the store."""
209
+ recover_interrupted()
210
+ job = JOBS.get(job_id)
211
+ if job is not None:
212
+ return job
213
+ row = state_store.job_load(job_id)
214
+ if not row:
215
+ return None
216
+ job = DeepJob.from_snapshot(row["snapshot"], state_store.events_after(job_id, 0))
217
+ JOBS[job_id] = job
218
+ return job
219
+
220
+
221
+ def _prune() -> None:
222
+ now = time.time()
223
+ for key, job in list(JOBS.items()):
224
+ if job.finished and now - job.finished > EXPERT_DEEP_JOB_TTL_S:
225
+ JOBS.pop(key, None)
226
+ state_store.jobs_prune(EXPERT_DEEP_JOB_TTL_S * 24)
227
+
228
+
229
+ def parse_plan(text: str, query: str, max_items: int) -> List[str]:
230
+ """The planner's sub-questions: JSON when it obeyed, a bullet or numbered list when it did
231
+ not, the request itself when nothing usable came back."""
232
+ text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S)
233
+ questions: List[str] = []
234
+ m = re.search(r"\{.*\}", text, flags=re.S)
235
+ if m:
236
+ try:
237
+ data = json.loads(m.group(0))
238
+ qs = data.get("questions") if isinstance(data, dict) else None
239
+ if isinstance(qs, list):
240
+ questions = [str(q).strip() for q in qs if str(q).strip()]
241
+ except json.JSONDecodeError:
242
+ pass
243
+ if not questions:
244
+ for line in text.splitlines():
245
+ if re.match(r"^\s*(?:[-*β€’]|\d+[.)])\s+\S", line):
246
+ questions.append(re.sub(r"^\s*(?:[-*β€’]|\d+[.)])\s*", "", line).strip().strip('"'))
247
+ questions = [q for q in questions if len(q) > 3][:max_items]
248
+ return questions or [query]
249
+
250
+
251
+ def _short(exc: Exception) -> str:
252
+ """One line of an error for a notice."""
253
+ return " ".join(str(exc).split())[:160] or type(exc).__name__
254
+
255
+
256
+ def _findings_report(plan: List["PlanItem"], sources: List[Dict[str, Any]]) -> str:
257
+ """The report when the writer cannot run: each researched question with its finding, and
258
+ the numbered sources the findings cite."""
259
+ blocks = [f"### {p.title}\n\n{p.finding.strip()}" for p in plan if p.finding and p.finding.strip()]
260
+ out = "\n\n".join(blocks)
261
+ if sources:
262
+ out += "\n\n**Sources**\n\n" + "\n".join(f"[{s['n']}] {s.get('title') or s['url']} β€” {s['url']}" for s in sources)
263
+ return out
264
+
265
+
266
+ def renumber(text: str, mapping: Dict[int, int]) -> str:
267
+ """A sub-agent's local [n] citations rewritten to the job-wide numbers."""
268
+ return re.sub(r"\[(\d+)\]", lambda m: f"[{mapping.get(int(m.group(1)), int(m.group(1)))}]", text or "")
269
+
270
+
271
+ def start(
272
+ query: str,
273
+ *,
274
+ provider: str,
275
+ model: Optional[str],
276
+ history: List[Dict[str, Any]],
277
+ chosen: Optional[target_mod.ChatTarget],
278
+ timezone: Optional[str],
279
+ context: Optional[str],
280
+ note: Optional[str],
281
+ notices: Optional[List[str]] = None,
282
+ registry: Any = None,
283
+ operator_prompt: Optional[str] = None,
284
+ session_id: Optional[str] = None,
285
+ ) -> DeepJob:
286
+ """Create the job and run it in the background; the caller returns its id at once."""
287
+ recover_interrupted()
288
+ _prune()
289
+ job = DeepJob(id=uuid.uuid4().hex[:12], query=query, provider=provider, model=model, session_id=session_id)
290
+ job.notices.extend(notices or [])
291
+ JOBS[job.id] = job
292
+ job.persist()
293
+
294
+ async def runner() -> None:
295
+ with target_mod.using(chosen), grounding.using(timezone, context), compaction.using(note):
296
+ await _run(job, list(history or []), registry=registry, operator_prompt=operator_prompt)
297
+
298
+ job.task = asyncio.create_task(runner())
299
+ return job
300
+
301
+
302
+ async def _run(job: DeepJob, history: List[Dict[str, Any]], *, registry: Any = None,
303
+ operator_prompt: Optional[str] = None) -> None:
304
+ started = time.monotonic()
305
+ budget = EXPERT_DEEP_TIME_BUDGET_S
306
+ kw = dict(model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=EXPERT_MAX_TOKENS)
307
+ book = SourceBook()
308
+ # A research job is background work: on a busy endpoint, a person's chat goes first.
309
+ mark_background()
310
+ try:
311
+ if is_trivial(job.query):
312
+ # A greeting needs no plan, no searches and no report: one reply, and the reason.
313
+ # (It also spares a shared endpoint the five to eight calls a research job makes.)
314
+ job.status = "writing"
315
+ job.emit({"type": "status", "status": "writing"})
316
+ job.notices.append("A short message needs no research; it was answered directly.")
317
+ parts: List[str] = []
318
+ thinking = ThinkFilter()
319
+ async for ev in stream_provider_events(
320
+ step_messages(job.query, system_prompt=_with_operator(EXPERT_SYSTEM_PROMPT, operator_prompt), history=history),
321
+ job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE, max_tokens=512,
322
+ ):
323
+ if "delta" in ev:
324
+ clean = thinking.feed(ev["delta"])
325
+ if clean:
326
+ parts.append(clean)
327
+ job.emit({"type": "token", "content": clean})
328
+ tail = thinking.flush()
329
+ if tail:
330
+ parts.append(tail)
331
+ job.emit({"type": "token", "content": tail})
332
+ job.report = "".join(parts)
333
+ job.status = "done"
334
+ job.finished = time.time()
335
+ job.emit({
336
+ "type": "done", "report": job.report, "sources": [], "notices": list(job.notices),
337
+ "plan": [], "elapsed_s": round(time.monotonic() - started, 1),
338
+ })
339
+ return
340
+
341
+ # 1. Plan
342
+ job.status = "planning"
343
+ job.emit({"type": "status", "status": "planning"})
344
+ try:
345
+ raw = await dispatch(
346
+ step_messages(
347
+ f"Request:\n{job.query}\n\nReturn the JSON plan.",
348
+ system_prompt=_with_operator(PLANNER_SYSTEM.format(max_items=EXPERT_DEEP_MAX_ITEMS), operator_prompt),
349
+ history=history,
350
+ ),
351
+ job.provider, model=job.model, temperature=0.2, max_tokens=600,
352
+ )
353
+ plan_text = raw["choices"][0]["message"]["content"] or ""
354
+ except (KeyError, IndexError, TypeError):
355
+ plan_text = ""
356
+ except Exception as exc: # noqa: BLE001 - no plan is not no research
357
+ logger.warning("Deep research %s: planner failed, researching the request as one question: %s", job.id, exc)
358
+ job.notices.append(f"The planner could not run ({_short(exc)}); the request was researched as one question.")
359
+ plan_text = ""
360
+ job.plan = [PlanItem(id=f"q{i + 1}", title=q) for i, q in enumerate(parse_plan(plan_text, job.query, EXPERT_DEEP_MAX_ITEMS))]
361
+ job.emit({"type": "plan", "items": [p.as_dict() for p in job.plan]})
362
+
363
+ # 2. Research, one sub-agent per item, within the time budget. Every item starts with a
364
+ # web search run by HomePilot (the question as the query), so the findings are
365
+ # grounded whether or not the model can call tools itself; a capable model may search
366
+ # further on its own.
367
+ job.status = "researching"
368
+ job.emit({"type": "status", "status": "researching"})
369
+ if tools_known(capability_key(target_mod.current(), job.provider, job.model)) is False:
370
+ job.notices.append("This model cannot call tools; HomePilot ran the web searches for it.")
371
+ out_of_time = False
372
+ for item in job.plan:
373
+ if time.monotonic() - started > budget:
374
+ if not out_of_time:
375
+ out_of_time = True
376
+ job.notices.append(f"Time budget of {budget:g}s reached; the remaining items were skipped.")
377
+ item.status = "skipped"
378
+ job.emit({"type": "item", "id": item.id, "status": "skipped", "title": item.title})
379
+ continue
380
+ item.status = "running"
381
+ job.emit({"type": "item", "id": item.id, "status": "running", "title": item.title})
382
+ messages = step_messages(
383
+ f"Overall request: {job.query}\n\nSub-question: {item.title}",
384
+ system_prompt=_with_operator(RESEARCHER_SYSTEM, operator_prompt), history=history,
385
+ )
386
+ finding, local_sources = "", []
387
+ try:
388
+ async for ev in stream_with_tools(messages, job.provider, max_rounds=EXPERT_DEEP_ROUNDS_PER_ITEM,
389
+ registry=registry, forced=[("hp_web_search", {"query": item.title})], **kw):
390
+ if ev["type"] in ("tool_start", "tool_end", "wait"):
391
+ job.emit({**ev, "item": item.id})
392
+ elif ev["type"] == "notice":
393
+ if ev["text"] not in job.notices:
394
+ job.notices.append(ev["text"])
395
+ job.emit({**ev, "item": item.id})
396
+ elif ev["type"] == "done":
397
+ finding, local_sources = ev.get("content") or "", ev.get("sources") or []
398
+ except Exception as exc: # noqa: BLE001 - one question down is not the report down
399
+ logger.warning("Deep research %s: item %s failed: %s", job.id, item.id, exc)
400
+ item.status = "failed"
401
+ item.finding = ""
402
+ note = f"β€œ{item.title}” could not be researched ({_short(exc)})."
403
+ if note not in job.notices:
404
+ job.notices.append(note)
405
+ job.emit({"type": "item", "id": item.id, "status": "failed", "title": item.title,
406
+ "summary": f"Could not be researched: {_short(exc)}"})
407
+ continue
408
+ mapping: Dict[int, int] = {}
409
+ for s in local_sources:
410
+ book.add([s])
411
+ numbered = book.numbered([s])
412
+ if numbered:
413
+ mapping[int(s.get("n", 0))] = numbered[0]["n"]
414
+ item.finding = renumber(finding, mapping)
415
+ item.sources = sorted(set(mapping.values()))
416
+ item.status = "done"
417
+ job.sources = list(book.items)
418
+ job.emit({"type": "item", "id": item.id, "status": "done", "title": item.title,
419
+ "summary": item.finding[:300], "sources": item.sources})
420
+ if book.items:
421
+ job.emit({"type": "sources", "sources": list(book.items)})
422
+
423
+ # 3. Write
424
+ job.status = "writing"
425
+ job.emit({"type": "status", "status": "writing"})
426
+ findings = "\n\n".join(
427
+ f"## {p.title}\n{p.finding or ('(could not be researched)' if p.status == 'failed' else '(not researched: out of time)')}"
428
+ for p in job.plan
429
+ )
430
+ source_lines = "\n".join(
431
+ f"[{s['n']}] {s.get('title') or s['url']} β€” {s['url']}" for s in book.items
432
+ ) or "(no sources were found; say so and answer from general knowledge, marked as such)"
433
+ writer_messages = step_messages(
434
+ f"Request:\n{job.query}\n\nFindings:\n{findings}\n\nSources:\n{source_lines}\n\nWrite the report.",
435
+ system_prompt=_with_operator(WRITER_SYSTEM, operator_prompt), history=history,
436
+ )
437
+ parts: List[str] = []
438
+ thinking = ThinkFilter()
439
+ try:
440
+ async for ev in stream_provider_events(
441
+ writer_messages, job.provider, model=job.model, temperature=EXPERT_DEEP_TEMPERATURE,
442
+ max_tokens=max(EXPERT_MAX_TOKENS, 3000),
443
+ ):
444
+ if "delta" in ev:
445
+ clean = thinking.feed(ev["delta"])
446
+ if clean:
447
+ parts.append(clean)
448
+ job.emit({"type": "token", "content": clean})
449
+ elif ev.get("notice") and ev["notice"] not in job.notices:
450
+ job.notices.append(ev["notice"])
451
+ except Exception as exc: # noqa: BLE001 - the findings are the report's substance
452
+ researched = [p for p in job.plan if p.finding]
453
+ if not researched and not parts:
454
+ raise
455
+ logger.warning("Deep research %s: writer failed, returning the findings: %s", job.id, exc)
456
+ job.notices.append(f"The writer could not finish ({_short(exc)}); the findings are shown as gathered.")
457
+ fallback = _findings_report(job.plan, book.items)
458
+ text = ("\n\n---\n\n" if parts else "") + fallback
459
+ parts.append(text)
460
+ job.emit({"type": "token", "content": text})
461
+ tail = thinking.flush()
462
+ if tail:
463
+ parts.append(tail)
464
+ job.emit({"type": "token", "content": tail})
465
+ job.report = "".join(parts)
466
+ job.status = "done"
467
+ job.finished = time.time()
468
+ job.emit({
469
+ "type": "done", "report": job.report, "sources": job.sources, "notices": list(job.notices),
470
+ "plan": [p.as_dict() for p in job.plan], "elapsed_s": round(time.monotonic() - started, 1),
471
+ })
472
+ except asyncio.CancelledError:
473
+ job.status = "cancelled"
474
+ job.finished = time.time()
475
+ job.emit({"type": "cancelled"})
476
+ except Exception as exc: # noqa: BLE001
477
+ logger.error("Deep research %s failed: %s", job.id, exc, exc_info=True)
478
+ job.status = "failed"
479
+ job.error = f"{type(exc).__name__}: {exc}"
480
+ job.finished = time.time()
481
+ job.emit({"type": "error", "error": job.error})
expert/backend/app/expert/eval_bench.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List
5
+
6
+
7
+ @dataclass
8
+ class EvalCase:
9
+ query: str
10
+ expected_keywords: List[str]
11
+
12
+
13
+ @dataclass
14
+ class EvalOutcome:
15
+ score: float
16
+ passed: bool
17
+
18
+
19
+ def score_output(text: str, expected_keywords: List[str]) -> float:
20
+ if not expected_keywords:
21
+ return 1.0
22
+ hits = sum(1 for k in expected_keywords if k.lower() in text.lower())
23
+ return hits / len(expected_keywords)
24
+
25
+
26
+ def regression_gate(outcomes: List[EvalOutcome], min_avg: float = 0.75) -> bool:
27
+ if not outcomes:
28
+ return False
29
+ avg = sum(o.score for o in outcomes) / len(outcomes)
30
+ return avg >= min_avg and all(o.passed for o in outcomes)
expert/backend/app/expert/grounding.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What one Expert request is grounded in: today's date, and β€” when the chat searched the web β€”
2
+ the numbered sources HomePilot found (``backend/app/web_search.py``).
3
+
4
+ Expert's three pipelines build their own prompts (fast: ``router.build_messages``; think:
5
+ ``thinking._msg``; heavy: ``heavy._msg``), and think and heavy never see the history. So the
6
+ grounding rides on the system prompt itself, through every step, the way ``target.py`` carries
7
+ the chosen model: a context variable set for the request, applied with :func:`apply`.
8
+
9
+ Without the date a model answers "which day is today?" from its training data; without sources
10
+ it recites old news as current. Both happened in Fast mode before this.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar
17
+ from datetime import datetime
18
+ from typing import Iterator, Optional
19
+
20
+ _CURRENT: ContextVar[str] = ContextVar("expert_grounding", default="")
21
+
22
+ #: The most a request may add to the prompt (a web-results block is ~5-8 kB).
23
+ MAX_CONTEXT_CHARS = 24_000
24
+
25
+
26
+ def date_line(timezone: Optional[str] = None, now: Optional[datetime] = None) -> str:
27
+ """Today's date and time, in the user's zone when the browser sent one."""
28
+ moment = (now or datetime.now()).astimezone()
29
+ zone = ""
30
+ if timezone:
31
+ try:
32
+ from zoneinfo import ZoneInfo
33
+
34
+ moment = moment.astimezone(ZoneInfo(timezone))
35
+ zone = timezone
36
+ except Exception: # noqa: BLE001 β€” an unknown name falls back to the server's zone
37
+ zone = ""
38
+ zone = zone or (moment.tzname() or "")
39
+ stamp = moment.strftime("%A, %B %d, %Y, %H:%M").replace(" 0", " ")
40
+ return (
41
+ f"Current date and time: {stamp}{f' ({zone})' if zone else ''}. Use it for anything that "
42
+ "depends on today's date. Your own knowledge stops at your training cutoff: for news, prices, "
43
+ "scores or anything else that may have changed since, say you cannot check live information "
44
+ "unless web results are provided, and never invent recent events."
45
+ )
46
+
47
+
48
+ @contextmanager
49
+ def using(timezone: Optional[str] = None, context: Optional[str] = None) -> Iterator[str]:
50
+ """Ground every prompt built inside the block in today's date and ``context``."""
51
+ parts = [date_line(timezone)]
52
+ extra = (context or "").strip()
53
+ if extra:
54
+ parts.append(extra[:MAX_CONTEXT_CHARS])
55
+ token = _CURRENT.set("\n\n".join(parts))
56
+ try:
57
+ yield _CURRENT.get()
58
+ finally:
59
+ _CURRENT.reset(token)
60
+
61
+
62
+ def apply(system_prompt: str) -> str:
63
+ """``system_prompt`` with this request's grounding appended (unchanged outside a request)."""
64
+ extra = _CURRENT.get()
65
+ return f"{system_prompt}\n\n{extra}" if extra else system_prompt
expert/backend/app/expert/heavy.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/heavy.py
2
+ # Multi-agent "heavy" pipeline for the most complex queries.
3
+ #
4
+ # Runs 4 specialized agents sequentially, each with a distinct role:
5
+ #
6
+ # Agent 1 β€” RESEARCHER: Deep information gathering + context building
7
+ # Agent 2 β€” REASONER: Logical inference and chain-of-thought over research
8
+ # Agent 3 β€” SYNTHESIZER: Condense into a clear, well-structured answer
9
+ # Agent 4 β€” VALIDATOR: Final sanity check + quality gate
10
+ #
11
+ # Each agent can use a different provider (e.g. Groq for fast research,
12
+ # Grok for deep reasoning) if configured via HEAVY_*_PROVIDER env vars.
13
+ # Falls back to the caller's selected provider for all agents.
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
21
+
22
+ from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT
23
+ from .router import (
24
+ dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName,
25
+ )
26
+ from .tool_loop import ThinkFilter, run_with_tools, stream_with_tools, strip_think
27
+
28
+ logger = logging.getLogger("expert.heavy")
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+ # Per-agent provider overrides (optional β€” falls back to caller's provider)
32
+ # ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ def _agent_provider(agent: str, default: ProviderName) -> ProviderName:
35
+ env_key = f"HEAVY_{agent.upper()}_PROVIDER"
36
+ val = os.getenv(env_key, "").strip()
37
+ return val if val else default # type: ignore[return-value]
38
+
39
+
40
+ # ─────────────────────────────────────────────────────────────────────────────
41
+ # Agent system prompts
42
+ # ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ _RESEARCHER_SYSTEM = (
45
+ "You are a deep research agent. Your role is to gather all relevant "
46
+ "context, background knowledge, related concepts, and potential edge cases "
47
+ "for a given topic. Be exhaustive and precise. Cite what you know."
48
+ )
49
+
50
+ _REASONER_SYSTEM = (
51
+ "You are a logical reasoning agent. Given a research brief, your job is to "
52
+ "reason step-by-step to conclusions. Use deductive and inductive logic. "
53
+ "Identify cause-and-effect relationships. Expose hidden assumptions."
54
+ )
55
+
56
+ _SYNTHESIZER_SYSTEM = (
57
+ "You are a synthesis agent. Given detailed research and reasoning, produce "
58
+ "a clear, well-structured, concise final answer. Use headers if helpful. "
59
+ "Prioritize clarity and actionability. Eliminate redundancy."
60
+ )
61
+
62
+ _VALIDATOR_SYSTEM = (
63
+ "You are a validation agent. Your job is to review a synthesized answer "
64
+ "for accuracy, completeness, and logical consistency. "
65
+ "Reply with JSON only, in this shape: "
66
+ '{"verdict": "validated" | "corrected" | "needs_review", '
67
+ '"issues": [{"severity": "high" | "medium" | "low", "text": "..."}], '
68
+ '"corrected_answer": "the full corrected answer, only when verdict is corrected" | null, '
69
+ '"method": "model review", "limitations": "what you could not verify"}. '
70
+ "Use \"corrected\" only when you can supply the complete corrected answer; use "
71
+ "\"needs_review\" when something important is doubtful but you cannot fix it; "
72
+ "use \"validated\" when the answer holds."
73
+ )
74
+
75
+ VERDICTS = ("validated", "corrected", "needs_review", "reviewed")
76
+
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ # Agent prompts
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ _RESEARCH_PROMPT = "Research this topic comprehensively:\n\n{input}"
82
+
83
+ _REASON_PROMPT = (
84
+ "Using this research brief, reason step-by-step toward an answer:\n\n"
85
+ "Research:\n{research}\n\nOriginal question: {input}"
86
+ )
87
+
88
+ _REASON_DIRECT_PROMPT = (
89
+ "Reason step-by-step toward an answer. Use your tools when a fact, a figure or a current "
90
+ "detail matters.\n\nQuestion: {input}"
91
+ )
92
+
93
+ _SYNTHESIZE_PROMPT = (
94
+ "Synthesize a final answer from this reasoning:\n\n"
95
+ "Question: {input}\n\n"
96
+ "Reasoning:\n{reasoning}"
97
+ )
98
+
99
+ _VALIDATE_PROMPT = (
100
+ "Validate this answer for the given question:\n\n"
101
+ "Question: {input}\n\n"
102
+ "Answer:\n{synthesis}"
103
+ )
104
+
105
+
106
+ def _role(system: str, operator: Optional[str]) -> str:
107
+ """An agent's role prompt, plus the operator's (persona or user) instructions when given."""
108
+ if operator and operator.strip():
109
+ return f"{system}\n\nOperator instructions for this assistant:\n{operator.strip()}"
110
+ return system
111
+
112
+
113
+ _NOTES = {
114
+ "validated": "The validator confirmed the answer.",
115
+ "corrected": "The validator found issues and corrected the answer.",
116
+ "needs_review": "The validator raised doubts it could not resolve; read the answer with care.",
117
+ "reviewed": "The validator reviewed the answer without a clear verdict.",
118
+ }
119
+
120
+
121
+ def _issues(raw: Any) -> List[Dict[str, str]]:
122
+ out: List[Dict[str, str]] = []
123
+ for item in raw if isinstance(raw, list) else []:
124
+ if isinstance(item, str) and item.strip():
125
+ out.append({"severity": "medium", "text": item.strip()})
126
+ elif isinstance(item, dict) and str(item.get("text") or item.get("issue") or "").strip():
127
+ severity = str(item.get("severity") or "medium").lower()
128
+ out.append({"severity": severity if severity in ("high", "medium", "low") else "medium",
129
+ "text": str(item.get("text") or item.get("issue")).strip()})
130
+ return out[:12]
131
+
132
+
133
+ def verdict_detail(validation: str, synthesis: str) -> Dict[str, Any]:
134
+ """The validator's structured result: ``verdict`` (validated / corrected / needs_review /
135
+ reviewed), ``final_answer``, ``note``, ``issues``, ``method``, ``limitations``.
136
+
137
+ JSON first (what the prompt asks for); the older prose shape as a fallback. A correction
138
+ without text, or a "validated" with a high-severity issue, is a disagreement: needs_review."""
139
+ text = (validation or "").strip()
140
+ data: Optional[Dict[str, Any]] = None
141
+ m = re.search(r"\{.*\}", text, flags=re.S)
142
+ if m:
143
+ try:
144
+ parsed = json.loads(m.group(0))
145
+ data = parsed if isinstance(parsed, dict) else None
146
+ except json.JSONDecodeError:
147
+ data = None
148
+ if data is not None:
149
+ verdict = str(data.get("verdict") or "").strip().lower().replace(" ", "_")
150
+ if verdict not in VERDICTS:
151
+ verdict = "reviewed"
152
+ issues = _issues(data.get("issues"))
153
+ corrected = str(data.get("corrected_answer") or "").strip()
154
+ method = str(data.get("method") or "model review").strip()
155
+ limitations = str(data.get("limitations") or "").strip()
156
+ if verdict == "corrected" and not corrected:
157
+ verdict = "needs_review"
158
+ if verdict == "validated" and any(i["severity"] == "high" for i in issues):
159
+ verdict = "needs_review"
160
+ final = corrected if verdict == "corrected" else synthesis
161
+ return {"verdict": verdict, "final_answer": final, "note": _NOTES[verdict], "issues": issues,
162
+ "method": method, "limitations": limitations, "structured": True}
163
+
164
+ upper = text.upper()
165
+ at = upper.find("CORRECTION:")
166
+ if 0 <= at <= 40:
167
+ corrected = text[at + len("CORRECTION:"):].lstrip(" *#:-β€”\n").strip()
168
+ if corrected:
169
+ return {"verdict": "corrected", "final_answer": corrected, "note": _NOTES["corrected"], "issues": [],
170
+ "method": "model review", "limitations": "", "structured": False}
171
+ if "VALIDATED" in upper[:160]:
172
+ return {"verdict": "validated", "final_answer": synthesis, "note": _NOTES["validated"], "issues": [],
173
+ "method": "model review", "limitations": "", "structured": False}
174
+ return {"verdict": "reviewed", "final_answer": synthesis, "note": _NOTES["reviewed"], "issues": [],
175
+ "method": "model review", "limitations": "", "structured": False}
176
+
177
+
178
+ def verdict_of(validation: str, synthesis: str) -> Tuple[str, str, str]:
179
+ """``(verdict, final_answer, note)`` β€” the short form of :func:`verdict_detail`."""
180
+ d = verdict_detail(validation, synthesis)
181
+ return d["verdict"], d["final_answer"], d["note"]
182
+
183
+
184
+ # ─────────────────────────────────────────────────────────────────────────────
185
+ # Sync heavy pipeline
186
+ # ─────────────────────────────────────────────────────────────────────────────
187
+
188
+ async def heavy(
189
+ query: str,
190
+ provider: ProviderName = "auto",
191
+ *,
192
+ history: Optional[List[Dict[str, str]]] = None,
193
+ model: Optional[str] = None,
194
+ temperature: float = EXPERT_TEMPERATURE,
195
+ max_tokens: int = EXPERT_MAX_TOKENS,
196
+ system_prompt: Optional[str] = None,
197
+ forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
198
+ research: bool = True,
199
+ registry: Any = None,
200
+ tool_rounds: Optional[int] = None,
201
+ ) -> Dict[str, Any]:
202
+ """
203
+ Run the heavy pipeline: research (when ``research``; else the reasoner gets the tools) β†’
204
+ reason β†’ synthesize β†’ validate.
205
+ Returns final answer + all intermediate agent outputs, the validator's structured
206
+ verdict, and the tool trace and sources. ``system_prompt``: operator instructions added
207
+ to every agent's role prompt; ``registry``: the request's tool set.
208
+ """
209
+
210
+ # Each agent keeps its own role prompt and gets the recent conversation ahead of its
211
+ # brief (see step_messages).
212
+ def _msg(system: str, user: str) -> List[Dict[str, str]]:
213
+ return step_messages(user, system_prompt=_role(system, system_prompt), history=history)
214
+
215
+ kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
216
+ # A model that cannot call tools still gets grounded: HomePilot searches the question for it.
217
+ assist = [("hp_web_search", {"query": query})]
218
+ loop_kw = dict(call=dispatch, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist)
219
+ notices: List[str] = []
220
+ fallback_applied = False
221
+ research_text = ""
222
+ tooled: Dict[str, Any] = {}
223
+
224
+ researched = research
225
+ if research:
226
+ # Agent 1: Research β€” with the tool loop (web search, workspace, notes)
227
+ logger.debug("Heavy agent 1: researcher")
228
+ p_research = _agent_provider("researcher", provider)
229
+ try:
230
+ tooled = await run_with_tools(
231
+ _msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw
232
+ )
233
+ except Exception as exc: # noqa: BLE001 - one agent down is not the turn down
234
+ researched = False
235
+ logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc)
236
+ notices.append(_agent_down("research agent", exc))
237
+ else:
238
+ research_text = tooled.get("content") or ""
239
+ notices.extend(tooled.get("notices", []))
240
+ fallback_applied = bool(tooled.get("fallback_applied"))
241
+
242
+ # Agent 2: Reason (with the tools itself when no research agent ran)
243
+ logger.debug("Heavy agent 2: reasoner")
244
+ p_reason = _agent_provider("reasoner", provider)
245
+ if researched:
246
+ raw_reason = await dispatch(
247
+ _msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)),
248
+ p_reason, **kw
249
+ )
250
+ reasoning = _extract(raw_reason)
251
+ notices.extend(_collect_notice(raw_reason))
252
+ fallback_applied = fallback_applied or _fallback(raw_reason)
253
+ else:
254
+ tooled = await run_with_tools(
255
+ _msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw
256
+ )
257
+ reasoning = tooled.get("content") or ""
258
+ notices.extend(tooled.get("notices", []))
259
+ fallback_applied = fallback_applied or bool(tooled.get("fallback_applied"))
260
+
261
+ # Agent 3: Synthesize
262
+ logger.debug("Heavy agent 3: synthesizer")
263
+ p_synth = _agent_provider("synthesizer", provider)
264
+ raw_synth = await dispatch(
265
+ _msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)),
266
+ p_synth, **kw
267
+ )
268
+ synthesis = _extract(raw_synth)
269
+ notices.extend(_collect_notice(raw_synth))
270
+ fallback_applied = fallback_applied or _fallback(raw_synth)
271
+
272
+ # Agent 4: Validate
273
+ logger.debug("Heavy agent 4: validator")
274
+ p_valid = _agent_provider("validator", provider)
275
+ validation_skipped = ""
276
+ try:
277
+ raw_valid = await dispatch(
278
+ _msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)),
279
+ p_valid, **kw
280
+ )
281
+ except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal
282
+ raw_valid, validation = {}, ""
283
+ validation_skipped = _agent_down("validator", exc)
284
+ logger.warning("Heavy validator failed; answer returned unverified: %s", exc)
285
+ notices.append(validation_skipped)
286
+ else:
287
+ validation = _extract(raw_valid)
288
+ notices.extend(_collect_notice(raw_valid))
289
+ fallback_applied = fallback_applied or _fallback(raw_valid)
290
+
291
+ synthesis, validation = strip_think(synthesis), strip_think(validation)
292
+ detail = verdict_detail(validation, synthesis)
293
+ if validation_skipped:
294
+ detail["note"] = "Not validated: the validator could not run. Read the answer with care."
295
+ detail["limitations"] = validation_skipped
296
+
297
+ return {
298
+ "final_answer": detail["final_answer"],
299
+ "agents": {
300
+ **({"research": research_text} if research else {}),
301
+ "reasoning": reasoning,
302
+ "synthesis": synthesis,
303
+ "validation": validation,
304
+ },
305
+ "verdict": detail["verdict"],
306
+ "verdict_note": detail["note"],
307
+ "verdict_detail": {k: detail[k] for k in ("verdict", "note", "issues", "method", "limitations", "structured")},
308
+ "researched": research,
309
+ "provider": raw_valid.get("provider", provider),
310
+ "pipeline": "heavy",
311
+ "fallback_applied": fallback_applied,
312
+ "notices": _dedupe_notices(notices),
313
+ "tool_trace": tooled.get("tool_trace", []),
314
+ "sources": tooled.get("sources", []),
315
+ }
316
+
317
+
318
+ # ─────────────────────────────────────────────────────────────────────────────
319
+ # Streaming heavy pipeline
320
+ # ─────────────────────────────────────────────────────────────────────────────
321
+
322
+ async def stream_heavy(
323
+ query: str,
324
+ provider: ProviderName = "auto",
325
+ *,
326
+ history: Optional[List[Dict[str, str]]] = None,
327
+ model: Optional[str] = None,
328
+ temperature: float = EXPERT_TEMPERATURE,
329
+ max_tokens: int = EXPERT_MAX_TOKENS,
330
+ system_prompt: Optional[str] = None,
331
+ forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
332
+ research: bool = True,
333
+ registry: Any = None,
334
+ tool_rounds: Optional[int] = None,
335
+ ) -> AsyncIterator[Dict[str, Any]]:
336
+ """
337
+ Stream the heavy pipeline agent by agent (the research agent only when ``research``;
338
+ otherwise the reasoner has the tools).
339
+ Each agent's output is streamed live; the UI renders each in a collapsible panel.
340
+ Besides step_start/token/step_end the stream carries the tool events, then ``verdict``
341
+ (the validator's structured call), ``final`` (the answer after validation), ``done``.
342
+ """
343
+
344
+ def _msg(system: str, user: str) -> List[Dict[str, str]]:
345
+ return step_messages(user, system_prompt=_role(system, system_prompt), history=history)
346
+
347
+ kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
348
+ assist = [("hp_web_search", {"query": query})]
349
+ loop_kw = dict(stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, assist=assist)
350
+ research_text = ""
351
+
352
+ researched = research
353
+ if research:
354
+ # ── Agent 1: Research β€” with the tool loop ───────────────────────────
355
+ p_research = _agent_provider("researcher", provider)
356
+ yield {"type": "step_start", "step": "research", "label": "πŸ”¬ Researching deeply…",
357
+ "provider": p_research, "role": "work"}
358
+ try:
359
+ async for ev in stream_with_tools(
360
+ _msg(_RESEARCHER_SYSTEM, _RESEARCH_PROMPT.format(input=query)), p_research, **loop_kw, **kw
361
+ ):
362
+ if ev["type"] == "token":
363
+ yield {"type": "token", "step": "research", "content": ev["content"]}
364
+ elif ev["type"] == "done":
365
+ research_text = ev.get("content") or ""
366
+ else:
367
+ yield {**ev, "step": "research"}
368
+ except Exception as exc: # noqa: BLE001 - one agent down is not the turn down
369
+ researched = False
370
+ logger.warning("Heavy research agent failed; reasoning directly instead: %s", exc)
371
+ yield {"type": "notice", "text": _agent_down("research agent", exc)}
372
+ yield {"type": "step_end", "step": "research"}
373
+
374
+ # ── Agent 2: Reason (with the tools itself when no research agent ran) ───
375
+ p_reason = _agent_provider("reasoner", provider)
376
+ yield {"type": "step_start", "step": "reasoning", "label": "🧠 Reasoning step-by-step…",
377
+ "provider": p_reason, "role": "work"}
378
+ reason_chunks: List[str] = []
379
+ if researched:
380
+ async for chunk in dispatch_stream(
381
+ _msg(_REASONER_SYSTEM, _REASON_PROMPT.format(research=research_text, input=query)), p_reason, **kw
382
+ ):
383
+ reason_chunks.append(chunk)
384
+ yield {"type": "token", "step": "reasoning", "content": chunk}
385
+ else:
386
+ async for ev in stream_with_tools(
387
+ _msg(_REASONER_SYSTEM, _REASON_DIRECT_PROMPT.format(input=query)), p_reason, **loop_kw, **kw
388
+ ):
389
+ if ev["type"] == "token":
390
+ reason_chunks.append(ev["content"])
391
+ yield {"type": "token", "step": "reasoning", "content": ev["content"]}
392
+ elif ev["type"] == "done":
393
+ reason_chunks = [ev.get("content") or ""]
394
+ else:
395
+ yield {**ev, "step": "reasoning"}
396
+ reasoning = "".join(reason_chunks)
397
+ yield {"type": "step_end", "step": "reasoning"}
398
+
399
+ # ── Agent 3: Synthesize ──────────────────────────────────────────────────
400
+ p_synth = _agent_provider("synthesizer", provider)
401
+ yield {"type": "step_start", "step": "synthesis", "label": "✍️ Synthesizing answer…",
402
+ "provider": p_synth, "role": "answer"}
403
+ synth_chunks: List[str] = []
404
+ thinking = ThinkFilter() # the synthesis is the reply: no reasoning tags in it
405
+ try:
406
+ async for chunk in dispatch_stream(
407
+ _msg(_SYNTHESIZER_SYSTEM, _SYNTHESIZE_PROMPT.format(input=query, reasoning=reasoning)),
408
+ p_synth, **kw
409
+ ):
410
+ clean = thinking.feed(chunk)
411
+ if clean:
412
+ synth_chunks.append(clean)
413
+ yield {"type": "token", "step": "synthesis", "content": clean}
414
+ except Exception as exc: # noqa: BLE001 - the reasoning already answers the question
415
+ if not reasoning.strip() or synth_chunks:
416
+ raise
417
+ logger.warning("Heavy synthesizer failed; answering from the reasoning: %s", exc)
418
+ yield {"type": "notice", "text": _agent_down("synthesizer", exc)}
419
+ fallback = strip_think(reasoning)
420
+ synth_chunks.append(fallback)
421
+ yield {"type": "token", "step": "synthesis", "content": fallback}
422
+ tail = thinking.flush()
423
+ if tail:
424
+ synth_chunks.append(tail)
425
+ yield {"type": "token", "step": "synthesis", "content": tail}
426
+ synthesis = "".join(synth_chunks)
427
+ yield {"type": "step_end", "step": "synthesis"}
428
+
429
+ # ── Agent 4: Validate ────────────────────────────────────────────────────
430
+ p_valid = _agent_provider("validator", provider)
431
+ yield {"type": "step_start", "step": "validation", "label": "βœ… Validating answer…",
432
+ "provider": p_valid, "role": "work"}
433
+ validation_chunks: List[str] = []
434
+ validation_skipped = ""
435
+ try:
436
+ async for chunk in dispatch_stream(
437
+ _msg(_VALIDATOR_SYSTEM, _VALIDATE_PROMPT.format(input=query, synthesis=synthesis)),
438
+ p_valid, **kw
439
+ ):
440
+ validation_chunks.append(chunk)
441
+ yield {"type": "token", "step": "validation", "content": chunk}
442
+ except Exception as exc: # noqa: BLE001 - the answer exists; a missing check is said, not fatal
443
+ validation_skipped = _agent_down("validator", exc)
444
+ logger.warning("Heavy validator failed; answer returned unverified: %s", exc)
445
+ yield {"type": "notice", "text": validation_skipped}
446
+ yield {"type": "step_end", "step": "validation"}
447
+
448
+ # The verdict decides what the reply is: the synthesis, or the validator's correction.
449
+ detail = verdict_detail(strip_think("".join(validation_chunks)), synthesis)
450
+ if validation_skipped:
451
+ detail["note"] = "Not validated: the validator could not run. Read the answer with care."
452
+ detail["limitations"] = validation_skipped
453
+ yield {"type": "verdict", "verdict": detail["verdict"], "note": detail["note"],
454
+ "detail": {k: detail[k] for k in ("issues", "method", "limitations", "structured")}}
455
+ yield {"type": "final", "content": detail["final_answer"]}
456
+ yield {"type": "done"}
457
+
458
+
459
+ # ─────────────────────────────────────────────────────────────────────────────
460
+ # Helper
461
+ # ─────────────────────────────────────────────────────────────────────────────
462
+
463
+ def _extract(raw: Dict[str, Any]) -> str:
464
+ try:
465
+ return raw["choices"][0]["message"]["content"] or ""
466
+ except (KeyError, IndexError):
467
+ return ""
468
+
469
+
470
+ def _agent_down(agent: str, exc: Exception) -> str:
471
+ """The notice for an agent that could not run (a provider rate limit past its retries, an
472
+ outage): the turn goes on without it, and the reader is told what is missing."""
473
+ reason = " ".join(str(exc).split())[:160] or type(exc).__name__
474
+ return f"Heavy's {agent} could not run ({reason}); the answer was produced without it."
475
+
476
+
477
+ def _fallback(raw: Dict[str, Any]) -> bool:
478
+ return bool(extract_dispatch_meta(raw).get("fallback_applied"))
479
+
480
+
481
+ def _collect_notice(raw: Dict[str, Any]) -> List[str]:
482
+ notice = extract_dispatch_meta(raw).get("notice")
483
+ return [notice] if isinstance(notice, str) and notice else []
484
+
485
+
486
+ def _dedupe_notices(notices: List[str]) -> List[str]:
487
+ return list(dict.fromkeys(notices))
expert/backend/app/expert/mcp_catalog.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Dict, List
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class MCPServerSpec:
9
+ key: str
10
+ priority: str
11
+ purpose: str
12
+ required_apis: List[str]
13
+
14
+
15
+ ESSENTIAL_MCP_SERVERS: List[MCPServerSpec] = [
16
+ MCPServerSpec(
17
+ key="mcp-web-search",
18
+ priority="P0",
19
+ purpose="Grounded web retrieval and citation candidates.",
20
+ required_apis=["search(query, top_k, recency_days)", "fetch(url)", "extract(content)"],
21
+ ),
22
+ MCPServerSpec(
23
+ key="mcp-doc-retrieval",
24
+ priority="P0",
25
+ purpose="Private knowledge retrieval and RAG.",
26
+ required_apis=["index(document_id, text, metadata)", "query(text, top_k, filters)", "delete(document_id)"],
27
+ ),
28
+ MCPServerSpec(
29
+ key="mcp-code-sandbox",
30
+ priority="P0",
31
+ purpose="Safe code execution and deterministic computation.",
32
+ required_apis=["run(language, code, timeout_s, memory_mb)", "status(run_id)", "result(run_id)"],
33
+ ),
34
+ MCPServerSpec(
35
+ key="mcp-citation-provenance",
36
+ priority="P0",
37
+ purpose="Claim to source lineage and citation integrity checks.",
38
+ required_apis=["attach_claim(claim_text, source_refs)", "verify_citations(response_text)", "get_lineage(response_id)"],
39
+ ),
40
+ MCPServerSpec(
41
+ key="mcp-memory-store",
42
+ priority="P0",
43
+ purpose="Durable memory service with privacy controls.",
44
+ required_apis=["append(session_id, role, content)", "recall(session_id, limit)", "profile_upsert(user_id, key, value)", "forget(scope_id)"],
45
+ ),
46
+ MCPServerSpec(
47
+ key="mcp-safety-policy",
48
+ priority="P1",
49
+ purpose="Input and output policy checks plus risk scoring.",
50
+ required_apis=["check_input(text, profile)", "check_output(text, profile)", "risk_score(context)"],
51
+ ),
52
+ MCPServerSpec(
53
+ key="mcp-eval-runner",
54
+ priority="P1",
55
+ purpose="Automated quality, safety, and cost evaluation suites.",
56
+ required_apis=["run_suite(suite_id, model_profile)", "report(run_id)", "regression_gate(run_id, baseline_id)"],
57
+ ),
58
+ MCPServerSpec(
59
+ key="mcp-observability",
60
+ priority="P1",
61
+ purpose="Central export for metrics, traces, and event logs.",
62
+ required_apis=["emit_metric(name, value, tags)", "emit_trace(trace_id, spans)", "emit_event(event_type, payload)"],
63
+ ),
64
+ MCPServerSpec(
65
+ key="mcp-cost-router",
66
+ priority="P2",
67
+ purpose="Budget-aware route recommendations and cost recording.",
68
+ required_apis=["recommend_route(query_meta, budget_state)", "record_cost(provider, tokens, latency_ms, success)", "monthly_budget_status(org_id)"],
69
+ ),
70
+ MCPServerSpec(
71
+ key="mcp-job-orchestrator",
72
+ priority="P2",
73
+ purpose="Long-running simulation and research job queue orchestration.",
74
+ required_apis=["submit(job_type, payload, priority)", "status(job_id)", "cancel(job_id)", "result(job_id)"],
75
+ ),
76
+ ]
77
+
78
+
79
+ TOOL_MAPPING: Dict[str, str] = {
80
+ "web_search": "mcp-web-search",
81
+ "retrieval": "mcp-doc-retrieval",
82
+ "code_exec": "mcp-code-sandbox",
83
+ "model_compare": "mcp-eval-runner",
84
+ }
85
+
86
+ # MCP Context Forge tool identifiers used by the Expert gateway.
87
+ # Default pattern keeps names predictable and can be overridden by env.
88
+ FORGE_TOOL_MAPPING: Dict[str, str] = {
89
+ "mcp-web-search": "mcp_web_search",
90
+ "mcp-doc-retrieval": "mcp_doc_retrieval",
91
+ "mcp-code-sandbox": "mcp_code_sandbox",
92
+ "mcp-citation-provenance": "mcp_citation_provenance",
93
+ "mcp-memory-store": "mcp_memory_store",
94
+ "mcp-safety-policy": "mcp_safety_policy",
95
+ "mcp-eval-runner": "mcp_eval_runner",
96
+ "mcp-observability": "mcp_observability",
97
+ "mcp-cost-router": "mcp_cost_router",
98
+ "mcp-job-orchestrator": "mcp_job_orchestrator",
99
+ }
100
+
101
+
102
+ def mcp_key_to_env(key: str) -> str:
103
+ normalized = key.upper().replace("-", "_")
104
+ return f"EXPERT_{normalized}_URL"
105
+
106
+
107
+ def p0_required_server_keys() -> List[str]:
108
+ return [s.key for s in ESSENTIAL_MCP_SERVERS if s.priority == "P0"]
109
+
110
+
111
+ def mcp_key_to_forge_tool_env(key: str) -> str:
112
+ normalized = key.upper().replace("-", "_")
113
+ return f"EXPERT_{normalized}_FORGE_TOOL_ID"
expert/backend/app/expert/mcp_gateway.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
+
7
+ import httpx
8
+ try:
9
+ from app.agentic.client import ContextForgeClient # type: ignore
10
+ except Exception: # pragma: no cover - optional dependency in isolated expert test env
11
+ ContextForgeClient = None # type: ignore
12
+
13
+ from .mcp_catalog import ESSENTIAL_MCP_SERVERS, FORGE_TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env
14
+
15
+
16
+ @dataclass
17
+ class MCPServerStatus:
18
+ key: str
19
+ priority: str
20
+ configured: bool
21
+ endpoint: str
22
+ forge_tool_id: str
23
+
24
+
25
+ class MCPGateway:
26
+ """Thin HTTP gateway for Expert MCP servers.
27
+
28
+ The gateway is intentionally generic and additive so servers can be swapped
29
+ without changing Expert routes/orchestration contracts.
30
+ """
31
+
32
+ def __init__(self, timeout_s: float = 15.0, env: Optional[Dict[str, str]] = None) -> None:
33
+ self.timeout_s = timeout_s
34
+ self.env = env or dict(os.environ)
35
+ self.orchestrator = (self.env.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower()
36
+ self.forge_base_url = (self.env.get("CONTEXT_FORGE_URL", "http://localhost:4444") or "").strip().rstrip("/")
37
+ self.forge_auth_user = (self.env.get("CONTEXT_FORGE_AUTH_USER", "admin") or "admin").strip()
38
+ self.forge_auth_pass = (self.env.get("CONTEXT_FORGE_AUTH_PASS", "changeme") or "changeme").strip()
39
+ self.forge_token = (self.env.get("CONTEXT_FORGE_TOKEN", "") or "").strip()
40
+ self._endpoint_map = self._build_endpoint_map()
41
+ self._forge_tool_map = self._build_forge_tool_map()
42
+
43
+ def _build_endpoint_map(self) -> Dict[str, str]:
44
+ mapping: Dict[str, str] = {}
45
+ for spec in ESSENTIAL_MCP_SERVERS:
46
+ endpoint = (self.env.get(mcp_key_to_env(spec.key), "") or "").strip()
47
+ if endpoint:
48
+ mapping[spec.key] = endpoint.rstrip("/")
49
+ return mapping
50
+
51
+ def endpoint_for(self, key: str) -> str:
52
+ return self._endpoint_map.get(key, "")
53
+
54
+ def forge_tool_for(self, key: str) -> str:
55
+ return self._forge_tool_map.get(key, "")
56
+
57
+ def _build_forge_tool_map(self) -> Dict[str, str]:
58
+ mapping: Dict[str, str] = {}
59
+ for spec in ESSENTIAL_MCP_SERVERS:
60
+ env_key = mcp_key_to_forge_tool_env(spec.key)
61
+ tool_id = (self.env.get(env_key, "") or "").strip() or FORGE_TOOL_MAPPING.get(spec.key, "")
62
+ if tool_id:
63
+ mapping[spec.key] = tool_id
64
+ return mapping
65
+
66
+ def status(self) -> list[MCPServerStatus]:
67
+ status: list[MCPServerStatus] = []
68
+ for spec in ESSENTIAL_MCP_SERVERS:
69
+ endpoint = self.endpoint_for(spec.key)
70
+ status.append(
71
+ MCPServerStatus(
72
+ key=spec.key,
73
+ priority=spec.priority,
74
+ configured=bool(endpoint or self.forge_tool_for(spec.key)),
75
+ endpoint=endpoint,
76
+ forge_tool_id=self.forge_tool_for(spec.key),
77
+ )
78
+ )
79
+ return status
80
+
81
+ def _forge_client(self) -> ContextForgeClient:
82
+ if ContextForgeClient is None:
83
+ raise RuntimeError("ContextForgeClient is unavailable; ensure backend/app is on PYTHONPATH")
84
+ return ContextForgeClient(
85
+ base_url=self.forge_base_url,
86
+ token=self.forge_token,
87
+ auth_user=self.forge_auth_user,
88
+ auth_pass=self.forge_auth_pass,
89
+ )
90
+
91
+ async def _invoke_via_forge(self, server_key: str, payload: Dict[str, Any]) -> Dict[str, Any]:
92
+ if not self.forge_base_url:
93
+ return {
94
+ "ok": False,
95
+ "error": "context_forge_not_configured",
96
+ "server": server_key,
97
+ }
98
+ tool_id = self.forge_tool_for(server_key)
99
+ if not tool_id:
100
+ return {
101
+ "ok": False,
102
+ "error": f"forge_tool_not_configured:{server_key}",
103
+ "server": server_key,
104
+ }
105
+ try:
106
+ client = self._forge_client()
107
+ except Exception as exc:
108
+ return {
109
+ "ok": False,
110
+ "error": str(exc),
111
+ "server": server_key,
112
+ "tool_id": tool_id,
113
+ "orchestrator": "context_forge",
114
+ }
115
+
116
+ result = await client.invoke_tool(tool_id=tool_id, args=payload, timeout=self.timeout_s)
117
+ if isinstance(result, dict) and result.get("error"):
118
+ return {
119
+ "ok": False,
120
+ "error": str(result["error"]),
121
+ "server": server_key,
122
+ "tool_id": tool_id,
123
+ "orchestrator": "context_forge",
124
+ }
125
+ return {
126
+ "ok": True,
127
+ "server": server_key,
128
+ "tool_id": tool_id,
129
+ "orchestrator": "context_forge",
130
+ "data": result,
131
+ }
132
+
133
+ async def invoke(self, server_key: str, method: str, payload: Dict[str, Any]) -> Dict[str, Any]:
134
+ if self.orchestrator == "context_forge":
135
+ return await self._invoke_via_forge(server_key=server_key, payload={"method": method, "payload": payload})
136
+
137
+ endpoint = self.endpoint_for(server_key)
138
+ if not endpoint:
139
+ return {
140
+ "ok": False,
141
+ "error": f"server_not_configured:{server_key}",
142
+ "server": server_key,
143
+ "method": method,
144
+ }
145
+
146
+ url = f"{endpoint.rstrip('/')}/{method.lstrip('/')}"
147
+ try:
148
+ async with httpx.AsyncClient(timeout=self.timeout_s) as client:
149
+ resp = await client.post(url, json=payload)
150
+ resp.raise_for_status()
151
+ data = resp.json() if resp.text else {}
152
+ return {"ok": True, "server": server_key, "method": method, "data": data, "orchestrator": "direct"}
153
+ except Exception as exc:
154
+ return {
155
+ "ok": False,
156
+ "error": f"{type(exc).__name__}: {exc}",
157
+ "server": server_key,
158
+ "method": method,
159
+ "url": url,
160
+ "orchestrator": "direct",
161
+ }
162
+
163
+ # P0 typed wrappers (preprod essentials)
164
+ async def web_search_search(self, query: str, top_k: int = 5, recency_days: int = 7) -> Dict[str, Any]:
165
+ return await self.invoke(
166
+ "mcp-web-search",
167
+ "search",
168
+ {"query": query, "top_k": top_k, "recency_days": recency_days},
169
+ )
170
+
171
+ async def doc_retrieval_query(self, text: str, top_k: int = 5, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
172
+ return await self.invoke(
173
+ "mcp-doc-retrieval",
174
+ "query",
175
+ {"text": text, "top_k": top_k, "filters": filters or {}},
176
+ )
177
+
178
+ async def code_sandbox_run(self, language: str, code: str, timeout_s: int = 15, memory_mb: int = 256) -> Dict[str, Any]:
179
+ return await self.invoke(
180
+ "mcp-code-sandbox",
181
+ "run",
182
+ {"language": language, "code": code, "timeout_s": timeout_s, "memory_mb": memory_mb},
183
+ )
184
+
185
+ async def citation_verify(self, response_text: str) -> Dict[str, Any]:
186
+ return await self.invoke(
187
+ "mcp-citation-provenance",
188
+ "verify_citations",
189
+ {"response_text": response_text},
190
+ )
191
+
192
+ async def memory_append(self, session_id: str, role: str, content: str) -> Dict[str, Any]:
193
+ return await self.invoke(
194
+ "mcp-memory-store",
195
+ "append",
196
+ {"session_id": session_id, "role": role, "content": content},
197
+ )
expert/backend/app/expert/mcp_tools.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import httpx
7
+
8
+
9
+ @dataclass
10
+ class ToolResult:
11
+ tool: str
12
+ ok: bool
13
+ content: str
14
+ meta: Dict[str, Any]
15
+
16
+
17
+ @dataclass
18
+ class ToolCall:
19
+ tool: str
20
+ input: str
21
+
22
+
23
+ class MCPToolClient:
24
+ """Production-oriented MCP tool runner with explicit call budget."""
25
+
26
+ def __init__(self, endpoints: Dict[str, str], timeout_s: float = 15.0) -> None:
27
+ self.endpoints = endpoints
28
+ self.timeout_s = timeout_s
29
+
30
+ async def run_many(self, calls: List[ToolCall], budget: int = 3) -> List[ToolResult]:
31
+ out: List[ToolResult] = []
32
+ remaining = budget
33
+ for call in calls:
34
+ if remaining <= 0:
35
+ out.append(ToolResult(call.tool, False, "Skipped (budget exhausted)", {"budget": budget}))
36
+ continue
37
+
38
+ endpoint = self.endpoints.get(call.tool)
39
+ if not endpoint:
40
+ out.append(ToolResult(call.tool, False, "No MCP endpoint configured", {}))
41
+ continue
42
+
43
+ remaining -= 1
44
+ out.append(await self._invoke(call.tool, endpoint, call.input))
45
+ return out
46
+
47
+ async def _invoke(self, tool: str, endpoint: str, payload: str) -> ToolResult:
48
+ try:
49
+ async with httpx.AsyncClient(timeout=self.timeout_s) as client:
50
+ resp = await client.post(endpoint, json={"input": payload})
51
+ resp.raise_for_status()
52
+ data = resp.json() if resp.text else {}
53
+ content = str(data.get("content", data))
54
+ return ToolResult(tool, True, content, {"endpoint": endpoint})
55
+ except Exception as exc:
56
+ return ToolResult(tool, False, f"{type(exc).__name__}: {exc}", {"endpoint": endpoint})
expert/backend/app/expert/observability.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List
5
+
6
+
7
+ @dataclass
8
+ class MetricEvent:
9
+ ok: bool
10
+ latency_ms: int
11
+
12
+
13
+ class SLOMonitor:
14
+ def __init__(self) -> None:
15
+ self._events: List[MetricEvent] = []
16
+
17
+ def record(self, ok: bool, latency_ms: int) -> None:
18
+ self._events.append(MetricEvent(ok=ok, latency_ms=latency_ms))
19
+ if len(self._events) > 5000:
20
+ self._events = self._events[-5000:]
21
+
22
+ def snapshot(self) -> dict:
23
+ if not self._events:
24
+ return {"error_rate": 0.0, "p95_ms": 0, "count": 0}
25
+
26
+ errs = sum(1 for e in self._events if not e.ok)
27
+ lats = sorted(e.latency_ms for e in self._events)
28
+ idx = min(len(lats) - 1, int(len(lats) * 0.95))
29
+ return {
30
+ "error_rate": errs / len(self._events),
31
+ "p95_ms": lats[idx],
32
+ "count": len(self._events),
33
+ }
expert/backend/app/expert/persistent_memory.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+
11
+ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
12
+ _PHONE_RE = re.compile(r"\+?[0-9][0-9\- ]{7,}[0-9]")
13
+
14
+
15
+ @dataclass
16
+ class MemoryRow:
17
+ role: str
18
+ content: str
19
+ ts: int
20
+
21
+
22
+ class SqliteMemoryStore:
23
+ def __init__(self, db_path: str) -> None:
24
+ self.db_path = db_path
25
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
26
+ self._init_db()
27
+
28
+ def _conn(self):
29
+ return sqlite3.connect(self.db_path)
30
+
31
+ def _init_db(self) -> None:
32
+ with self._conn() as c:
33
+ c.execute(
34
+ """
35
+ CREATE TABLE IF NOT EXISTS expert_memory(
36
+ session_id TEXT NOT NULL,
37
+ role TEXT NOT NULL,
38
+ content TEXT NOT NULL,
39
+ ts INTEGER NOT NULL
40
+ )
41
+ """
42
+ )
43
+ c.execute("CREATE INDEX IF NOT EXISTS idx_expert_memory_session_ts ON expert_memory(session_id, ts)")
44
+
45
+ def append(self, session_id: str, role: str, content: str) -> None:
46
+ safe = redact_pii(content)
47
+ with self._conn() as c:
48
+ c.execute(
49
+ "INSERT INTO expert_memory(session_id, role, content, ts) VALUES (?, ?, ?, ?)",
50
+ (session_id, role, safe, int(time.time())),
51
+ )
52
+
53
+ def recall(self, session_id: str, limit: int = 8) -> List[MemoryRow]:
54
+ with self._conn() as c:
55
+ rows = c.execute(
56
+ "SELECT role, content, ts FROM expert_memory WHERE session_id = ? ORDER BY ts DESC LIMIT ?",
57
+ (session_id, limit),
58
+ ).fetchall()
59
+ return [MemoryRow(role=r[0], content=r[1], ts=r[2]) for r in reversed(rows)]
60
+
61
+
62
+ def redact_pii(text: str) -> str:
63
+ text = _EMAIL_RE.sub("[REDACTED_EMAIL]", text)
64
+ text = _PHONE_RE.sub("[REDACTED_PHONE]", text)
65
+ return text
expert/backend/app/expert/policies.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import asdict, dataclass
5
+ from typing import Literal
6
+
7
+ from .config import (
8
+ EXPERT_DEEP_TEMPERATURE,
9
+ EXPERT_FAST_MAX_TOKENS,
10
+ EXPERT_FAST_TEMPERATURE,
11
+ EXPERT_FAST_TOOL_ROUNDS,
12
+ EXPERT_HEAVY_MAX_TOKENS,
13
+ EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY,
14
+ EXPERT_HEAVY_TEMPERATURE,
15
+ EXPERT_HEAVY_TOOL_ROUNDS,
16
+ EXPERT_THINK_MAX_TOKENS,
17
+ EXPERT_THINK_PLAN_MIN_COMPLEXITY,
18
+ EXPERT_THINK_TEMPERATURE,
19
+ EXPERT_THINK_TOOL_ROUNDS,
20
+ )
21
+
22
+
23
+ ThinkingMode = Literal["auto", "fast", "think", "heavy"]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ModeResolution:
28
+ mode_used: ThinkingMode
29
+ strategy: Literal["single-pass", "expert-thinking", "heavy-multi-pass"]
30
+
31
+
32
+ def resolve_thinking_mode(mode: ThinkingMode, complexity: int) -> ModeResolution:
33
+ """Deterministically resolve auto/explicit mode into concrete pipeline strategy."""
34
+ if mode == "auto":
35
+ if complexity >= 8:
36
+ mode = "heavy"
37
+ elif complexity >= 5:
38
+ mode = "think"
39
+ else:
40
+ mode = "fast"
41
+
42
+ strategy = {
43
+ "fast": "single-pass",
44
+ "think": "expert-thinking",
45
+ "heavy": "heavy-multi-pass",
46
+ }[mode]
47
+
48
+ return ModeResolution(mode_used=mode, strategy=strategy)
49
+
50
+
51
+ # ─────────────────────────────────────────────────────────────────────────────
52
+ # Effort: the mode is a ceiling; the request decides how much of it is spent
53
+ # ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ EffortLevel = Literal["direct", "standard", "full"]
56
+
57
+ _GREETING = re.compile(
58
+ r"^(hi|hello|hey|yo|hola|ciao|salve|bonjour|hallo|thanks|thank you|thx|ok|okay|test|ping|"
59
+ r"good (morning|afternoon|evening)|how are you)\b[\s!.?,]*$",
60
+ re.I,
61
+ )
62
+
63
+
64
+ def is_trivial(query: str) -> bool:
65
+ """A greeting, an acknowledgement, or a few words with no question: the kind of turn that
66
+ needs one generation whatever mode is selected (the same rule the composer uses)."""
67
+ t = (query or "").strip()
68
+ if not t or _GREETING.match(t):
69
+ return True
70
+ words = t.split()
71
+ return len(words) <= 3 and "?" not in t and len(t) <= 24
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class EffortProfile:
76
+ mode: str
77
+ level: EffortLevel # direct = one generation Β· standard = the mode without its optional stages Β· full
78
+ temperature: float
79
+ max_tokens: int
80
+ tool_rounds: int
81
+ plan: bool # think: analyze β†’ plan before solving
82
+ research: bool # heavy: the research agent runs
83
+ reason: str # one line for the trace
84
+ analyze: bool = False # think: a visible analysis step before the answer (always with plan)
85
+
86
+ def as_dict(self) -> dict:
87
+ return asdict(self)
88
+
89
+
90
+ def effort_for(mode: str, complexity: int, *, trivial: bool = False, forced_tools: bool = False) -> EffortProfile:
91
+ """What this request may spend in ``mode``. Fast is always one generation. Expert plans
92
+ only above a complexity threshold. Heavy researches only when the question is complex or
93
+ the user asked for a search; a trivial message is answered directly in every mode."""
94
+ if mode == "fast":
95
+ return EffortProfile("fast", "direct", EXPERT_FAST_TEMPERATURE, EXPERT_FAST_MAX_TOKENS,
96
+ EXPERT_FAST_TOOL_ROUNDS, False, False, "one generation, tools on demand")
97
+ if mode == "think":
98
+ if trivial:
99
+ return EffortProfile("think", "direct", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS,
100
+ EXPERT_THINK_TOOL_ROUNDS, False, False, "a short message needs no plan")
101
+ if complexity < EXPERT_THINK_PLAN_MIN_COMPLEXITY:
102
+ # The reasoning stays visible: a short analysis, then the answer from it (two calls).
103
+ return EffortProfile(
104
+ "think", "standard", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS, EXPERT_THINK_TOOL_ROUNDS,
105
+ False, False,
106
+ f"complexity {complexity} is below {EXPERT_THINK_PLAN_MIN_COMPLEXITY}: analyzed, then answered without a separate plan",
107
+ analyze=True,
108
+ )
109
+ return EffortProfile("think", "full", EXPERT_THINK_TEMPERATURE, EXPERT_THINK_MAX_TOKENS,
110
+ EXPERT_THINK_TOOL_ROUNDS, True, False, f"complexity {complexity}: analyze, plan, solve",
111
+ analyze=True)
112
+ if mode == "heavy":
113
+ if trivial:
114
+ return EffortProfile("heavy", "direct", EXPERT_HEAVY_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS,
115
+ EXPERT_HEAVY_TOOL_ROUNDS, False, False, "a short message needs no verification")
116
+ research = forced_tools or complexity >= EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY
117
+ if forced_tools:
118
+ why = "a web search was asked for: research, reason, synthesize, validate"
119
+ elif research:
120
+ why = f"complexity {complexity}: research, reason, synthesize, validate"
121
+ else:
122
+ why = f"complexity {complexity} is below {EXPERT_HEAVY_RESEARCH_MIN_COMPLEXITY}: reason, synthesize, validate"
123
+ return EffortProfile("heavy", "full" if research else "standard", EXPERT_HEAVY_TEMPERATURE,
124
+ EXPERT_HEAVY_MAX_TOKENS, EXPERT_HEAVY_TOOL_ROUNDS, False, research, why)
125
+ return EffortProfile("deep", "full", EXPERT_DEEP_TEMPERATURE, EXPERT_HEAVY_MAX_TOKENS,
126
+ EXPERT_THINK_TOOL_ROUNDS, True, True, "deep research runs its own plan")
expert/backend/app/expert/project_context.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A project's context for Expert: its instructions in the system prompt, its documents as a
2
+ tool.
3
+
4
+ When Expert runs inside HomePilot (the usual case: the package is mounted as ``app.expert``),
5
+ the project's documents are searched through the backend's own retrieval β€” the same
6
+ ``query_project_knowledge`` the standard chat uses β€” so Expert in a project sees what the
7
+ standard chat sees. As a standalone service that retrieval is out of reach, and the tool is
8
+ simply not offered; the instructions still apply.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from typing import Any, Callable, Dict, List, Optional
15
+
16
+ from .config import EXPERT_SYSTEM_PROMPT
17
+ from .tools import LocalTool, ToolOutcome, ToolSpec
18
+
19
+ logger = logging.getLogger("expert.project")
20
+
21
+ PROJECT_TOOL = "project_search"
22
+ PROJECT_TOOL_NATIVE = "hp.project.search"
23
+
24
+
25
+ def project_system_prompt(base: Optional[str], project: Optional[Dict[str, Any]]) -> Optional[str]:
26
+ """The system prompt with the project folded in; ``base`` (or the Expert default) alone
27
+ when there is no project."""
28
+ if not project or not (project.get("name") or project.get("instructions")):
29
+ return base
30
+ name = str(project.get("name") or "this project").strip()
31
+ lines = [base or EXPERT_SYSTEM_PROMPT, "", f"You are working inside the project β€œ{name}”."]
32
+ instructions = str(project.get("instructions") or "").strip()
33
+ if instructions:
34
+ lines += ["Project instructions:", instructions]
35
+ if int(project.get("document_count") or 0) > 0:
36
+ lines.append(
37
+ f"The project has documents; the {PROJECT_TOOL} tool searches them. Prefer what they say over "
38
+ "general knowledge and name the document you used."
39
+ )
40
+ return "\n".join(lines)
41
+
42
+
43
+ def _retrieval() -> Optional[Callable[..., List[Dict[str, Any]]]]:
44
+ """The backend's project retrieval, when this Expert runs inside HomePilot."""
45
+ try:
46
+ from ..vectordb import query_project_knowledge # type: ignore[import-not-found]
47
+ return query_project_knowledge
48
+ except Exception: # noqa: BLE001 β€” standalone Expert, or RAG disabled
49
+ return None
50
+
51
+
52
+ def render_passages(docs: List[Dict[str, Any]]) -> str:
53
+ """Retrieved chunks grouped per document, numbered, the way the standard chat renders them."""
54
+ grouped: Dict[str, List[str]] = {}
55
+ for doc in docs or []:
56
+ if not isinstance(doc, dict):
57
+ continue
58
+ source = str((doc.get("metadata") or {}).get("source") or "Unknown")
59
+ grouped.setdefault(source, []).append(str(doc.get("content") or ""))
60
+ if not grouped:
61
+ return "No matching passages in the project's documents."
62
+ parts = []
63
+ for i, (source, chunks) in enumerate(grouped.items(), 1):
64
+ parts.append(f"[Document {i}: {source}]\n" + "\n...\n".join(c.strip() for c in chunks if c.strip()))
65
+ return "\n\n".join(parts)
66
+
67
+
68
+ def project_tool(project: Optional[Dict[str, Any]], retrieval: Optional[Callable[..., Any]] = None) -> Optional[LocalTool]:
69
+ """The ``project_search`` tool for ``project``, or None when the project has no documents or
70
+ retrieval is not reachable from this process."""
71
+ if not project or not project.get("id") or int(project.get("document_count") or 0) <= 0:
72
+ return None
73
+ search = retrieval or _retrieval()
74
+ if search is None:
75
+ return None
76
+ project_id = str(project["id"])
77
+ name = str(project.get("name") or "the project")
78
+
79
+ async def handler(arguments: Dict[str, Any]) -> ToolOutcome:
80
+ query = str(arguments.get("query") or "").strip()
81
+ if not query:
82
+ return ToolOutcome(name=PROJECT_TOOL, ok=False, error="query is required")
83
+ limit = max(1, min(int(arguments.get("limit") or 5), 10))
84
+ docs = await asyncio.to_thread(search, project_id, query, limit)
85
+ docs = list(docs or [])
86
+ sources = {str((d.get("metadata") or {}).get("source") or "Unknown") for d in docs if isinstance(d, dict)}
87
+ text = render_passages(docs)
88
+ return ToolOutcome(
89
+ name=PROJECT_TOOL, ok=True, text=text,
90
+ data={"project_id": project_id, "passages": len(docs), "documents": sorted(sources)},
91
+ )
92
+
93
+ spec = ToolSpec(
94
+ name=PROJECT_TOOL,
95
+ native=PROJECT_TOOL_NATIVE,
96
+ description=f"Search the documents of the project β€œ{name}” for passages relevant to a query.",
97
+ parameters={
98
+ "type": "object",
99
+ "properties": {
100
+ "query": {"type": "string", "description": "What to look for in the project's documents"},
101
+ "limit": {"type": "integer", "default": 5, "minimum": 1, "maximum": 10},
102
+ },
103
+ "required": ["query"],
104
+ },
105
+ server="local",
106
+ )
107
+ return LocalTool(spec=spec, handler=handler)
expert/backend/app/expert/providers.py ADDED
@@ -0,0 +1,1282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/providers.py
2
+ # All new LLM provider adapters for the Expert module.
3
+ # Pattern mirrors backend/app/llm.py β€” returns OpenAI-compatible dicts.
4
+ # Streaming variants yield raw SSE chunks for the /expert/stream endpoint.
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import contextvars
10
+ import heapq
11
+ import itertools
12
+ import json
13
+ import logging
14
+ import os
15
+ import random
16
+ import time
17
+ from collections import deque
18
+ from typing import Any, AsyncIterator, Deque, Dict, List, Optional, Tuple
19
+
20
+ import httpx
21
+
22
+ from .config import (
23
+ GROK_API_KEY, GROK_BASE_URL, GROK_MODEL,
24
+ GROQ_API_KEY, GROQ_BASE_URL, GROQ_MODEL,
25
+ GEMINI_API_KEY, GEMINI_BASE_URL, GEMINI_MODEL,
26
+ EXPERT_OLLAMA_URL, EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL,
27
+ EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE,
28
+ EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S,
29
+ EXPERT_PROVIDER_RETRIES, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S,
30
+ EXPERT_PROVIDER_WINDOW_S,
31
+ )
32
+
33
+ logger = logging.getLogger("expert.providers")
34
+
35
+ _TIMEOUT = httpx.Timeout(timeout=120.0, connect=15.0)
36
+ _STREAM_TIMEOUT = httpx.Timeout(timeout=300.0, connect=15.0)
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Pacing and retries β€” one gateway is never flooded, and a rate limit is waited out
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ # Who is waiting: 0 = a person in the chat, 1 = background work (a Deep research job). When the
44
+ # endpoint is scarce, interactive requests are served first and may wait less long.
45
+ INTERACTIVE, BACKGROUND = 0, 1
46
+ _PRIORITY: contextvars.ContextVar[int] = contextvars.ContextVar("expert_request_priority", default=INTERACTIVE)
47
+
48
+
49
+ def mark_background() -> None:
50
+ """Make the current task's model calls background work (call at the start of a job's task;
51
+ a task's context is its own, so the setting never leaks into a chat request)."""
52
+ _PRIORITY.set(BACKGROUND)
53
+
54
+
55
+ class _PriorityGate:
56
+ """A semaphore whose waiters are served by priority, then first come first served β€” the
57
+ request queue in front of one endpoint. Cancellation-safe: a waiter that goes away leaves
58
+ the queue, and a slot granted to a request that was cancelled passes to the next one."""
59
+
60
+ def __init__(self, capacity: int) -> None:
61
+ self.capacity = max(1, int(capacity))
62
+ self.inflight = 0
63
+ self._waiters: List[Tuple[int, int, "asyncio.Future[None]"]] = []
64
+ self._seq = itertools.count()
65
+
66
+ def waiting(self) -> int:
67
+ return sum(1 for _, _, fut in self._waiters if not fut.done())
68
+
69
+ async def acquire(self, priority: int) -> None:
70
+ if self.inflight < self.capacity and not self.waiting():
71
+ self.inflight += 1
72
+ return
73
+ fut: "asyncio.Future[None]" = asyncio.get_running_loop().create_future()
74
+ heapq.heappush(self._waiters, (priority, next(self._seq), fut))
75
+ try:
76
+ await fut
77
+ except asyncio.CancelledError:
78
+ if fut.done() and not fut.cancelled():
79
+ self.release() # granted, then cancelled: hand the slot on
80
+ raise
81
+
82
+ def release(self) -> None:
83
+ while self._waiters:
84
+ _, _, fut = heapq.heappop(self._waiters)
85
+ if not fut.done():
86
+ fut.set_result(None) # the slot passes straight to the next waiter
87
+ return
88
+ self.inflight = max(0, self.inflight - 1)
89
+
90
+
91
+ class _EndpointPacer:
92
+ """Adaptive client-side throttling, per base URL (the pattern behind the AWS SDKs'
93
+ "adaptive" retry mode and Google SRE's client-side throttling):
94
+
95
+ * a **queue** in front of every endpoint: at most ``max_inflight`` requests at a time, the
96
+ rest wait in line β€” interactive requests ahead of background work β€” and a gap between
97
+ request starts;
98
+ * the gap starts at ``min_interval`` and **doubles on every rate limit** (up to
99
+ ``max_interval``), and every other caller to that endpoint waits out the cooldown too β€”
100
+ so one 429 slows the whole app down instead of each request hammering on its own;
101
+ * every success **shrinks the gap back** by 15 %, so a quiet endpoint is fast again soon.
102
+
103
+ Heavy's agents and Deep research's sub-agents then reach a shared gateway as a steady
104
+ trickle that adapts to its real limit, instead of a burst that trips it."""
105
+
106
+ def __init__(self, max_inflight: int, min_interval: float, max_interval: float, window: float = 60.0) -> None:
107
+ self.max_inflight = max(1, int(max_inflight))
108
+ self.min_interval = max(0.0, float(min_interval))
109
+ self.max_interval = max(self.min_interval, float(max_interval))
110
+ self.window = max(0.01, float(window))
111
+ self._slots: Dict[str, _PriorityGate] = {}
112
+ self._next_start: Dict[str, float] = {}
113
+ self._interval: Dict[str, float] = {}
114
+ # The meter, per endpoint: request starts (reserved, oldest first), successes and rate
115
+ # limits in the rolling window, and the learned cap β€” requests per window the endpoint
116
+ # takes before it says "rate limit" β€” with when it was last lowered or raised.
117
+ self._starts: Dict[str, Deque[float]] = {}
118
+ self._ok: Dict[str, Deque[float]] = {}
119
+ self._limited: Dict[str, Deque[float]] = {}
120
+ self._cap: Dict[str, int] = {}
121
+ self._cap_since: Dict[str, float] = {}
122
+ # Token bucket at the learned rate (cap per window): tokens left, and when last counted.
123
+ # It goes negative as requests reserve future slots, so starts are spread evenly.
124
+ self._tokens: Dict[str, float] = {}
125
+ self._refill_at: Dict[str, float] = {}
126
+ self._totals: Dict[str, Dict[str, int]] = {}
127
+ self._urls: Dict[str, str] = {}
128
+
129
+ def reset(self, *, min_interval: Optional[float] = None, max_interval: Optional[float] = None,
130
+ window: Optional[float] = None) -> None:
131
+ """Forget every endpoint's state (tests; a settings change)."""
132
+ if min_interval is not None:
133
+ self.min_interval = max(0.0, float(min_interval))
134
+ if max_interval is not None:
135
+ self.max_interval = max(self.min_interval, float(max_interval))
136
+ if window is not None:
137
+ self.window = max(0.01, float(window))
138
+ for store in (self._slots, self._next_start, self._interval, self._starts, self._ok, self._limited,
139
+ self._cap, self._cap_since, self._tokens, self._refill_at, self._totals, self._urls):
140
+ store.clear()
141
+
142
+ # ── the meter ────────────────────────────────────────────────────────────
143
+ def _trim(self, q: Deque[float], now: float) -> Deque[float]:
144
+ while q and q[0] < now - self.window:
145
+ q.popleft()
146
+ return q
147
+
148
+ def _count(self, key: str, what: str) -> None:
149
+ totals = self._totals.setdefault(key, {"requests": 0, "ok": 0, "rate_limited": 0})
150
+ totals[what] = totals.get(what, 0) + 1
151
+
152
+ def cap(self, base_url: str) -> Optional[int]:
153
+ return self._cap.get(self._key(base_url))
154
+
155
+ def usage(self) -> List[Dict[str, Any]]:
156
+ """Per endpoint: what is in flight and in line, the gap and cooldown, the learned cap,
157
+ and the rolling window's requests, successes and rate limits (``GET /v1/expert/usage``)."""
158
+ now = time.monotonic()
159
+ out: List[Dict[str, Any]] = []
160
+ for key, url in self._urls.items():
161
+ gate = self._slots.get(key)
162
+ limited = self._trim(self._limited.setdefault(key, deque()), now)
163
+ out.append({
164
+ "endpoint": url,
165
+ "in_flight": gate.inflight if gate else 0,
166
+ "queued": gate.waiting() if gate else 0,
167
+ "interval_s": round(self._interval.get(key, self.min_interval), 2),
168
+ "cooldown_s": round(max(0.0, self._next_start.get(key, 0.0) - now), 1),
169
+ "window_s": self.window,
170
+ "cap_per_window": self._cap.get(key),
171
+ "tokens": round(self._tokens[key], 2) if key in self._tokens else None,
172
+ "requests_in_window": len(self._trim(self._starts.setdefault(key, deque()), now)),
173
+ "ok_in_window": len(self._trim(self._ok.setdefault(key, deque()), now)),
174
+ "rate_limited_in_window": len(limited),
175
+ "last_rate_limit_s_ago": round(now - limited[-1], 1) if limited else None,
176
+ "totals": dict(self._totals.get(key, {})),
177
+ })
178
+ return out
179
+
180
+ @staticmethod
181
+ def _key(base_url: str) -> str:
182
+ # Keyed by event loop too: a semaphore belongs to the loop it first waited in.
183
+ return f"{id(asyncio.get_running_loop())}|{(base_url or '').rstrip('/').lower()}"
184
+
185
+ def interval(self, base_url: str) -> float:
186
+ return self._interval.get(self._key(base_url), self.min_interval)
187
+
188
+ def _gate(self, key: str) -> _PriorityGate:
189
+ gate = self._slots.get(key)
190
+ if gate is None:
191
+ gate = self._slots[key] = _PriorityGate(self.max_inflight)
192
+ return gate
193
+
194
+ def expected_wait(self, base_url: str) -> float:
195
+ """Roughly how long a request sent now waits before it starts: the cooldown, plus one
196
+ gap per request already in line. Used to tell the reader before a long wait."""
197
+ key = self._key(base_url)
198
+ gate = self._gate(key)
199
+ cooldown = max(0.0, self._next_start.get(key, 0.0) - time.monotonic())
200
+ queued = gate.waiting() + (1 if gate.inflight >= gate.capacity else 0)
201
+ return cooldown + queued * self._interval.get(key, self.min_interval)
202
+
203
+ def _bucket(self, key: str, cap: int, now: float) -> float:
204
+ """Tokens in the bucket now (refilled at ``cap`` per window since last counted)."""
205
+ rate = cap / self.window
206
+ tokens = self._tokens.get(key, float(cap))
207
+ tokens = min(float(cap), tokens + (now - self._refill_at.get(key, now)) * rate)
208
+ self._refill_at[key] = now
209
+ return tokens
210
+
211
+ def _reserve(self, key: str) -> float:
212
+ """This request's start time: after the cooldown and the gap, and β€” once a cap has been
213
+ learned β€” when the token bucket has a token for it. Starts are then spread evenly at the
214
+ learned rate (one every window/cap seconds) instead of a burst and a long stall."""
215
+ now = time.monotonic()
216
+ start_at = max(now, self._next_start.get(key, 0.0))
217
+ starts = self._starts.setdefault(key, deque())
218
+ cap = self._cap.get(key)
219
+ if cap:
220
+ tokens = self._bucket(key, cap, now) - 1.0
221
+ self._tokens[key] = tokens
222
+ if tokens < 0:
223
+ start_at = max(start_at, now + (-tokens) * self.window / cap)
224
+ starts.append(start_at)
225
+ while starts and starts[0] < now - 2 * self.window:
226
+ starts.popleft()
227
+ self._next_start[key] = start_at + self._interval.get(key, self.min_interval)
228
+ return start_at
229
+
230
+ @contextlib.asynccontextmanager
231
+ async def slot(self, base_url: str) -> AsyncIterator[None]:
232
+ key = self._key(base_url)
233
+ self._urls.setdefault(key, (base_url or "").rstrip("/"))
234
+ gate = self._gate(key)
235
+ await gate.acquire(_PRIORITY.get())
236
+ try:
237
+ # Reserve this request's start time before sleeping, so two waiting callers can never
238
+ # read the same free moment and start together.
239
+ start_at = self._reserve(key)
240
+ self._count(key, "requests")
241
+ now = time.monotonic()
242
+ if start_at > now:
243
+ await asyncio.sleep(start_at - now)
244
+ yield
245
+ finally:
246
+ gate.release()
247
+
248
+ def penalize(self, base_url: str, retry_after: Optional[float] = None) -> float:
249
+ """A rate limit: double the gap, hold every caller until the cooldown ends, and learn the
250
+ cap from the successes this window took β€” the endpoint's limit is at most that. With no
251
+ success in the window there is nothing to learn from (the limit may be shared with other
252
+ clients): an existing cap is lowered by one, and otherwise backoff alone handles it.
253
+ The bucket is emptied, so the next start waits one learned interval."""
254
+ key = self._key(base_url)
255
+ now = time.monotonic()
256
+ self._count(key, "rate_limited")
257
+ self._trim(self._limited.setdefault(key, deque()), now).append(now)
258
+ ok = len(self._trim(self._ok.setdefault(key, deque()), now))
259
+ previous = self._cap.get(key)
260
+ if ok >= 1:
261
+ self._cap[key] = max(1, min(ok, previous - 1)) if previous else ok
262
+ elif previous:
263
+ self._cap[key] = max(1, previous - 1)
264
+ if key in self._cap:
265
+ self._cap_since[key] = now
266
+ self._tokens[key] = min(0.0, self._bucket(key, self._cap[key], now))
267
+ if self._cap[key] != previous:
268
+ logger.info("endpoint %s: learned a limit of about %d requests per %.0fs", self._urls.get(key, key),
269
+ self._cap[key], self.window)
270
+ current = self._interval.get(key, self.min_interval)
271
+ grown = min(self.max_interval, max(current * 2, 2.0, self.min_interval))
272
+ self._interval[key] = grown
273
+ cooldown = min(self.max_interval, max(grown, retry_after or 0.0))
274
+ self._next_start[key] = max(self._next_start.get(key, 0.0), now + cooldown)
275
+ return grown
276
+
277
+ def reward(self, base_url: str) -> None:
278
+ """A success: count it, shrink the gap back toward ``min_interval``, and after a quiet
279
+ stretch (three windows without a rate limit) raise the learned cap by one β€” additive
280
+ increase, so a limit that was lifted is found again."""
281
+ key = self._key(base_url)
282
+ now = time.monotonic()
283
+ self._count(key, "ok")
284
+ self._trim(self._ok.setdefault(key, deque()), now).append(now)
285
+ cap = self._cap.get(key)
286
+ if cap is not None and now - self._cap_since.get(key, now) > 3 * self.window:
287
+ if cap >= 120:
288
+ self._cap.pop(key, None) # far above any limit seen: stop capping
289
+ self._cap_since.pop(key, None)
290
+ else:
291
+ self._cap[key] = cap + 1
292
+ self._cap_since[key] = now
293
+ current = self._interval.get(key)
294
+ if current is not None:
295
+ relaxed = max(self.min_interval, current * 0.85)
296
+ if relaxed <= self.min_interval:
297
+ self._interval.pop(key, None)
298
+ else:
299
+ self._interval[key] = relaxed
300
+
301
+
302
+ _PACER = _EndpointPacer(EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_MIN_INTERVAL_S, EXPERT_PROVIDER_MAX_INTERVAL_S,
303
+ EXPERT_PROVIDER_WINDOW_S)
304
+
305
+
306
+ def endpoint_usage() -> List[Dict[str, Any]]:
307
+ """The usage meter, for ``GET /v1/expert/usage``."""
308
+ return _PACER.usage()
309
+
310
+
311
+ class _Transient(RuntimeError):
312
+ """A failure worth a short wait and another try β€” before any token has been produced, so
313
+ nothing is repeated to the reader. Past the retries it reaches the user as it is."""
314
+
315
+ def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
316
+ super().__init__(message)
317
+ self.retry_after = retry_after
318
+
319
+
320
+ class _RateLimited(_Transient):
321
+ """HTTP 429, or a body that says "rate limit" (Open WebUI says it with a 400)."""
322
+
323
+
324
+ class _Overloaded(_Transient):
325
+ """HTTP 502/503/504: a gateway shedding load (503 + Retry-After is backpressure, not an
326
+ error) or a replica mid-restart. One or two tries later it is usually gone."""
327
+
328
+
329
+ def _retry_after(headers: Any) -> Optional[float]:
330
+ try:
331
+ value = float((headers or {}).get("retry-after") or 0)
332
+ except (TypeError, ValueError):
333
+ return None
334
+ return value if 0 < value <= 30 else None
335
+
336
+
337
+ def _backoff_s(attempt: int, retry_after: Optional[float]) -> float:
338
+ """Exponential backoff with jitter: about 2 s, 4 s, 8 s, 16 s (capped at 20 s), each Β±25 %
339
+ so callers that failed together do not retry together. A Retry-After header is a floor."""
340
+ base = min(20.0, 2.0 * (2 ** attempt))
341
+ if retry_after:
342
+ base = max(base, retry_after)
343
+ return base * random.uniform(0.75, 1.25)
344
+
345
+
346
+ def _wait_budget() -> float:
347
+ """How long a request may wait in line and retry before it gives up: a person waits less
348
+ than a background job."""
349
+ return EXPERT_PROVIDER_BACKGROUND_WAIT_S if _PRIORITY.get() == BACKGROUND else EXPERT_PROVIDER_WAIT_S
350
+
351
+
352
+ def _gave_up(err: "_Transient", waited: float, model: str) -> RuntimeError:
353
+ """The error a reader sees once the wait budget is spent: what happened, and what to do."""
354
+ name = (model or "the selected model").split("/")[-1]
355
+ if isinstance(err, _RateLimited):
356
+ text = (f"The model provider for {name} is busy right now (rate limited). HomePilot waited "
357
+ f"{waited:.0f} s in line; please try again in a minute.")
358
+ else:
359
+ text = (f"The model provider for {name} is overloaded or restarting. HomePilot waited "
360
+ f"{waited:.0f} s; please try again in a minute.")
361
+ out = type(err)(text, retry_after=err.retry_after)
362
+ out.__cause__ = err
363
+ return out
364
+
365
+
366
+ def _next_retry(err: "_Transient", attempt: int, started: float, base_url: str, model: str) -> float:
367
+ """The delay before the next try, or raise when the budget (time or tries) is spent."""
368
+ delay = _backoff_s(attempt, err.retry_after)
369
+ waited = time.monotonic() - started
370
+ if attempt >= EXPERT_PROVIDER_RETRIES or waited + delay > _wait_budget():
371
+ logger.warning("%s for %s: giving up after %.0fs and %d tries: %s", base_url, model, waited, attempt + 1, err)
372
+ raise _gave_up(err, waited, model)
373
+ logger.info("%s for %s: %s; retry %d in %.1fs (waited %.0fs of %.0fs)", base_url, model,
374
+ type(err).__name__.strip("_").lower(), attempt + 1, delay, waited, _wait_budget())
375
+ return delay
376
+
377
+
378
+ def _wait_event(text: str, seconds: float, attempt: int = 0) -> Dict[str, Any]:
379
+ return {"wait": {"text": text, "retry_in": round(max(0.0, seconds), 1), "attempt": attempt}}
380
+
381
+ # If the configured EXPERT_LOCAL_MODEL / EXPERT_LOCAL_FAST_MODEL isn't pulled,
382
+ # fall back to whatever Ollama has locally. Set to "false" to disable and get
383
+ # a hard error instead β€” useful in production where the deployed model must
384
+ # match exactly.
385
+ _LOCAL_AUTO_FALLBACK = os.getenv("EXPERT_LOCAL_AUTO_FALLBACK", "true").lower() != "false"
386
+
387
+ # Where Ollama may live besides EXPERT_OLLAMA_URL: loopback, and the Windows /
388
+ # Docker host when this service runs inside WSL or a container. The first URL
389
+ # that answers /api/tags with models wins and is remembered for a short time
390
+ # (same rules as HomePilot's backend/app/model_resolver.py; this service is
391
+ # deliberately isolated, so the 40 lines are copied rather than imported).
392
+ _OLLAMA_CACHE: Dict[str, Any] = {"url": "", "ts": 0.0, "models": []}
393
+ _OLLAMA_CACHE_TTL_S = 30.0
394
+
395
+
396
+ def _read_text(path: str) -> str:
397
+ try:
398
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
399
+ return fh.read()
400
+ except OSError:
401
+ return ""
402
+
403
+
404
+ def _host_gateway_ips() -> List[str]:
405
+ out: List[str] = []
406
+ for line in _read_text("/proc/net/route").splitlines()[1:]:
407
+ parts = line.split()
408
+ if len(parts) >= 3 and parts[1] == "00000000":
409
+ try:
410
+ raw = int(parts[2], 16)
411
+ out.append(".".join(str((raw >> (8 * i)) & 0xFF) for i in range(4)))
412
+ except ValueError:
413
+ pass
414
+ for line in _read_text("/etc/resolv.conf").splitlines():
415
+ p = line.split()
416
+ if len(p) >= 2 and p[0] == "nameserver" and p[1].count(".") == 3 and p[1] not in out:
417
+ out.append(p[1])
418
+ return [ip for ip in out if not ip.startswith("127.")]
419
+
420
+
421
+ def _ollama_candidates() -> List[str]:
422
+ urls = [EXPERT_OLLAMA_URL, "http://127.0.0.1:11434", "http://localhost:11434"]
423
+ in_wsl = bool(os.environ.get("WSL_DISTRO_NAME")) or "microsoft" in _read_text("/proc/version").lower()
424
+ if in_wsl:
425
+ urls += [f"http://{ip}:11434" for ip in _host_gateway_ips()]
426
+ if os.path.exists("/.dockerenv"):
427
+ urls += ["http://host.docker.internal:11434", "http://172.17.0.1:11434"]
428
+ seen: List[str] = []
429
+ for u in urls:
430
+ u = (u or "").rstrip("/")
431
+ if u and u not in seen:
432
+ seen.append(u)
433
+ return seen
434
+
435
+
436
+ def _ollama_base() -> str:
437
+ """Runtime URL the last successful probe found (EXPERT_OLLAMA_URL until then)."""
438
+ return _OLLAMA_CACHE["url"] or EXPERT_OLLAMA_URL
439
+
440
+
441
+ async def _ollama_pulled_models() -> List[str]:
442
+ """Model tags of the first reachable Ollama among ``_ollama_candidates()``
443
+ that has models. Remembers which URL answered so chat_local/stream_local
444
+ talk to the same runtime. Empty when nothing answers."""
445
+ import time as _time
446
+ now = _time.monotonic()
447
+ if _OLLAMA_CACHE["url"] and _OLLAMA_CACHE["models"] and now - _OLLAMA_CACHE["ts"] < _OLLAMA_CACHE_TTL_S:
448
+ return list(_OLLAMA_CACHE["models"])
449
+ best_url, best_models = "", []
450
+ try:
451
+ async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client:
452
+ for url in _ollama_candidates():
453
+ try:
454
+ r = await client.get(f"{url}/api/tags")
455
+ r.raise_for_status()
456
+ data = r.json()
457
+ except Exception:
458
+ continue
459
+ models = [m.get("name") or m.get("model") for m in data.get("models", []) if isinstance(m, dict)]
460
+ if models:
461
+ best_url, best_models = url, models
462
+ break
463
+ if not best_url:
464
+ best_url = url
465
+ except Exception:
466
+ pass
467
+ if best_url:
468
+ _OLLAMA_CACHE.update(url=best_url, ts=now, models=best_models)
469
+ return best_models
470
+
471
+
472
+ async def _resolve_local_model(requested: str) -> str:
473
+ """Return ``requested`` if Ollama has it pulled, otherwise either fall back
474
+ to the first available model or raise a clear error describing how to pull
475
+ it. Behavior controlled by ``EXPERT_LOCAL_AUTO_FALLBACK``."""
476
+ pulled = await _ollama_pulled_models()
477
+ if not pulled:
478
+ probed = ", ".join(_ollama_candidates())
479
+ raise RuntimeError(
480
+ f"No Ollama with models answered (tried {probed}). Open Settings β†’ Models in "
481
+ f"HomePilot to download one, or run `ollama pull {requested}`. If Ollama runs on "
482
+ f"Windows and this service runs in WSL, enable WSL mirrored networking."
483
+ )
484
+ if requested in pulled:
485
+ return requested
486
+ if not _LOCAL_AUTO_FALLBACK:
487
+ raise RuntimeError(
488
+ f"Expert local model '{requested}' is not pulled. "
489
+ f"Available: {pulled}. Run `ollama pull {requested}` to fix."
490
+ )
491
+ # Prefer an exact stem match (handles ':latest' vs ':Q4_K_M' variants).
492
+ stem = requested.split(":")[0]
493
+ for m in pulled:
494
+ if m and m.split(":")[0] == stem:
495
+ return m
496
+ return pulled[0]
497
+
498
+
499
+ # ─────────────────────────────────────────────────────────────────────────────
500
+ # Helpers
501
+ # ─────────────────────────────────────────────────────────────────────────────
502
+
503
+ def _openai_wrap(
504
+ content: str, model: str, provider: str, tool_calls: Optional[List[Dict[str, Any]]] = None
505
+ ) -> Dict[str, Any]:
506
+ """Wrap a plain text response into OpenAI-like schema for orchestrator consistency.
507
+ ``tool_calls`` (OpenAI shape, see :func:`_normalize_tool_calls`) ride on the message."""
508
+ message: Dict[str, Any] = {"role": "assistant", "content": content}
509
+ if tool_calls:
510
+ message["tool_calls"] = tool_calls
511
+ return {
512
+ "choices": [{"message": message}],
513
+ "model": model,
514
+ "provider": provider,
515
+ }
516
+
517
+
518
+ def _extract_content(data: Dict[str, Any]) -> str:
519
+ """Extract text content from OpenAI-style response dict."""
520
+ try:
521
+ return data["choices"][0]["message"]["content"] or ""
522
+ except (KeyError, IndexError):
523
+ return ""
524
+
525
+
526
+ # ─────────────────────────────────────────────────────────────────────────────
527
+ # Tool calling β€” one wire shape inside Expert (OpenAI's), translated per provider
528
+ # ─────────────────────────────────────────────────────────────────────────────
529
+
530
+ class ToolsUnsupported(RuntimeError):
531
+ """The model or endpoint rejected the ``tools`` field; the caller retries without tools."""
532
+
533
+
534
+ def _rate_limited(status: int, body: str) -> bool:
535
+ text = (body or "").lower()
536
+ return status == 429 or "rate limit" in text or "rate_limit" in text or "too many requests" in text
537
+
538
+
539
+ def _tools_rejected(status: int, body: str) -> bool:
540
+ """Ollama answers 400 "<model> does not support tools"; OllaBridge says ``tools_unsupported``;
541
+ other gateways say similar things. Never a rate limit, whatever its status."""
542
+ text = (body or "").lower()
543
+ if _rate_limited(status, text):
544
+ return False
545
+ if "tools_unsupported" in text:
546
+ return True
547
+ return status in (400, 422) and ("tool" in text or "function" in text)
548
+
549
+
550
+ def _legacy_tools_failure(status: int, body: str) -> bool:
551
+ """OllaBridge before October 2026 sent a tool-bearing request for a provider model to local
552
+ Ollama: HTTP 500 wrapping "404 Not Found … /api/chat". Read as "no tools here"."""
553
+ text = (body or "").lower()
554
+ return status >= 500 and "/api/chat" in text and "404" in text
555
+
556
+
557
+ def _wrapped_local_refusal(status: int, body: str) -> bool:
558
+ """An OllaBridge 500 wrapping local Ollama's 400 ("Client error '400 Bad Request' for url …
559
+ /api/chat"): on a tool-bearing request, Ollama's "this model does not support tools"."""
560
+ text = (body or "").lower()
561
+ return status >= 500 and "/api/chat" in text and "400" in text
562
+
563
+
564
+ def _message_in(data: Any) -> str:
565
+ """The human message in an error body, however it is nested: OpenAI's ``error.message``,
566
+ FastAPI's ``detail`` (a string, or OllaBridge's ``detail.error.message``), or ``message``."""
567
+ if isinstance(data, str):
568
+ return data.strip()
569
+ if not isinstance(data, dict):
570
+ return ""
571
+ for key in ("error", "detail", "message"):
572
+ found = _message_in(data.get(key))
573
+ if found:
574
+ return found
575
+ return ""
576
+
577
+
578
+ def _detail_text(body: str) -> str:
579
+ """The message inside an error body (see :func:`_message_in`), else the body."""
580
+ try:
581
+ data = json.loads(body or "")
582
+ except (json.JSONDecodeError, TypeError):
583
+ return (body or "").strip()
584
+ return _message_in(data) or (body or "").strip()
585
+
586
+
587
+ def _openai_compat_error(status: int, detail: str, headers: Any, *, base_url: str, model: str, tools: bool) -> Exception:
588
+ """The exception for an error answer from ``{base_url}/chat/completions``: a rate limit to
589
+ wait out, a refusal of the tools field to remember, or the endpoint's own explanation."""
590
+ who = f"{model or 'The selected model'} at {base_url}"
591
+ text = (detail or "").strip()
592
+ if _rate_limited(status, text):
593
+ return _RateLimited(
594
+ f"{who} is being rate limited by its provider (HTTP {status}"
595
+ + (f": {text[:160]}" if text else "") + "). Try again in a moment.",
596
+ retry_after=_retry_after(headers),
597
+ )
598
+ if tools and (_tools_rejected(status, text) or _legacy_tools_failure(status, text)
599
+ or _wrapped_local_refusal(status, text)):
600
+ return ToolsUnsupported(
601
+ f"{who} rejected the tools field (HTTP {status}" + (f": {text[:160]}" if text else "") + ")"
602
+ )
603
+ if _legacy_tools_failure(status, text):
604
+ # An older OllaBridge reports a provider failure (most often its rate limit) as a 500
605
+ # from local Ollama; for a request without tools it is worth waiting for, not fatal.
606
+ return _RateLimited(
607
+ f"{who} could not reach its provider (HTTP {status}; an older OllaBridge reports a "
608
+ "provider's rate limit this way). Try again in a moment.",
609
+ retry_after=_retry_after(headers),
610
+ )
611
+ if status in (502, 503, 504):
612
+ return _Overloaded(
613
+ f"{who} is overloaded or restarting (HTTP {status}"
614
+ + (f": {text[:160]}" if text else "") + "). Try again in a moment.",
615
+ retry_after=_retry_after(headers),
616
+ )
617
+ return RuntimeError(f"{who} answered HTTP {status}" + (f": {text[:240]}" if text else ""))
618
+
619
+
620
+ def _normalize_tool_calls(raw: Any) -> List[Dict[str, Any]]:
621
+ """Tool calls as OpenAI emits them: ``{id, type, function: {name, arguments: <json str>}}``.
622
+ Ollama sends ``arguments`` as an object and no id; both are normalized here."""
623
+ out: List[Dict[str, Any]] = []
624
+ for i, call in enumerate(raw or []):
625
+ if not isinstance(call, dict):
626
+ continue
627
+ fn = call.get("function") or {}
628
+ args = fn.get("arguments")
629
+ if not isinstance(args, str):
630
+ args = json.dumps(args or {}, ensure_ascii=False)
631
+ out.append({
632
+ "id": str(call.get("id") or f"call_{i + 1}"),
633
+ "type": "function",
634
+ "function": {"name": str(fn.get("name") or ""), "arguments": args or "{}"},
635
+ })
636
+ return out
637
+
638
+
639
+ def _openai_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
640
+ """Messages for an OpenAI-compatible endpoint: role/content, plus the tool-call fields."""
641
+ out: List[Dict[str, Any]] = []
642
+ for m in messages:
643
+ role = m.get("role")
644
+ if role == "tool":
645
+ out.append({"role": "tool", "tool_call_id": str(m.get("tool_call_id") or ""), "content": str(m.get("content") or "")})
646
+ elif role == "assistant" and m.get("tool_calls"):
647
+ out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": _normalize_tool_calls(m["tool_calls"])})
648
+ else:
649
+ out.append({"role": role, "content": m.get("content") or ""})
650
+ return out
651
+
652
+
653
+ def _ollama_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
654
+ """Messages for Ollama's /api/chat: tool results carry ``tool_name`` and arguments are objects."""
655
+ out: List[Dict[str, Any]] = []
656
+ for m in messages:
657
+ role = m.get("role")
658
+ if role == "tool":
659
+ out.append({"role": "tool", "content": str(m.get("content") or ""), "tool_name": str(m.get("name") or "")})
660
+ elif role == "assistant" and m.get("tool_calls"):
661
+ calls = []
662
+ for c in _normalize_tool_calls(m["tool_calls"]):
663
+ try:
664
+ args = json.loads(c["function"]["arguments"] or "{}")
665
+ except json.JSONDecodeError:
666
+ args = {"input": c["function"]["arguments"]}
667
+ calls.append({"function": {"name": c["function"]["name"], "arguments": args if isinstance(args, dict) else {"input": args}}})
668
+ out.append({"role": "assistant", "content": m.get("content") or "", "tool_calls": calls})
669
+ else:
670
+ out.append({"role": role, "content": m.get("content") or ""})
671
+ return out
672
+
673
+
674
+ def _params_b(text: Any) -> Optional[float]:
675
+ """'8.2B' β†’ 8.2, '70B' β†’ 70, '355M' β†’ 0.355; None when the text says nothing."""
676
+ s = str(text or "").strip().upper()
677
+ if not s:
678
+ return None
679
+ try:
680
+ if s.endswith("B"):
681
+ return float(s[:-1])
682
+ if s.endswith("M"):
683
+ return float(s[:-1]) / 1000.0
684
+ return float(s)
685
+ except ValueError:
686
+ return None
687
+
688
+
689
+ async def ollama_models_detail(base_url: Optional[str] = None) -> List[Dict[str, Any]]:
690
+ """``[{name, params_b, family, size_gb}]`` from ``/api/tags`` of ``base_url`` or the default
691
+ Ollama. Empty when nothing answers."""
692
+ urls = [base_url.rstrip("/")] if base_url else _ollama_candidates()
693
+ try:
694
+ async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client:
695
+ for url in urls:
696
+ try:
697
+ r = await client.get(f"{url}/api/tags")
698
+ r.raise_for_status()
699
+ data = r.json()
700
+ except Exception:
701
+ continue
702
+ out = []
703
+ for m in data.get("models", []) or []:
704
+ if not isinstance(m, dict):
705
+ continue
706
+ details = m.get("details") or {}
707
+ out.append({
708
+ "name": m.get("name") or m.get("model") or "",
709
+ "params_b": _params_b(details.get("parameter_size")),
710
+ "family": str(details.get("family") or ""),
711
+ "size_gb": round(float(m.get("size") or 0) / 1e9, 1),
712
+ })
713
+ if out:
714
+ return out
715
+ except Exception:
716
+ pass
717
+ return []
718
+
719
+
720
+ async def openai_compat_models(base_url: str, api_key: str = "") -> List[str]:
721
+ """Model ids an OpenAI-compatible endpoint lists at ``GET {base_url}/models``."""
722
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
723
+ try:
724
+ async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
725
+ r = await client.get(f"{base_url.rstrip('/')}/models", headers=headers)
726
+ r.raise_for_status()
727
+ data = r.json()
728
+ except Exception:
729
+ return []
730
+ items = data.get("data") if isinstance(data, dict) else data
731
+ names: List[str] = []
732
+ for item in items or []:
733
+ name = item.get("id") or item.get("name") if isinstance(item, dict) else item
734
+ if isinstance(name, str) and name:
735
+ names.append(name)
736
+ return names
737
+
738
+
739
+ # ─────────────────────────────────────────────────────────────────────────────
740
+ # xAI Grok (OpenAI-compatible β€” reuses same wire format)
741
+ # ─────────────────────────────────────────────────────────────────────────────
742
+
743
+ async def chat_grok(
744
+ messages: List[Dict[str, Any]],
745
+ *,
746
+ model: Optional[str] = None,
747
+ temperature: float = EXPERT_TEMPERATURE,
748
+ max_tokens: int = EXPERT_MAX_TOKENS,
749
+ ) -> Dict[str, Any]:
750
+ """xAI Grok via OpenAI-compatible /v1/chat/completions."""
751
+ if not GROK_API_KEY:
752
+ raise RuntimeError("GROK_API_KEY not set β€” cannot use Grok provider.")
753
+ mdl = model or GROK_MODEL
754
+ url = f"{GROK_BASE_URL}/chat/completions"
755
+ payload = {
756
+ "model": mdl,
757
+ "messages": messages,
758
+ "temperature": float(temperature),
759
+ "max_tokens": int(max_tokens),
760
+ }
761
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
762
+ r = await client.post(
763
+ url, json=payload,
764
+ headers={"Authorization": f"Bearer {GROK_API_KEY}"},
765
+ )
766
+ r.raise_for_status()
767
+ data = r.json()
768
+ data["provider"] = "grok"
769
+ return data
770
+
771
+
772
+ async def stream_grok(
773
+ messages: List[Dict[str, Any]],
774
+ *,
775
+ model: Optional[str] = None,
776
+ temperature: float = EXPERT_TEMPERATURE,
777
+ max_tokens: int = EXPERT_MAX_TOKENS,
778
+ ) -> AsyncIterator[str]:
779
+ """Stream tokens from Grok, yielding SSE-ready delta strings."""
780
+ if not GROK_API_KEY:
781
+ raise RuntimeError("GROK_API_KEY not set.")
782
+ mdl = model or GROK_MODEL
783
+ url = f"{GROK_BASE_URL}/chat/completions"
784
+ payload = {
785
+ "model": mdl, "messages": messages, "stream": True,
786
+ "temperature": float(temperature), "max_tokens": int(max_tokens),
787
+ }
788
+ async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
789
+ async with client.stream(
790
+ "POST", url, json=payload,
791
+ headers={"Authorization": f"Bearer {GROK_API_KEY}"},
792
+ ) as resp:
793
+ resp.raise_for_status()
794
+ async for line in resp.aiter_lines():
795
+ if line.startswith("data: "):
796
+ chunk = line[6:]
797
+ if chunk.strip() == "[DONE]":
798
+ break
799
+ try:
800
+ delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
801
+ if delta:
802
+ yield delta
803
+ except (json.JSONDecodeError, KeyError, IndexError):
804
+ continue
805
+
806
+
807
+ # ─────────────────────────────────────────────────────────────────────────────
808
+ # Groq (ultra-fast, free tier, open models: Llama 3.3 70B, Mixtral, etc.)
809
+ # ─────────────────────────────────────────────────────────────────────────────
810
+
811
+ async def chat_groq(
812
+ messages: List[Dict[str, Any]],
813
+ *,
814
+ model: Optional[str] = None,
815
+ temperature: float = EXPERT_TEMPERATURE,
816
+ max_tokens: int = EXPERT_MAX_TOKENS,
817
+ ) -> Dict[str, Any]:
818
+ """Groq inference API β€” OpenAI-compatible, blazing fast."""
819
+ if not GROQ_API_KEY:
820
+ raise RuntimeError("GROQ_API_KEY not set β€” cannot use Groq provider.")
821
+ mdl = model or GROQ_MODEL
822
+ url = f"{GROQ_BASE_URL}/chat/completions"
823
+ payload = {
824
+ "model": mdl,
825
+ "messages": messages,
826
+ "temperature": float(temperature),
827
+ "max_tokens": int(max_tokens),
828
+ }
829
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
830
+ r = await client.post(
831
+ url, json=payload,
832
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
833
+ )
834
+ r.raise_for_status()
835
+ data = r.json()
836
+ data["provider"] = "groq"
837
+ return data
838
+
839
+
840
+ async def stream_groq(
841
+ messages: List[Dict[str, Any]],
842
+ *,
843
+ model: Optional[str] = None,
844
+ temperature: float = EXPERT_TEMPERATURE,
845
+ max_tokens: int = EXPERT_MAX_TOKENS,
846
+ ) -> AsyncIterator[str]:
847
+ """Stream tokens from Groq."""
848
+ if not GROQ_API_KEY:
849
+ raise RuntimeError("GROQ_API_KEY not set.")
850
+ mdl = model or GROQ_MODEL
851
+ url = f"{GROQ_BASE_URL}/chat/completions"
852
+ payload = {
853
+ "model": mdl, "messages": messages, "stream": True,
854
+ "temperature": float(temperature), "max_tokens": int(max_tokens),
855
+ }
856
+ async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
857
+ async with client.stream(
858
+ "POST", url, json=payload,
859
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
860
+ ) as resp:
861
+ resp.raise_for_status()
862
+ async for line in resp.aiter_lines():
863
+ if line.startswith("data: "):
864
+ chunk = line[6:]
865
+ if chunk.strip() == "[DONE]":
866
+ break
867
+ try:
868
+ delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
869
+ if delta:
870
+ yield delta
871
+ except (json.JSONDecodeError, KeyError, IndexError):
872
+ continue
873
+
874
+
875
+ # ─────────────────────────────────────────────────────────────────────────────
876
+ # Google Gemini (via OpenAI-compatible endpoint β€” available since Gemini 1.5)
877
+ # ─────────────────────────────────────────────────────────────────────────────
878
+
879
+ async def chat_gemini(
880
+ messages: List[Dict[str, Any]],
881
+ *,
882
+ model: Optional[str] = None,
883
+ temperature: float = EXPERT_TEMPERATURE,
884
+ max_tokens: int = EXPERT_MAX_TOKENS,
885
+ ) -> Dict[str, Any]:
886
+ """Google Gemini via its OpenAI-compatible REST endpoint."""
887
+ if not GEMINI_API_KEY:
888
+ raise RuntimeError("GEMINI_API_KEY not set β€” cannot use Gemini provider.")
889
+ mdl = model or GEMINI_MODEL
890
+ url = f"{GEMINI_BASE_URL}/chat/completions"
891
+ payload = {
892
+ "model": mdl,
893
+ "messages": messages,
894
+ "temperature": float(temperature),
895
+ "max_tokens": int(max_tokens),
896
+ }
897
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
898
+ r = await client.post(
899
+ url, json=payload,
900
+ headers={"Authorization": f"Bearer {GEMINI_API_KEY}"},
901
+ )
902
+ r.raise_for_status()
903
+ data = r.json()
904
+ data["provider"] = "gemini"
905
+ return data
906
+
907
+
908
+ async def stream_gemini(
909
+ messages: List[Dict[str, Any]],
910
+ *,
911
+ model: Optional[str] = None,
912
+ temperature: float = EXPERT_TEMPERATURE,
913
+ max_tokens: int = EXPERT_MAX_TOKENS,
914
+ ) -> AsyncIterator[str]:
915
+ """Stream tokens from Gemini."""
916
+ if not GEMINI_API_KEY:
917
+ raise RuntimeError("GEMINI_API_KEY not set.")
918
+ mdl = model or GEMINI_MODEL
919
+ url = f"{GEMINI_BASE_URL}/chat/completions"
920
+ payload = {
921
+ "model": mdl, "messages": messages, "stream": True,
922
+ "temperature": float(temperature), "max_tokens": int(max_tokens),
923
+ }
924
+ async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
925
+ async with client.stream(
926
+ "POST", url, json=payload,
927
+ headers={"Authorization": f"Bearer {GEMINI_API_KEY}"},
928
+ ) as resp:
929
+ resp.raise_for_status()
930
+ async for line in resp.aiter_lines():
931
+ if line.startswith("data: "):
932
+ chunk = line[6:]
933
+ if chunk.strip() == "[DONE]":
934
+ break
935
+ try:
936
+ delta = json.loads(chunk)["choices"][0]["delta"].get("content", "")
937
+ if delta:
938
+ yield delta
939
+ except (json.JSONDecodeError, KeyError, IndexError):
940
+ continue
941
+
942
+
943
+ # ─────────────────────────────────────────────────────────────────────────────
944
+ # OpenAI-compatible endpoint the user chose for chat (OllaBridge gateway, Cloud relay, vLLM…)
945
+ # ─────────────────────────────────────────────────────────────────────────────
946
+
947
+ async def chat_openai_compat(
948
+ messages: List[Dict[str, Any]],
949
+ *,
950
+ base_url: str,
951
+ model: str = "",
952
+ api_key: str = "",
953
+ temperature: float = EXPERT_TEMPERATURE,
954
+ max_tokens: int = EXPERT_MAX_TOKENS,
955
+ provider_label: str = "openai_compat",
956
+ tools: Optional[List[Dict[str, Any]]] = None,
957
+ ) -> Dict[str, Any]:
958
+ """``POST {base_url}/chat/completions`` β€” the same request HomePilot's ordinary chat sends
959
+ (``app.llm.chat_openai_compat``), kept here because Expert is loaded as its own top-level
960
+ package and cannot import the backend's modules relatively.
961
+
962
+ Failures carry the endpoint's own explanation: a gateway that refuses a model says why in
963
+ its body, and "HTTP 500" alone sends the user looking in the wrong place.
964
+ """
965
+ url = f"{base_url.rstrip('/')}/chat/completions"
966
+ payload: Dict[str, Any] = {
967
+ "messages": _openai_messages(messages),
968
+ "temperature": float(temperature),
969
+ "max_tokens": int(max_tokens),
970
+ "stream": False,
971
+ }
972
+ if model:
973
+ payload["model"] = model
974
+ if tools:
975
+ payload["tools"] = tools
976
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
977
+ attempt = 0
978
+ started = time.monotonic()
979
+ while True:
980
+ try:
981
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client, _PACER.slot(base_url):
982
+ r = await client.post(url, json=payload, headers=headers)
983
+ except httpx.HTTPError as exc:
984
+ raise RuntimeError(
985
+ f"Could not reach {base_url} for {model or 'the selected model'} "
986
+ f"({type(exc).__name__}). Is it running?"
987
+ ) from exc
988
+ if r.status_code < 400:
989
+ _PACER.reward(base_url)
990
+ break
991
+ err = _openai_compat_error(
992
+ r.status_code, _error_detail(r), r.headers, base_url=base_url, model=model, tools=bool(tools),
993
+ )
994
+ if not isinstance(err, _Transient):
995
+ raise err
996
+ if isinstance(err, _RateLimited):
997
+ _PACER.penalize(base_url, err.retry_after)
998
+ await asyncio.sleep(_next_retry(err, attempt, started, base_url, model))
999
+ attempt += 1
1000
+ data = r.json()
1001
+ try:
1002
+ raw_calls = data["choices"][0]["message"].get("tool_calls")
1003
+ except (KeyError, IndexError, AttributeError):
1004
+ raw_calls = None
1005
+ return _openai_wrap(
1006
+ _extract_content(data), str(data.get("model") or model), provider_label,
1007
+ tool_calls=_normalize_tool_calls(raw_calls),
1008
+ )
1009
+
1010
+
1011
+ def _error_detail(r: httpx.Response) -> str:
1012
+ try:
1013
+ return _message_in(r.json()) or (r.text or "").strip()
1014
+ except Exception: # noqa: BLE001
1015
+ return (r.text or "").strip()
1016
+
1017
+
1018
+ async def stream_openai_compat_events(
1019
+ messages: List[Dict[str, Any]],
1020
+ *,
1021
+ base_url: str,
1022
+ model: str = "",
1023
+ api_key: str = "",
1024
+ temperature: float = EXPERT_TEMPERATURE,
1025
+ max_tokens: int = EXPERT_MAX_TOKENS,
1026
+ tools: Optional[List[Dict[str, Any]]] = None,
1027
+ ) -> AsyncIterator[Dict[str, Any]]:
1028
+ """Stream ``{base_url}/chat/completions`` (:func:`_stream_openai_compat_once`) through the
1029
+ endpoint's queue, retried with backoff while the provider rate-limits or sheds load β€” only
1030
+ before the first token, so nothing is ever repeated to the reader. While it waits it says
1031
+ so: ``{"wait": {"text", "retry_in", "attempt"}}`` events, which the chat shows in place of
1032
+ a silent pause."""
1033
+ attempt = 0
1034
+ started = time.monotonic()
1035
+ ahead = _PACER.expected_wait(base_url)
1036
+ if ahead >= 2.0:
1037
+ yield _wait_event("Waiting in line for the model provider…", ahead)
1038
+ while True:
1039
+ try:
1040
+ async for event in _stream_openai_compat_once(
1041
+ messages, base_url=base_url, model=model, api_key=api_key,
1042
+ temperature=temperature, max_tokens=max_tokens, tools=tools,
1043
+ ):
1044
+ yield event
1045
+ _PACER.reward(base_url)
1046
+ return
1047
+ except _Transient as exc:
1048
+ if isinstance(exc, _RateLimited):
1049
+ _PACER.penalize(base_url, exc.retry_after)
1050
+ delay = _next_retry(exc, attempt, started, base_url, model)
1051
+ busy = "busy" if isinstance(exc, _RateLimited) else "overloaded"
1052
+ yield _wait_event(f"The model provider is {busy}; retrying in {delay:.0f} s…", delay, attempt + 1)
1053
+ await asyncio.sleep(delay)
1054
+ attempt += 1
1055
+
1056
+
1057
+ async def _stream_openai_compat_once(
1058
+ messages: List[Dict[str, Any]],
1059
+ *,
1060
+ base_url: str,
1061
+ model: str = "",
1062
+ api_key: str = "",
1063
+ temperature: float = EXPERT_TEMPERATURE,
1064
+ max_tokens: int = EXPERT_MAX_TOKENS,
1065
+ tools: Optional[List[Dict[str, Any]]] = None,
1066
+ ) -> AsyncIterator[Dict[str, Any]]:
1067
+ """Stream ``{base_url}/chat/completions``: ``{"delta": text}`` as tokens arrive and, once
1068
+ the stream ends, ``{"tool_calls": [...]}`` when the model called tools (their argument
1069
+ fragments are assembled here, by index, the way OpenAI streams them)."""
1070
+ url = f"{base_url.rstrip('/')}/chat/completions"
1071
+ payload: Dict[str, Any] = {
1072
+ "messages": _openai_messages(messages),
1073
+ "temperature": float(temperature),
1074
+ "max_tokens": int(max_tokens),
1075
+ "stream": True,
1076
+ }
1077
+ if model:
1078
+ payload["model"] = model
1079
+ if tools:
1080
+ payload["tools"] = tools
1081
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
1082
+ pending: Dict[int, Dict[str, str]] = {}
1083
+ try:
1084
+ async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client, _PACER.slot(base_url):
1085
+ async with client.stream("POST", url, json=payload, headers=headers) as resp:
1086
+ if resp.status_code >= 400:
1087
+ body = (await resp.aread()).decode("utf-8", "replace")
1088
+ raise _openai_compat_error(
1089
+ resp.status_code, _detail_text(body), resp.headers,
1090
+ base_url=base_url, model=model, tools=bool(tools),
1091
+ )
1092
+ if "text/event-stream" not in (resp.headers.get("content-type") or "").lower():
1093
+ # A gateway that ignores stream=true answers with one JSON completion (OllaBridge
1094
+ # does): hand it over as a single delta rather than reading it as empty SSE.
1095
+ raw_body = (await resp.aread()).decode("utf-8", "replace")
1096
+ try:
1097
+ data = json.loads(raw_body or "{}")
1098
+ except json.JSONDecodeError:
1099
+ data = {}
1100
+ content = _extract_content(data) if isinstance(data, dict) else ""
1101
+ if content:
1102
+ yield {"delta": content}
1103
+ try:
1104
+ raw_calls = data["choices"][0]["message"].get("tool_calls")
1105
+ except (KeyError, IndexError, AttributeError, TypeError):
1106
+ raw_calls = None
1107
+ calls = _normalize_tool_calls(raw_calls)
1108
+ if calls:
1109
+ yield {"tool_calls": calls}
1110
+ return
1111
+ async for line in resp.aiter_lines():
1112
+ if not line.startswith("data:"):
1113
+ continue
1114
+ chunk = line[5:].strip()
1115
+ if chunk == "[DONE]":
1116
+ break
1117
+ try:
1118
+ obj = json.loads(chunk)
1119
+ except json.JSONDecodeError:
1120
+ continue
1121
+ choices = obj.get("choices") or []
1122
+ if not choices:
1123
+ continue
1124
+ delta = choices[0].get("delta") or {}
1125
+ if delta.get("content"):
1126
+ yield {"delta": delta["content"]}
1127
+ for tc in delta.get("tool_calls") or []:
1128
+ idx = int(tc.get("index", len(pending)) or 0)
1129
+ slot = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
1130
+ if tc.get("id"):
1131
+ slot["id"] = str(tc["id"])
1132
+ fn = tc.get("function") or {}
1133
+ if fn.get("name") and not slot["name"]:
1134
+ slot["name"] = str(fn["name"])
1135
+ if fn.get("arguments"):
1136
+ slot["arguments"] += str(fn["arguments"])
1137
+ except httpx.HTTPError as exc:
1138
+ raise RuntimeError(
1139
+ f"Could not reach {base_url} for {model or 'the selected model'} ({type(exc).__name__}). Is it running?"
1140
+ ) from exc
1141
+ if pending:
1142
+ yield {"tool_calls": _normalize_tool_calls([
1143
+ {"id": s["id"], "function": {"name": s["name"], "arguments": s["arguments"] or "{}"}}
1144
+ for _, s in sorted(pending.items())
1145
+ ])}
1146
+
1147
+
1148
+ # ─────────────────────────────────────────────────────────────────────────────
1149
+ # Local Ollama (free, on-premises, no API key needed)
1150
+ # ─────────────────────────────────────────────────────────────────────────────
1151
+
1152
+ def _ollama_failure(r: httpx.Response, mdl: str) -> RuntimeError:
1153
+ """Ollama's own reason for a 5xx, which is usually the useful part ("model requires more
1154
+ system memory", "llama runner process has terminated") and was hidden behind a bare
1155
+ ``500 Internal Server Error``."""
1156
+ reason = ""
1157
+ try:
1158
+ reason = str((r.json() or {}).get("error") or "")
1159
+ except Exception: # noqa: BLE001
1160
+ reason = (r.text or "").strip()
1161
+ reason = reason[:240] or r.reason_phrase or "server error"
1162
+ return RuntimeError(f"Ollama could not run '{mdl}' (HTTP {r.status_code}): {reason}")
1163
+
1164
+
1165
+ async def chat_local(
1166
+ messages: List[Dict[str, Any]],
1167
+ *,
1168
+ model: Optional[str] = None,
1169
+ fast: bool = False,
1170
+ temperature: float = EXPERT_TEMPERATURE,
1171
+ max_tokens: int = EXPERT_MAX_TOKENS,
1172
+ base_url: Optional[str] = None,
1173
+ tools: Optional[List[Dict[str, Any]]] = None,
1174
+ ) -> Dict[str, Any]:
1175
+ """Ollama local inference β€” zero cost, fully sovereign.
1176
+
1177
+ ``base_url`` is an Ollama the user chose for chat; its model is used as given, since the
1178
+ installed-model check below is about the default Ollama, not that one. ``tools`` (OpenAI
1179
+ shape) are offered to the model; a model without tool support raises ToolsUnsupported.
1180
+ """
1181
+ requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL)
1182
+ mdl = requested if (base_url and model) else await _resolve_local_model(requested)
1183
+ url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat"
1184
+ payload: Dict[str, Any] = {
1185
+ "model": mdl,
1186
+ "messages": _ollama_messages(messages),
1187
+ "stream": False,
1188
+ "options": {
1189
+ "temperature": float(temperature),
1190
+ "num_predict": int(max_tokens),
1191
+ },
1192
+ }
1193
+ if tools:
1194
+ payload["tools"] = tools
1195
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
1196
+ r = await client.post(url, json=payload)
1197
+ if r.status_code == 404:
1198
+ raise RuntimeError(
1199
+ f"Ollama rejected model '{mdl}' with 404. Run "
1200
+ f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)."
1201
+ )
1202
+ if r.status_code >= 500:
1203
+ raise _ollama_failure(r, mdl)
1204
+ if tools and _tools_rejected(r.status_code, r.text):
1205
+ raise ToolsUnsupported(f"{mdl} does not support tool calling.")
1206
+ r.raise_for_status()
1207
+ data = r.json()
1208
+
1209
+ message = data.get("message", {}) or {}
1210
+ return _openai_wrap(message.get("content", ""), mdl, "local", tool_calls=_normalize_tool_calls(message.get("tool_calls")))
1211
+
1212
+
1213
+ async def stream_local_events(
1214
+ messages: List[Dict[str, Any]],
1215
+ *,
1216
+ model: Optional[str] = None,
1217
+ fast: bool = False,
1218
+ temperature: float = EXPERT_TEMPERATURE,
1219
+ max_tokens: int = EXPERT_MAX_TOKENS,
1220
+ base_url: Optional[str] = None,
1221
+ tools: Optional[List[Dict[str, Any]]] = None,
1222
+ ) -> AsyncIterator[Dict[str, Any]]:
1223
+ """Stream Ollama: ``{"delta": text}`` per token and ``{"tool_calls": [...]}`` when the model
1224
+ calls tools. ``base_url`` and ``tools``: as in :func:`chat_local`."""
1225
+ requested = model or (EXPERT_LOCAL_FAST_MODEL if fast else EXPERT_LOCAL_MODEL)
1226
+ mdl = requested if (base_url and model) else await _resolve_local_model(requested)
1227
+ url = f"{(base_url or _ollama_base()).rstrip('/')}/api/chat"
1228
+ payload: Dict[str, Any] = {
1229
+ "model": mdl,
1230
+ "messages": _ollama_messages(messages),
1231
+ "stream": True,
1232
+ "options": {"temperature": float(temperature), "num_predict": int(max_tokens)},
1233
+ }
1234
+ if tools:
1235
+ payload["tools"] = tools
1236
+ calls: List[Dict[str, Any]] = []
1237
+ async with httpx.AsyncClient(timeout=_STREAM_TIMEOUT) as client:
1238
+ async with client.stream("POST", url, json=payload) as resp:
1239
+ if resp.status_code == 404:
1240
+ raise RuntimeError(
1241
+ f"Ollama rejected model '{mdl}' with 404. Run "
1242
+ f"`ollama pull {mdl}` and retry (or set EXPERT_LOCAL_MODEL)."
1243
+ )
1244
+ if resp.status_code == 400 and tools:
1245
+ body = (await resp.aread()).decode("utf-8", "replace")
1246
+ if _tools_rejected(400, body):
1247
+ raise ToolsUnsupported(f"{mdl} does not support tool calling.")
1248
+ resp.raise_for_status()
1249
+ async for line in resp.aiter_lines():
1250
+ if not line.strip():
1251
+ continue
1252
+ try:
1253
+ chunk = json.loads(line)
1254
+ except json.JSONDecodeError:
1255
+ continue
1256
+ message = chunk.get("message", {}) or {}
1257
+ delta = message.get("content", "")
1258
+ if delta:
1259
+ yield {"delta": delta}
1260
+ if message.get("tool_calls"):
1261
+ calls.extend(_normalize_tool_calls(message["tool_calls"]))
1262
+ if chunk.get("done"):
1263
+ break
1264
+ if calls:
1265
+ yield {"tool_calls": calls}
1266
+
1267
+
1268
+ async def stream_local(
1269
+ messages: List[Dict[str, Any]],
1270
+ *,
1271
+ model: Optional[str] = None,
1272
+ fast: bool = False,
1273
+ temperature: float = EXPERT_TEMPERATURE,
1274
+ max_tokens: int = EXPERT_MAX_TOKENS,
1275
+ base_url: Optional[str] = None,
1276
+ ) -> AsyncIterator[str]:
1277
+ """Stream tokens from Ollama. ``base_url``: as in :func:`chat_local`."""
1278
+ async for event in stream_local_events(
1279
+ messages, model=model, fast=fast, temperature=temperature, max_tokens=max_tokens, base_url=base_url,
1280
+ ):
1281
+ if "delta" in event:
1282
+ yield event["delta"]
expert/backend/app/expert/readiness.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Dict, List
6
+
7
+ from .mcp_catalog import mcp_key_to_env, p0_required_server_keys
8
+
9
+
10
+ @dataclass
11
+ class ReadinessCheck:
12
+ name: str
13
+ passed: bool
14
+ detail: str
15
+
16
+
17
+ @dataclass
18
+ class ReadinessReport:
19
+ ready: bool
20
+ stage: str
21
+ checks: List[ReadinessCheck]
22
+
23
+
24
+ def build_readiness_report(env: Dict[str, str] | None = None) -> ReadinessReport:
25
+ source = env or dict(os.environ)
26
+ preprod_mode = _truthy(source.get("EXPERT_PREPROD_MODE"))
27
+
28
+ checks = [
29
+ _check("tools_live", _truthy(source.get("EXPERT_TOOLS_ENABLED")), "Tool layer must be enabled."),
30
+ _check("memory_enabled", _truthy(source.get("EXPERT_MEMORY_ENABLED")), "Memory layer must be enabled."),
31
+ _check("evals_enabled", _truthy(source.get("EXPERT_EVALS_ENABLED")), "Eval recording must be enabled."),
32
+ _check("router_v2", _truthy(source.get("EXPERT_V2_ROUTER_ENABLED")), "V2 router must be enabled."),
33
+ _check(
34
+ "persistent_memory",
35
+ bool(source.get("EXPERT_MEMORY_BACKEND", "").strip()),
36
+ "Persistent memory backend must be configured.",
37
+ ),
38
+ _check(
39
+ "observability",
40
+ bool(source.get("EXPERT_TELEMETRY_ENDPOINT", "").strip()),
41
+ "Telemetry endpoint must be configured.",
42
+ ),
43
+ _check(
44
+ "safety_policy",
45
+ bool(source.get("EXPERT_SAFETY_POLICY", "").strip()),
46
+ "Safety policy profile must be configured.",
47
+ ),
48
+ ]
49
+ checks.extend(_mcp_preprod_checks(source, enabled=preprod_mode))
50
+
51
+ passed = sum(1 for c in checks if c.passed)
52
+ ready = passed == len(checks)
53
+ if ready:
54
+ stage = "production"
55
+ elif passed >= 4:
56
+ stage = "preprod"
57
+ else:
58
+ stage = "dev"
59
+
60
+ return ReadinessReport(ready=ready, stage=stage, checks=checks)
61
+
62
+
63
+ def _check(name: str, passed: bool, detail: str) -> ReadinessCheck:
64
+ return ReadinessCheck(name=name, passed=passed, detail=detail)
65
+
66
+
67
+ def _truthy(v: str | None) -> bool:
68
+ return (v or "").strip().lower() in {"1", "true", "yes", "on"}
69
+
70
+
71
+ def _mcp_preprod_checks(source: Dict[str, str], enabled: bool) -> List[ReadinessCheck]:
72
+ checks: List[ReadinessCheck] = []
73
+ if not enabled:
74
+ return checks
75
+ orchestrator = (source.get("EXPERT_MCP_ORCHESTRATOR", "context_forge") or "context_forge").strip().lower()
76
+ checks.append(
77
+ _check(
78
+ "mcp_orchestrator",
79
+ orchestrator in {"context_forge", "direct"},
80
+ "EXPERT_MCP_ORCHESTRATOR must be 'context_forge' or 'direct'.",
81
+ )
82
+ )
83
+
84
+ if orchestrator == "context_forge":
85
+ checks.append(
86
+ _check(
87
+ "context_forge_url",
88
+ bool(source.get("CONTEXT_FORGE_URL", "").strip()),
89
+ "Set CONTEXT_FORGE_URL for MCP Context Forge orchestration.",
90
+ )
91
+ )
92
+ return checks
93
+
94
+ for key in p0_required_server_keys():
95
+ env_key = mcp_key_to_env(key)
96
+ checks.append(
97
+ _check(
98
+ f"mcp:{key}",
99
+ bool(source.get(env_key, "").strip()),
100
+ f"Set {env_key} for preprod MCP P0 integration.",
101
+ )
102
+ )
103
+ return checks
expert/backend/app/expert/router.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/router.py
2
+ # Smart multi-provider routing: score query complexity β†’ pick optimal provider.
3
+ # Fallback chain ensures resilience: if preferred provider fails, try next tier.
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ from typing import Any, AsyncIterator, Dict, List, Literal, Optional
9
+
10
+ from .config import (
11
+ GROK_API_KEY, GROQ_API_KEY, GEMINI_API_KEY,
12
+ EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD,
13
+ EXPERT_SYSTEM_PROMPT, EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_PROVIDER_TIMEOUT_S,
14
+ EXPERT_HISTORY_MAX_TURNS, EXPERT_HISTORY_MAX_CHARS,
15
+ available_expert_providers,
16
+ )
17
+ from .providers import (
18
+ chat_grok, stream_grok,
19
+ chat_groq, stream_groq,
20
+ chat_gemini, stream_gemini,
21
+ chat_local, stream_local, stream_local_events,
22
+ chat_openai_compat, stream_openai_compat_events,
23
+ ToolsUnsupported,
24
+ )
25
+ from . import compaction
26
+ from . import grounding
27
+ from . import target as target_mod
28
+
29
+ logger = logging.getLogger("expert.router")
30
+
31
+ ProviderName = Literal["local", "groq", "grok", "gemini", "claude", "openai", "auto"]
32
+
33
+
34
+ def _attach_dispatch_meta(
35
+ raw: Dict[str, Any],
36
+ *,
37
+ requested_provider: ProviderName,
38
+ fallback_applied: bool,
39
+ notice: Optional[str] = None,
40
+ ) -> Dict[str, Any]:
41
+ provider_used = raw.get("provider", requested_provider)
42
+ raw["__expert_meta"] = {
43
+ "requested_provider": requested_provider,
44
+ "provider_used": provider_used,
45
+ "fallback_applied": fallback_applied,
46
+ "notice": notice,
47
+ }
48
+ return raw
49
+
50
+
51
+ def extract_dispatch_meta(raw: Dict[str, Any]) -> Dict[str, Any]:
52
+ meta = raw.get("__expert_meta")
53
+ if isinstance(meta, dict):
54
+ return meta
55
+ provider = raw.get("provider", "local")
56
+ return {
57
+ "requested_provider": provider,
58
+ "provider_used": provider,
59
+ "fallback_applied": False,
60
+ "notice": None,
61
+ }
62
+
63
+
64
+ # ─────────────────────────────────────────────────────────────────────────────
65
+ # Complexity scorer
66
+ # ─────────────────────────────────────────────────────────────────────────────
67
+
68
+ _COMPLEX_KEYWORDS = {
69
+ "analyze", "analyse", "compare", "reason", "explain", "prove",
70
+ "research", "summarize", "summarise", "evaluate", "critique",
71
+ "code", "debug", "implement", "design", "architecture",
72
+ "math", "calculate", "derive", "equation",
73
+ "write", "draft", "essay", "report",
74
+ }
75
+
76
+ _SIMPLE_KEYWORDS = {
77
+ "what is", "who is", "when", "where", "define", "list",
78
+ "how many", "translate", "hi", "hello", "thanks",
79
+ }
80
+
81
+
82
+ def score_complexity(query: str) -> int:
83
+ """
84
+ Score query complexity 0–10.
85
+ 0–3 β†’ local fast model (simple factual, greetings)
86
+ 4–6 β†’ Groq 70B (moderate reasoning, explanations)
87
+ 7–10 β†’ Grok / Gemini (deep reasoning, long-form, code)
88
+ """
89
+ score = 0
90
+ q = query.lower().strip()
91
+
92
+ # Length signals
93
+ words = len(q.split())
94
+ if words > 100:
95
+ score += 3
96
+ elif words > 40:
97
+ score += 2
98
+ elif words > 15:
99
+ score += 1
100
+
101
+ # Complexity keywords
102
+ if any(kw in q for kw in _COMPLEX_KEYWORDS):
103
+ score += 3
104
+
105
+ # Simple keywords (penalise complexity)
106
+ if any(kw in q for kw in _SIMPLE_KEYWORDS):
107
+ score = max(0, score - 2)
108
+
109
+ # Multi-sentence questions suggest depth
110
+ sentences = [s.strip() for s in q.split(".") if s.strip()]
111
+ if len(sentences) > 3:
112
+ score += 1
113
+
114
+ # Code blocks or backticks
115
+ if "```" in query or "`" in query:
116
+ score += 2
117
+
118
+ # Multiple questions
119
+ if query.count("?") > 1:
120
+ score += 1
121
+
122
+ return min(score, 10)
123
+
124
+
125
+ # ─────────────────────────────────────────────────────────────────────────────
126
+ # Provider selection
127
+ # ─────────────────────────────────────────────────────────────────────────────
128
+
129
+ def select_provider(query: str, preferred: ProviderName = "auto") -> ProviderName:
130
+ """
131
+ Select best available provider for this query.
132
+
133
+ If preferred == 'auto', use complexity-based routing.
134
+ Otherwise, use the requested provider if available, else fallback to auto.
135
+ """
136
+ available = available_expert_providers()
137
+
138
+ if preferred != "auto":
139
+ if preferred in available:
140
+ return preferred
141
+ logger.warning("Provider '%s' not available, falling back to auto routing.", preferred)
142
+
143
+ complexity = score_complexity(query)
144
+ logger.debug("Query complexity score: %d", complexity)
145
+
146
+ if complexity <= EXPERT_LOCAL_THRESHOLD:
147
+ return "local"
148
+
149
+ if complexity <= EXPERT_GROQ_THRESHOLD:
150
+ if "groq" in available:
151
+ return "groq"
152
+ if "local" in available:
153
+ return "local"
154
+
155
+ # High complexity β€” best available cloud
156
+ for p in ("grok", "gemini", "claude", "openai", "groq", "local"):
157
+ if p in available:
158
+ return p
159
+
160
+ return "local" # ultimate fallback
161
+
162
+
163
+ # ─────────────────────────────────────────────────────────────────────────────
164
+ # Conversation history
165
+ # ─────────────────────────────────────────────────────────────────────────────
166
+
167
+ # Prefixed to a pipeline step's instruction when earlier turns precede it, so the step reads
168
+ # the user's latest question against them rather than as a question from nowhere.
169
+ CONTINUATION_NOTE = (
170
+ "The task below continues the conversation above. Resolve references in it (\"it\", "
171
+ "\"the second option\", \"as before\"), keep the constraints already agreed, and answer "
172
+ "in the conversation's language.\n\n"
173
+ )
174
+
175
+
176
+ def clean_turns(history: Optional[List[Dict[str, Any]]]) -> List[Dict[str, str]]:
177
+ """User and assistant turns with text, in order; everything else dropped."""
178
+ return [
179
+ {"role": str(m.get("role")), "content": str(m.get("content"))}
180
+ for m in (history or [])
181
+ if isinstance(m, dict)
182
+ and m.get("role") in ("user", "assistant")
183
+ and str(m.get("content") or "").strip()
184
+ ]
185
+
186
+
187
+ def history_window(
188
+ history: Optional[List[Dict[str, Any]]],
189
+ *,
190
+ max_turns: Optional[int] = None,
191
+ max_chars: Optional[int] = None,
192
+ ) -> List[Dict[str, str]]:
193
+ """The recent conversation as clean chat turns: user and assistant messages with text,
194
+ the newest that fit the turn and character budgets, opening on a user turn (providers
195
+ that enforce role alternation accept the window as it is). The turns before the window
196
+ are not lost: compaction.py carries them as a note."""
197
+ max_turns = EXPERT_HISTORY_MAX_TURNS if max_turns is None else max_turns
198
+ max_chars = EXPERT_HISTORY_MAX_CHARS if max_chars is None else max_chars
199
+ clean = clean_turns(history)
200
+ kept: List[Dict[str, str]] = []
201
+ used = 0
202
+ for m in reversed(clean):
203
+ if len(kept) >= max_turns or used + len(m["content"]) > max_chars:
204
+ break
205
+ kept.append(m)
206
+ used += len(m["content"])
207
+ kept.reverse()
208
+ while kept and kept[0]["role"] != "user":
209
+ kept.pop(0)
210
+ return kept
211
+
212
+
213
+ def build_messages(
214
+ user_query: str,
215
+ history: Optional[List[Dict[str, str]]] = None,
216
+ system_prompt: Optional[str] = None,
217
+ ) -> List[Dict[str, str]]:
218
+ """Assemble the messages list: system prompt, the recent conversation as role-tagged
219
+ turns, then the new query as the newest user turn β€” the shape chat models are trained on."""
220
+ messages: List[Dict[str, str]] = [{
221
+ "role": "system",
222
+ "content": compaction.apply(grounding.apply(system_prompt or EXPERT_SYSTEM_PROMPT)),
223
+ }]
224
+ messages.extend(history_window(history))
225
+ messages.append({"role": "user", "content": user_query})
226
+ return messages
227
+
228
+
229
+ def step_messages(
230
+ instruction: str,
231
+ *,
232
+ system_prompt: str,
233
+ history: Optional[List[Dict[str, str]]] = None,
234
+ ) -> List[Dict[str, str]]:
235
+ """Messages for one call of a multi-step pipeline (think, heavy). Every step sees the
236
+ conversation the question came from β€” so a follow-up ("and for the second one?") is
237
+ understood in the analysis and the final answer matches the thread β€” while the step's
238
+ own instruction stays the newest user turn, told that it continues that conversation."""
239
+ turns = history_window(history)
240
+ if turns:
241
+ instruction = CONTINUATION_NOTE + instruction
242
+ return build_messages(instruction, history=turns, system_prompt=system_prompt)
243
+
244
+
245
+ # ─────────────────────────────────────────────────────────────────────────────
246
+ # Dispatch β€” sync
247
+ # ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ async def dispatch(
250
+ messages: List[Dict[str, str]],
251
+ provider: ProviderName,
252
+ *,
253
+ model: Optional[str] = None,
254
+ temperature: float = EXPERT_TEMPERATURE,
255
+ max_tokens: int = EXPERT_MAX_TOKENS,
256
+ tools: Optional[List[Dict[str, Any]]] = None,
257
+ ) -> Dict[str, Any]:
258
+ """
259
+ Call the selected provider and return an OpenAI-compatible response dict.
260
+ Includes one fallback level: if provider fails, try local Ollama.
261
+ ``tools`` (OpenAI shape) reach the local/target path β€” Ollama and OpenAI-compatible
262
+ endpoints; the response message then carries ``tool_calls`` when the model used them.
263
+ """
264
+ try:
265
+ raw = await asyncio.wait_for(
266
+ _call_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools),
267
+ timeout=EXPERT_PROVIDER_TIMEOUT_S,
268
+ )
269
+ return _attach_dispatch_meta(raw, requested_provider=provider, fallback_applied=False)
270
+ except ToolsUnsupported:
271
+ raise
272
+ except Exception as e:
273
+ logger.warning("Provider '%s' failed (%s), falling back to local.", provider, e)
274
+ if provider != "local":
275
+ # "local" is the user's chosen chat model when there is one (see target.py) β€”
276
+ # called directly, so the fallback never re-enters the path that just failed.
277
+ raw = await asyncio.wait_for(
278
+ _call_local(messages, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools),
279
+ timeout=EXPERT_PROVIDER_TIMEOUT_S,
280
+ )
281
+ return _attach_dispatch_meta(
282
+ raw,
283
+ requested_provider=provider,
284
+ fallback_applied=True,
285
+ notice="Requested provider failed; used local fallback.",
286
+ )
287
+ raise
288
+
289
+
290
+ async def _call_provider(
291
+ messages: List[Dict[str, str]],
292
+ provider: ProviderName,
293
+ *,
294
+ model: Optional[str],
295
+ temperature: float,
296
+ max_tokens: int,
297
+ tools: Optional[List[Dict[str, Any]]] = None,
298
+ ) -> Dict[str, Any]:
299
+ kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens)
300
+ if provider == "grok":
301
+ return await chat_grok(messages, **kwargs)
302
+ if provider == "groq":
303
+ return await chat_groq(messages, **kwargs)
304
+ if provider == "gemini":
305
+ return await chat_gemini(messages, **kwargs)
306
+ if provider == "claude":
307
+ # Delegate to existing HomePilot llm.py to reuse ANTHROPIC_API_KEY
308
+ from ..llm import chat_claude
309
+ return await chat_claude(messages, **{k: v for k, v in kwargs.items() if v is not None})
310
+ if provider == "openai":
311
+ from ..llm import chat_openai
312
+ return await chat_openai(messages, **{k: v for k, v in kwargs.items() if v is not None})
313
+ return await _call_local(messages, tools=tools, **kwargs)
314
+
315
+
316
+ async def _call_local(
317
+ messages: List[Dict[str, str]],
318
+ *,
319
+ model: Optional[str],
320
+ temperature: float,
321
+ max_tokens: int,
322
+ tools: Optional[List[Dict[str, Any]]] = None,
323
+ ) -> Dict[str, Any]:
324
+ """The chat model the user picked (target.py), else local Ollama."""
325
+ kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens, tools=tools)
326
+ chosen = target_mod.current()
327
+ if chosen is not None:
328
+ return await _call_target(messages, chosen, **kwargs)
329
+ return await chat_local(messages, **kwargs)
330
+
331
+
332
+ async def _call_target(
333
+ messages: List[Dict[str, str]],
334
+ chosen: "target_mod.ChatTarget",
335
+ *,
336
+ model: Optional[str],
337
+ temperature: float,
338
+ max_tokens: int,
339
+ tools: Optional[List[Dict[str, Any]]] = None,
340
+ ) -> Dict[str, Any]:
341
+ """Answer from the user's chosen chat model: an Ollama model, or an OpenAI-compatible
342
+ endpoint such as an OllaBridge gateway (``http://localhost:11435/v1``) or the Cloud relay.
343
+ """
344
+ if chosen.is_ollama:
345
+ return await chat_local(
346
+ messages,
347
+ model=chosen.model or model,
348
+ base_url=chosen.base_url or None,
349
+ temperature=temperature,
350
+ max_tokens=max_tokens,
351
+ tools=tools,
352
+ )
353
+ return await chat_openai_compat(
354
+ messages,
355
+ base_url=chosen.base_url,
356
+ model=chosen.model or model or "",
357
+ api_key=chosen.api_key,
358
+ temperature=temperature,
359
+ max_tokens=max_tokens,
360
+ provider_label=chosen.label,
361
+ tools=tools,
362
+ )
363
+
364
+
365
+ # ─────────────────────────────────────────────────────────────────────────────
366
+ # Dispatch β€” streaming
367
+ # ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ async def dispatch_stream(
370
+ messages: List[Dict[str, str]],
371
+ provider: ProviderName,
372
+ *,
373
+ model: Optional[str] = None,
374
+ temperature: float = EXPERT_TEMPERATURE,
375
+ max_tokens: int = EXPERT_MAX_TOKENS,
376
+ ) -> AsyncIterator[str]:
377
+ """
378
+ Stream tokens from selected provider.
379
+ Falls back to local if cloud provider fails on first chunk.
380
+ """
381
+ try:
382
+ async for chunk in _stream_provider(messages, provider, model=model,
383
+ temperature=temperature, max_tokens=max_tokens):
384
+ yield chunk
385
+ except Exception as e:
386
+ logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e)
387
+ if provider != "local":
388
+ async for chunk in _stream_provider(messages, "local", model=model,
389
+ temperature=temperature, max_tokens=max_tokens):
390
+ yield chunk
391
+ else:
392
+ raise
393
+
394
+
395
+ async def _stream_provider(
396
+ messages: List[Dict[str, str]],
397
+ provider: ProviderName,
398
+ *,
399
+ model: Optional[str],
400
+ temperature: float,
401
+ max_tokens: int,
402
+ ) -> AsyncIterator[str]:
403
+ kwargs = dict(model=model, temperature=temperature, max_tokens=max_tokens)
404
+ if provider == "grok":
405
+ async for c in stream_grok(messages, **kwargs): yield c
406
+ elif provider == "groq":
407
+ async for c in stream_groq(messages, **kwargs): yield c
408
+ elif provider == "gemini":
409
+ async for c in stream_gemini(messages, **kwargs): yield c
410
+ elif provider in ("claude", "openai"):
411
+ # Cloud providers without dedicated stream adapter: fall back to non-streaming
412
+ # and emit the full response as one chunk β€” transparent to the client
413
+ resp = await _call_provider(messages, provider, **kwargs)
414
+ content = resp.get("choices", [{}])[0].get("message", {}).get("content", "")
415
+ yield content
416
+ else:
417
+ chosen = target_mod.current()
418
+ if chosen is not None and chosen.is_ollama:
419
+ async for c in stream_local(messages, model=chosen.model or model,
420
+ base_url=chosen.base_url or None,
421
+ temperature=temperature, max_tokens=max_tokens):
422
+ yield c
423
+ elif chosen is not None:
424
+ # Real streaming from the OpenAI-compatible endpoint; a gateway that cannot stream
425
+ # answers in one chunk, the way Claude and OpenAI are streamed above.
426
+ yielded = False
427
+ try:
428
+ async for ev in stream_openai_compat_events(
429
+ messages, base_url=chosen.base_url, model=chosen.model or model or "",
430
+ api_key=chosen.api_key, temperature=temperature, max_tokens=max_tokens,
431
+ ):
432
+ if "delta" in ev:
433
+ yielded = True
434
+ yield ev["delta"]
435
+ except Exception:
436
+ if yielded:
437
+ raise
438
+ resp = await _call_target(messages, chosen, **kwargs)
439
+ yield resp.get("choices", [{}])[0].get("message", {}).get("content", "")
440
+ else:
441
+ async for c in stream_local(messages, **kwargs): yield c
442
+
443
+
444
+ # ─────────────────────────────────────────────────────────────────────────────
445
+ # Dispatch β€” streaming with structure (tokens and tool calls)
446
+ # ─────────────────────────────────────────────────────────────────────────────
447
+
448
+ async def stream_provider_events(
449
+ messages: List[Dict[str, Any]],
450
+ provider: ProviderName,
451
+ *,
452
+ tools: Optional[List[Dict[str, Any]]] = None,
453
+ model: Optional[str] = None,
454
+ temperature: float = EXPERT_TEMPERATURE,
455
+ max_tokens: int = EXPERT_MAX_TOKENS,
456
+ ) -> AsyncIterator[Dict[str, Any]]:
457
+ """Stream ``{"delta": text}`` / ``{"tool_calls": [...]}`` events, with the same local
458
+ fallback as :func:`dispatch_stream` (announced as a ``{"notice": ..., "fallback": True}``
459
+ event). ``tools`` reach the local/target path β€” Ollama and OpenAI-compatible endpoints;
460
+ the other providers stream text only. A ToolsUnsupported refusal is passed through so the
461
+ caller can retry without tools."""
462
+ try:
463
+ async for ev in _stream_provider_events(messages, provider, tools=tools, model=model,
464
+ temperature=temperature, max_tokens=max_tokens):
465
+ yield ev
466
+ except ToolsUnsupported:
467
+ raise
468
+ except Exception as e:
469
+ if provider == "local":
470
+ logger.warning("Streaming provider 'local' failed: %s", e)
471
+ raise
472
+ logger.warning("Streaming provider '%s' failed (%s), falling back to local.", provider, e)
473
+ yield {"notice": "Requested provider failed; used local fallback.", "fallback": True}
474
+ async for ev in _stream_provider_events(messages, "local", tools=tools, model=model,
475
+ temperature=temperature, max_tokens=max_tokens):
476
+ yield ev
477
+
478
+
479
+ async def _stream_provider_events(
480
+ messages: List[Dict[str, Any]],
481
+ provider: ProviderName,
482
+ *,
483
+ tools: Optional[List[Dict[str, Any]]],
484
+ model: Optional[str],
485
+ temperature: float,
486
+ max_tokens: int,
487
+ ) -> AsyncIterator[Dict[str, Any]]:
488
+ if provider in ("grok", "groq", "gemini", "claude", "openai"):
489
+ async for c in _stream_provider(messages, provider, model=model, temperature=temperature, max_tokens=max_tokens):
490
+ yield {"delta": c}
491
+ return
492
+ chosen = target_mod.current()
493
+ kwargs = dict(temperature=temperature, max_tokens=max_tokens, tools=tools)
494
+ if chosen is not None and not chosen.is_ollama:
495
+ # Real streaming from the OpenAI-compatible endpoint; when the stream fails before its
496
+ # first event, the plain request is tried once (a gateway that cannot stream still
497
+ # answers that). A refusal of the tools field passes through for the retry without.
498
+ yielded = False
499
+ try:
500
+ async for ev in stream_openai_compat_events(
501
+ messages, base_url=chosen.base_url, model=chosen.model or model or "", api_key=chosen.api_key, **kwargs,
502
+ ):
503
+ yielded = True
504
+ yield ev
505
+ except ToolsUnsupported:
506
+ raise
507
+ except Exception:
508
+ if yielded:
509
+ raise
510
+ raw = await _call_target(messages, chosen, model=model, temperature=temperature, max_tokens=max_tokens, tools=tools)
511
+ message = ((raw.get("choices") or [{}])[0] or {}).get("message") or {}
512
+ if message.get("content"):
513
+ yield {"delta": message["content"]}
514
+ if message.get("tool_calls"):
515
+ yield {"tool_calls": message["tool_calls"]}
516
+ elif chosen is not None:
517
+ async for ev in stream_local_events(messages, model=chosen.model or model, base_url=chosen.base_url or None, **kwargs):
518
+ yield ev
519
+ else:
520
+ async for ev in stream_local_events(messages, model=model, **kwargs):
521
+ yield ev
expert/backend/app/expert/routes.py ADDED
@@ -0,0 +1,865 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/routes.py
2
+ # FastAPI router for the Expert module.
3
+ # Mount pattern identical to upscale.py / enhance.py β€” include in main.py with 2 lines.
4
+ #
5
+ # Endpoints:
6
+ # GET /v1/expert/info β†’ provider status + routing config
7
+ # POST /v1/expert/chat β†’ single-turn or multi-turn, returns full response
8
+ # POST /v1/expert/stream β†’ streaming SSE response
9
+ # POST /v1/expert/route β†’ debug: returns which provider would be selected
10
+ #
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import os
17
+ import time
18
+ from typing import Any, AsyncIterator, Dict, Optional
19
+
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from fastapi.responses import StreamingResponse
22
+
23
+ from .config import (
24
+ EXPERT_LOCAL_THRESHOLD, EXPERT_GROQ_THRESHOLD,
25
+ EXPERT_LOCAL_MODEL, EXPERT_LOCAL_FAST_MODEL,
26
+ GROQ_MODEL, GROK_MODEL, GEMINI_MODEL,
27
+ EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE,
28
+ EXPERT_HEAVY_ESCALATE, EXPERT_DEEP_TIME_BUDGET_S,
29
+ available_expert_providers,
30
+ )
31
+ from .router import (
32
+ build_messages,
33
+ dispatch,
34
+ dispatch_stream,
35
+ extract_dispatch_meta,
36
+ score_complexity,
37
+ select_provider,
38
+ )
39
+ from .tool_loop import run_with_tools, stream_with_tools
40
+ from .policies import effort_for, is_trivial, resolve_thinking_mode
41
+ from .project_context import PROJECT_TOOL, project_system_prompt, project_tool
42
+ from . import tools as tools_mod
43
+ from .thinking import think, stream_think
44
+ from .heavy import heavy, stream_heavy
45
+ from .schemas import (
46
+ CapabilitiesRequest,
47
+ ExpertChatRequest,
48
+ ExpertChatResponse,
49
+ ExpertInfoResponse,
50
+ ExpertStreamRequest,
51
+ OllabridgeChatRequest,
52
+ PersonaDraftRequest,
53
+ PersonaDraftResponse,
54
+ ProductionReadinessResponse,
55
+ ReadinessCheckItem,
56
+ )
57
+ from . import capability
58
+ from . import compaction
59
+ from . import config as config_mod
60
+ from . import deep
61
+ from . import grounding
62
+ from . import target as target_mod
63
+ from .readiness import build_readiness_report
64
+ from .mcp_catalog import ESSENTIAL_MCP_SERVERS, TOOL_MAPPING, mcp_key_to_env, mcp_key_to_forge_tool_env
65
+ from .mcp_gateway import MCPGateway
66
+
67
+ logger = logging.getLogger("expert.routes")
68
+
69
+ router = APIRouter(prefix="/v1/expert", tags=["expert"])
70
+
71
+
72
+ def _compute_request_complexity(req: ExpertChatRequest) -> int:
73
+ score = score_complexity(req.query)
74
+ if req.has_attachments:
75
+ score += 2
76
+ if len(req.history) > 8:
77
+ score += 1
78
+ return min(score, 10)
79
+
80
+
81
+ # ─────────────────────────────────────────────────────────────────────────────
82
+ # GET /v1/expert/info
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ @router.get("/info", response_model=ExpertInfoResponse, summary="Expert module status")
86
+ async def expert_info() -> ExpertInfoResponse:
87
+ """Return which providers are configured and the current routing thresholds."""
88
+ providers = available_expert_providers()
89
+ return ExpertInfoResponse(
90
+ available_providers=providers,
91
+ default_provider="auto",
92
+ local_threshold=EXPERT_LOCAL_THRESHOLD,
93
+ groq_threshold=EXPERT_GROQ_THRESHOLD,
94
+ local_model=EXPERT_LOCAL_MODEL,
95
+ local_fast_model=EXPERT_LOCAL_FAST_MODEL,
96
+ groq_model=GROQ_MODEL,
97
+ grok_model=GROK_MODEL,
98
+ gemini_model=GEMINI_MODEL,
99
+ )
100
+
101
+
102
+ # ─────────────────────────────────────────────────────────────────────────────
103
+ # POST /v1/expert/chat (non-streaming, full response)
104
+ # ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ def _provider_for(req: ExpertChatRequest, chosen) -> str:
107
+ """The provider to start from. With the user's own chat model chosen (``target``), 'auto'
108
+ means that model β€” a complex question is not a reason to send it to a cloud provider the
109
+ user did not pick."""
110
+ if chosen is not None and req.provider == "auto":
111
+ return "local"
112
+ return select_provider(req.query, preferred=req.provider)
113
+
114
+
115
+ def _model_label(chosen, requested: Optional[str]) -> Optional[str]:
116
+ """The model a response reports: the chosen target's, else the one the request named."""
117
+ if chosen is not None and chosen.model:
118
+ return chosen.model
119
+ return requested or None
120
+
121
+
122
+ async def _escalate_for_heavy(chosen, req: ExpertChatRequest, mode: str, provider: str):
123
+ """Heavy runs on the strongest model of the user's own connection when that is a clear
124
+ step up (capability.py) β€” the same Ollama or gateway, never a provider they did not pick.
125
+ Returns ``(target to run with, escalated model name or None)``."""
126
+ if mode != "heavy" or not EXPERT_HEAVY_ESCALATE or provider != "local":
127
+ return chosen, None
128
+ if isinstance(req.feature_hints, dict) and req.feature_hints.get("escalate") is False:
129
+ return chosen, None
130
+ try:
131
+ caps = await capability.assess(chosen, req.model)
132
+ except Exception as exc: # noqa: BLE001
133
+ logger.debug("Heavy escalation skipped: %s", exc)
134
+ return chosen, None
135
+ best, selected = caps.get("best"), caps.get("selected") or {}
136
+ if not best or not capability.stronger(best, selected):
137
+ return chosen, None
138
+ if chosen is None:
139
+ return target_mod.ChatTarget(provider="ollama", base_url="", model=best["name"]), best["name"]
140
+ return (
141
+ target_mod.ChatTarget(provider=chosen.provider, base_url=chosen.base_url, model=best["name"], api_key=chosen.api_key),
142
+ best["name"],
143
+ )
144
+
145
+
146
+ def _project(req: ExpertChatRequest) -> Optional[Dict[str, Any]]:
147
+ return req.project.model_dump() if req.project is not None else None
148
+
149
+
150
+ def _request_tools(req: ExpertChatRequest) -> tools_mod.RequestTools:
151
+ """The shared tools plus this request's own: the project's documents, when there are any
152
+ and the backend's retrieval is reachable (project_context.py)."""
153
+ local = [t for t in (project_tool(_project(req)),) if t is not None]
154
+ return tools_mod.RequestTools(tools_mod.registry(), local)
155
+
156
+
157
+ def _forced_tools(req: ExpertChatRequest, registry: Optional[tools_mod.RequestTools] = None):
158
+ """What runs before the model is asked anything: the project's documents when the chat is
159
+ inside a project with documents, and a web search when the chat's "Search the web" switch
160
+ is on (feature_hints.webSearch = "force") β€” in every mode."""
161
+ forced: list = []
162
+ project = _project(req)
163
+ if project and int(project.get("document_count") or 0) > 0 and (registry is None or registry.spec(PROJECT_TOOL)):
164
+ forced.append((PROJECT_TOOL, {"query": req.query}))
165
+ hints = req.feature_hints if isinstance(req.feature_hints, dict) else {}
166
+ if str(hints.get("webSearch") or "").lower() in ("force", "on", "true", "1"):
167
+ forced.append(("hp_web_search", {"query": req.query}))
168
+ return forced or None
169
+
170
+
171
+ def _effort_notice(effort) -> Optional[str]:
172
+ return None
173
+
174
+
175
+ def _escalation_notice(escalated: str, chosen, req: ExpertChatRequest) -> str:
176
+ was = _model_label(chosen, req.model) or EXPERT_LOCAL_MODEL
177
+ return f"Heavy used {escalated}, the strongest model on this connection, instead of {was}."
178
+
179
+
180
+ @router.post("/chat", response_model=ExpertChatResponse, summary="Expert chat (sync)")
181
+ async def expert_chat(req: ExpertChatRequest) -> ExpertChatResponse:
182
+ """
183
+ Send a message to the Expert and get a complete response.
184
+
185
+ thinking_mode controls the pipeline:
186
+ 'fast' β†’ single LLM call (default for simple queries)
187
+ 'think' β†’ analyze β†’ plan β†’ solve (3 chained LLM calls)
188
+ 'heavy' β†’ research β†’ reason β†’ synthesize β†’ validate (4-agent pipeline)
189
+ 'auto' β†’ complexity score decides which pipeline to use
190
+
191
+ ``target`` (the user's chosen chat model) is in force for every step of the pipeline.
192
+ """
193
+ chosen = target_mod.from_request(req.target)
194
+ # Today's date (and any web sources HomePilot found) reach every step's system prompt.
195
+ with target_mod.using(chosen), grounding.using(req.client_timezone, req.context):
196
+ return await _expert_chat(req, chosen)
197
+
198
+
199
+ async def _expert_chat(req: ExpertChatRequest, chosen) -> ExpertChatResponse:
200
+ complexity = _compute_request_complexity(req)
201
+ provider = _provider_for(req, chosen)
202
+ started = time.perf_counter()
203
+ include_raw = os.getenv("EXPERT_DEBUG", "").lower() in ("1", "true", "yes")
204
+
205
+ resolution = resolve_thinking_mode(req.thinking_mode, complexity)
206
+ mode = resolution.mode_used
207
+ strategy = resolution.strategy
208
+ route_notices: list[str] = []
209
+ if req.thinking_mode == "auto":
210
+ route_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.")
211
+ budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else ""
212
+ if budget_tier == "low" and mode == "heavy":
213
+ route_notices.append("Budget tier is low; downgraded heavy mode to think.")
214
+ mode = "think"
215
+ strategy = "expert-thinking"
216
+
217
+ # The request's tools (the project's documents among them), what runs first, and the
218
+ # effort this message earns inside the mode (policies.effort_for).
219
+ registry = _request_tools(req)
220
+ forced = _forced_tools(req, registry)
221
+ effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced))
222
+ temperature = req.temperature if req.temperature is not None else effort.temperature
223
+ max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens
224
+ system_prompt = project_system_prompt(req.system_prompt, _project(req))
225
+ direct = effort.level == "direct"
226
+ if direct and mode != "fast":
227
+ strategy = "direct"
228
+
229
+ # Compaction: the turns the window keeps go verbatim; the older ones ride as a note.
230
+ window, note, compaction_notice = await compaction.prepare(
231
+ [m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=req.model,
232
+ )
233
+ if compaction_notice:
234
+ route_notices.append(compaction_notice)
235
+ run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider)
236
+ if escalated:
237
+ route_notices.append(_escalation_notice(escalated, chosen, req))
238
+
239
+ trace_id = (req.session_id or "expert")[:8]
240
+ history_chars = sum(len(m.content or "") for m in req.history)
241
+ print(
242
+ f"[EXPERT CHAT {trace_id}] start requested_mode={req.thinking_mode!r} "
243
+ f"resolved_mode={mode!r} strategy={strategy!r} provider={provider!r} "
244
+ f"model={req.model!r} target={chosen!r} complexity={complexity} history={len(req.history)} "
245
+ f"history_chars={history_chars} query_chars={len(req.query or '')} "
246
+ f"max_tokens={max_tokens} temperature={temperature}"
247
+ )
248
+
249
+ try:
250
+ # ── heavy pipeline ────────────────────────────────────────────────────
251
+ if mode == "heavy" and not direct:
252
+ with target_mod.using(run_target), compaction.using(note):
253
+ result = await heavy(
254
+ req.query, provider,
255
+ history=window, system_prompt=system_prompt, forced_tools=forced,
256
+ research=effort.research, registry=registry, tool_rounds=effort.tool_rounds,
257
+ model=req.model, temperature=temperature, max_tokens=max_tokens,
258
+ )
259
+ print(
260
+ f"[EXPERT CHAT {trace_id}] done mode=heavy provider={result.get('provider', provider)!r} "
261
+ f"elapsed_ms={int((time.perf_counter() - started) * 1000)} "
262
+ f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))} "
263
+ f"verdict={result.get('verdict')!r} escalated={escalated!r}"
264
+ )
265
+ return ExpertChatResponse(
266
+ content=result["final_answer"],
267
+ provider_used=result.get("provider", provider),
268
+ model_used=_model_label(run_target, req.model),
269
+ complexity_score=complexity,
270
+ thinking_mode_used="heavy",
271
+ steps=result.get("agents"),
272
+ strategy_used=strategy,
273
+ fallback_applied=bool(result.get("fallback_applied", False)),
274
+ notices=[*route_notices, *list(result.get("notices", []))],
275
+ latency_ms=int((time.perf_counter() - started) * 1000),
276
+ tool_trace=list(result.get("tool_trace", [])),
277
+ sources=list(result.get("sources", [])),
278
+ verdict=result.get("verdict"),
279
+ verdict_detail=result.get("verdict_detail"),
280
+ model_escalated=escalated,
281
+ effort=effort.as_dict(),
282
+ )
283
+
284
+ # ── think pipeline ────────────────────────────────────────────────────
285
+ if mode == "think" and not direct:
286
+ with compaction.using(note):
287
+ result = await think(
288
+ req.query, provider,
289
+ history=window, system_prompt=system_prompt, forced_tools=forced,
290
+ plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds,
291
+ model=req.model, temperature=temperature, max_tokens=max_tokens,
292
+ with_critique=req.with_critique,
293
+ )
294
+ print(
295
+ f"[EXPERT CHAT {trace_id}] done mode=think provider={result.get('provider', provider)!r} "
296
+ f"elapsed_ms={int((time.perf_counter() - started) * 1000)} "
297
+ f"answer_len={len(result.get('final_answer') or '')} fallback={bool(result.get('fallback_applied', False))}"
298
+ )
299
+ return ExpertChatResponse(
300
+ content=result["final_answer"],
301
+ provider_used=result.get("provider", provider),
302
+ model_used=result.get("model") or _model_label(chosen, req.model),
303
+ complexity_score=complexity,
304
+ thinking_mode_used="think",
305
+ steps=result.get("steps"),
306
+ strategy_used=strategy,
307
+ fallback_applied=bool(result.get("fallback_applied", False)),
308
+ notices=[*route_notices, *list(result.get("notices", []))],
309
+ latency_ms=int((time.perf_counter() - started) * 1000),
310
+ tool_trace=list(result.get("tool_trace", [])),
311
+ sources=list(result.get("sources", [])),
312
+ effort=effort.as_dict(),
313
+ )
314
+
315
+ # ── one generation with the per-turn tool loop: Fast, and a trivial message in any mode ──
316
+ with compaction.using(note):
317
+ messages = build_messages(req.query, history=window, system_prompt=system_prompt)
318
+ result = await run_with_tools(
319
+ messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens,
320
+ call=dispatch, forced=forced, registry=registry, max_rounds=effort.tool_rounds,
321
+ )
322
+ content = result.get("content") or ""
323
+ notices = [*route_notices, *list(result.get("notices", []))]
324
+
325
+ print(
326
+ f"[EXPERT CHAT {trace_id}] done mode={mode} effort={effort.level} provider={provider!r} "
327
+ f"model={_model_label(chosen, req.model)!r} elapsed_ms={int((time.perf_counter() - started) * 1000)} "
328
+ f"answer_len={len(content or '')} tools={len(result.get('tool_trace', []))} "
329
+ f"fallback={bool(result.get('fallback_applied', False))}"
330
+ )
331
+ return ExpertChatResponse(
332
+ content=content,
333
+ provider_used=(chosen.label if chosen is not None else provider),
334
+ model_used=_model_label(chosen, req.model),
335
+ complexity_score=complexity,
336
+ thinking_mode_used=mode,
337
+ strategy_used=strategy,
338
+ fallback_applied=bool(result.get("fallback_applied", False)),
339
+ notices=notices,
340
+ latency_ms=int((time.perf_counter() - started) * 1000),
341
+ tool_trace=list(result.get("tool_trace", [])),
342
+ sources=list(result.get("sources", [])),
343
+ effort=effort.as_dict(),
344
+ )
345
+
346
+ except Exception as exc:
347
+ print(f"[EXPERT CHAT {trace_id}] error mode={mode!r} provider={provider!r} error={type(exc).__name__}: {exc}")
348
+ logger.error("Expert chat failed (mode=%s): %s", mode, exc, exc_info=True)
349
+ raise HTTPException(status_code=502, detail=f"Expert pipeline error: {exc}")
350
+
351
+
352
+ # ─────────────────────────────────────────────────────────────────────────────
353
+ # POST /v1/expert/stream (Server-Sent Events streaming)
354
+ # ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ @router.post("/stream", summary="Expert chat (streaming SSE)")
357
+ async def expert_stream(req: ExpertStreamRequest) -> StreamingResponse:
358
+ """
359
+ Stream Expert response via SSE. Routes through fast/think/heavy pipeline.
360
+
361
+ Event types:
362
+ event: meta β†’ {provider, complexity, thinking_mode}
363
+ event: step β†’ {step, label, provider} (think/heavy only)
364
+ event: step_end β†’ {step}
365
+ data: <token> β†’ plain text token
366
+ data: [DONE] β†’ end of stream
367
+ event: error β†’ {error: str}
368
+ """
369
+ complexity = _compute_request_complexity(req)
370
+ chosen = target_mod.from_request(req.target)
371
+ provider = _provider_for(req, chosen)
372
+
373
+ resolution = resolve_thinking_mode(req.thinking_mode, complexity)
374
+ mode = resolution.mode_used
375
+ stream_notices: list[str] = []
376
+ if req.thinking_mode == "auto":
377
+ stream_notices.append(f"Auto selected '{mode}' strategy for complexity score {complexity}.")
378
+ budget_tier = str(req.feature_hints.get("budgetTier", "")).lower() if req.feature_hints else ""
379
+ if budget_tier == "low" and mode == "heavy":
380
+ stream_notices.append("Budget tier is low; downgraded heavy mode to think.")
381
+ mode = "think"
382
+ resolution = resolve_thinking_mode(mode, complexity)
383
+
384
+ # The request's tools, what runs first, and the effort this message earns (as in _expert_chat).
385
+ registry = _request_tools(req)
386
+ forced = _forced_tools(req, registry)
387
+ effort = effort_for(mode, complexity, trivial=is_trivial(req.query), forced_tools=bool(forced))
388
+ temperature = req.temperature if req.temperature is not None else effort.temperature
389
+ max_tokens = req.max_tokens if req.max_tokens is not None else effort.max_tokens
390
+ system_prompt = project_system_prompt(req.system_prompt, _project(req))
391
+ direct = effort.level == "direct"
392
+ strategy = "direct" if direct and mode != "fast" else resolution.strategy
393
+
394
+ # Compaction runs inside the stream (below): the reader sees "Compacting the conversation…"
395
+ # while the summary is written, and the summary is written by the chat's own model (the
396
+ # target context is set around the stream, not around this function).
397
+ history_dump = [m.model_dump() for m in req.history]
398
+ compacting_turns = compaction.needs_summary(history_dump, req.session_id)
399
+ run_target, escalated = (chosen, None) if direct else await _escalate_for_heavy(chosen, req, mode, provider)
400
+ if escalated:
401
+ stream_notices.append(_escalation_notice(escalated, chosen, req))
402
+ started = time.perf_counter()
403
+ project = _project(req)
404
+
405
+ def sse(name: str, payload: Dict[str, Any]) -> str:
406
+ return f"event: {name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
407
+
408
+ def tok(text: str) -> str:
409
+ return f"data: {text.replace(chr(10), chr(92) + 'n')}\n\n"
410
+
411
+ async def event_generator():
412
+ summary: Dict[str, Any] = {"tool_trace": [], "sources": [], "verdict": None, "notices": list(stream_notices)}
413
+ final_answer = ""
414
+
415
+ def relay(event: Dict[str, Any]) -> str:
416
+ """A pipeline event β†’ its SSE frame ('' for events the client has no use for)."""
417
+ kind = event["type"]
418
+ if kind == "step_start":
419
+ return sse("step", {
420
+ "step": event["step"], "label": event.get("label", ""),
421
+ "provider": event.get("provider", provider), "role": event.get("role", "work"),
422
+ })
423
+ if kind == "step_end":
424
+ return sse("step_end", {"step": event["step"]})
425
+ if kind == "token":
426
+ return tok(event["content"])
427
+ if kind in ("tool_start", "tool_end"):
428
+ payload = {k: v for k, v in event.items() if k != "type"}
429
+ payload["phase"] = "start" if kind == "tool_start" else "end"
430
+ if kind == "tool_end":
431
+ summary["tool_trace"].append(
432
+ {k: event.get(k) for k in ("id", "name", "label", "ok", "summary", "elapsed_ms", "step")}
433
+ )
434
+ return sse("tool", payload)
435
+ if kind == "sources":
436
+ summary["sources"] = event["sources"]
437
+ return sse("sources", {"sources": event["sources"], "step": event.get("step")})
438
+ if kind == "reset":
439
+ return sse("reset", {"step": event.get("step")})
440
+ if kind == "wait":
441
+ return sse("wait", {k: v for k, v in event.items() if k != "type"})
442
+ if kind == "notice":
443
+ if event["text"] not in summary["notices"]:
444
+ summary["notices"].append(event["text"])
445
+ return sse("notice", {"text": event["text"]})
446
+ if kind == "verdict":
447
+ summary["verdict"] = event["verdict"]
448
+ summary["verdict_detail"] = {"verdict": event["verdict"], "note": event.get("note", ""), **(event.get("detail") or {})}
449
+ return sse("verdict", {"verdict": event["verdict"], "note": event.get("note", ""), "detail": event.get("detail") or {}})
450
+ return ""
451
+
452
+ if compacting_turns:
453
+ yield sse("compacting", {
454
+ "turns": compacting_turns,
455
+ "text": f"Compacting the conversation: summarizing {compacting_turns} earlier "
456
+ f"message{'' if compacting_turns == 1 else 's'}…",
457
+ })
458
+ window, note, compaction_notice = await compaction.prepare(
459
+ history_dump, session_id=req.session_id, provider=provider, model=req.model,
460
+ )
461
+ if compaction_notice:
462
+ stream_notices.append(compaction_notice)
463
+ summary["notices"].append(compaction_notice)
464
+ yield sse("meta", {
465
+ "provider": provider, "complexity": complexity, "thinking_mode": mode,
466
+ "strategy": strategy, "notices": list(stream_notices),
467
+ "model": _model_label(run_target, req.model), "escalated": escalated,
468
+ "compacted": bool(note), "effort": effort.as_dict(),
469
+ "project": {"id": project["id"], "name": project.get("name")} if project else None,
470
+ })
471
+ try:
472
+ with compaction.using(note):
473
+ if mode == "heavy" and not direct:
474
+ with target_mod.using(run_target):
475
+ async for event in stream_heavy(
476
+ req.query, provider, history=window, system_prompt=system_prompt,
477
+ forced_tools=forced, research=effort.research, registry=registry,
478
+ tool_rounds=effort.tool_rounds,
479
+ model=req.model, temperature=temperature, max_tokens=max_tokens,
480
+ ):
481
+ if event["type"] == "final":
482
+ final_answer = event.get("content") or ""
483
+ continue
484
+ frame = relay(event)
485
+ if frame:
486
+ yield frame
487
+ elif mode == "think" and not direct:
488
+ async for event in stream_think(
489
+ req.query, provider, history=window, system_prompt=system_prompt,
490
+ forced_tools=forced, plan=effort.plan, analyze=effort.analyze, registry=registry, tool_rounds=effort.tool_rounds,
491
+ model=req.model, temperature=temperature, max_tokens=max_tokens,
492
+ with_critique=req.with_critique,
493
+ ):
494
+ if event["type"] == "final":
495
+ final_answer = event.get("content") or ""
496
+ continue
497
+ frame = relay(event)
498
+ if frame:
499
+ yield frame
500
+ else:
501
+ # One generation with the tool loop: Fast, and a trivial message in any mode.
502
+ messages = build_messages(req.query, history=window, system_prompt=system_prompt)
503
+ async for event in stream_with_tools(
504
+ messages, provider, model=req.model, temperature=temperature, max_tokens=max_tokens,
505
+ forced=forced, registry=registry, max_rounds=effort.tool_rounds,
506
+ ):
507
+ if event["type"] == "done":
508
+ final_answer = event.get("content") or ""
509
+ summary["tool_trace"] = list(event.get("tool_trace", []))
510
+ summary["sources"] = list(event.get("sources", []))
511
+ continue
512
+ frame = relay(event)
513
+ if frame:
514
+ yield frame
515
+ if mode in ("heavy", "think") and not direct and final_answer:
516
+ # The reply, sent whole: the step tokens above were the work, not the answer.
517
+ yield "event: final_answer\ndata: start\n\n"
518
+ yield tok(final_answer)
519
+ yield sse("done", {
520
+ "content": final_answer, "thinking_mode": mode, "strategy": strategy, "effort": effort.as_dict(),
521
+ "latency_ms": int((time.perf_counter() - started) * 1000), **summary,
522
+ })
523
+ except Exception as exc:
524
+ logger.error("Expert stream error (mode=%s): %s", mode, exc, exc_info=True)
525
+ yield sse("error", {"error": str(exc)})
526
+ yield "data: [DONE]\n\n"
527
+
528
+ async def with_target():
529
+ # Set inside the generator: the response body is iterated after this function has
530
+ # returned, so a context set around the return would already be gone.
531
+ with target_mod.using(chosen), grounding.using(req.client_timezone, req.context):
532
+ async for item in event_generator():
533
+ yield item
534
+
535
+ return StreamingResponse(
536
+ with_heartbeat(with_target()),
537
+ media_type="text/event-stream",
538
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
539
+ )
540
+
541
+
542
+ async def with_heartbeat(source: AsyncIterator[str], every: Optional[float] = None) -> AsyncIterator[str]:
543
+ """``source``'s frames, plus a ``: keep-alive`` comment whenever it is silent for ``every``
544
+ seconds (a long model call before the first token, a slow tool). Proxies and browsers
545
+ then never take a working stream for a dead one. The source runs as its own task, so a
546
+ timeout never cancels it mid-await; when the reader goes away, that task is cancelled
547
+ with the response, which closes the model request behind it."""
548
+ every = config_mod.EXPERT_SSE_HEARTBEAT_S if every is None else every
549
+ if not every or every <= 0:
550
+ async for item in source:
551
+ yield item
552
+ return
553
+ queue: "asyncio.Queue[Any]" = asyncio.Queue()
554
+ finished = object()
555
+
556
+ async def pump() -> None:
557
+ try:
558
+ async for item in source:
559
+ await queue.put(item)
560
+ except asyncio.CancelledError:
561
+ raise
562
+ except Exception as exc: # noqa: BLE001 - re-raised on the reader's side
563
+ await queue.put(exc)
564
+ return
565
+ await queue.put(finished)
566
+
567
+ task = asyncio.create_task(pump())
568
+ try:
569
+ while True:
570
+ try:
571
+ item = await asyncio.wait_for(queue.get(), timeout=every)
572
+ except asyncio.TimeoutError:
573
+ yield ": keep-alive\n\n"
574
+ continue
575
+ if item is finished:
576
+ break
577
+ if isinstance(item, Exception):
578
+ raise item
579
+ yield item
580
+ finally:
581
+ if not task.done():
582
+ task.cancel()
583
+ try:
584
+ await task
585
+ except BaseException: # noqa: BLE001 - the pump's own cancellation
586
+ pass
587
+
588
+
589
+ # ─────────────────────────────────────────────────────────────────────────────
590
+ # Capabilities + Deep research
591
+ # ─────────────────────────────────────────────────────────────────────────────
592
+
593
+ _SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}
594
+
595
+
596
+ @router.get("/usage", summary="Usage meter: each model endpoint's queue, pacing and learned rate limit")
597
+ async def expert_usage() -> dict:
598
+ """Per OpenAI-compatible endpoint this process has called: requests in flight and in line,
599
+ the gap between starts and any cooldown, the learned cap (requests per window before the
600
+ provider rate-limits), and the rolling window's requests, successes and rate limits."""
601
+ from .providers import endpoint_usage
602
+ from .config import EXPERT_PROVIDER_MAX_INFLIGHT, EXPERT_PROVIDER_WAIT_S, EXPERT_PROVIDER_BACKGROUND_WAIT_S
603
+ return {
604
+ "endpoints": endpoint_usage(),
605
+ "policy": {
606
+ "max_in_flight": EXPERT_PROVIDER_MAX_INFLIGHT,
607
+ "interactive_wait_s": EXPERT_PROVIDER_WAIT_S,
608
+ "background_wait_s": EXPERT_PROVIDER_BACKGROUND_WAIT_S,
609
+ },
610
+ }
611
+
612
+
613
+ @router.post("/capabilities", summary="What the connected models can carry (the Deep research gate)")
614
+ async def expert_capabilities(req: CapabilitiesRequest) -> dict:
615
+ """The selected model's tier, the strongest model on the same connection, and whether
616
+ Deep research is offered (capability.py). POST, so a gateway key never rides in a URL."""
617
+ chosen = target_mod.from_request(req.target)
618
+ return await capability.assess(chosen, req.model, run_probe=req.probe)
619
+
620
+
621
+ @router.post("/deep", summary="Start a Deep research run (background job)")
622
+ async def deep_start(req: ExpertChatRequest) -> dict:
623
+ """Plan β†’ sub-agents with tools β†’ cited report, as a background job (deep.py). Refused
624
+ (403) when the connection has no high-end model, unless EXPERT_DEEP_RESEARCH=always."""
625
+ chosen = target_mod.from_request(req.target)
626
+ provider = _provider_for(req, chosen)
627
+ caps = await capability.assess(chosen, req.model)
628
+ gate = caps["deep_research"]
629
+ if not gate["enabled"]:
630
+ raise HTTPException(status_code=403, detail=gate["reason"])
631
+ notices: list = []
632
+ # The model chosen in the chat runs the research too (no silent swap to another model).
633
+ run_target, model = chosen, req.model
634
+ window, note, compaction_notice = await compaction.prepare(
635
+ [m.model_dump() for m in req.history], session_id=req.session_id, provider=provider, model=model,
636
+ )
637
+ if compaction_notice:
638
+ notices.append(compaction_notice)
639
+ registry = _request_tools(req)
640
+ job = deep.start(
641
+ req.query, provider=provider, model=_model_label(run_target, model), history=window, chosen=run_target,
642
+ timezone=req.client_timezone, context=req.context, note=note, notices=notices,
643
+ registry=registry, operator_prompt=project_system_prompt(req.system_prompt, _project(req)),
644
+ session_id=req.session_id,
645
+ )
646
+ return {"job_id": job.id, "status": job.status, "model": job.model, "notices": notices,
647
+ "time_budget_s": EXPERT_DEEP_TIME_BUDGET_S}
648
+
649
+
650
+ @router.get("/deep/{job_id}", summary="Deep research job snapshot")
651
+ async def deep_status(job_id: str) -> dict:
652
+ job = deep.get_job(job_id)
653
+ if job is None:
654
+ raise HTTPException(status_code=404, detail="Unknown Deep research job")
655
+ return job.snapshot()
656
+
657
+
658
+ @router.get("/deep/{job_id}/events", summary="Deep research progress (SSE; replays from `after`, or from the start)")
659
+ async def deep_events(job_id: str, request: Request, after: int = 0) -> StreamingResponse:
660
+ """Every frame carries ``id: <seq>``. A client that reconnects sends ``Last-Event-ID`` (or
661
+ ``?after=``) and gets only what it missed β€” no duplicate steps, no duplicate text."""
662
+ job = deep.get_job(job_id)
663
+ if job is None:
664
+ raise HTTPException(status_code=404, detail="Unknown Deep research job")
665
+ header = request.headers.get("last-event-id")
666
+ if header and header.isdigit():
667
+ after = max(after, int(header))
668
+
669
+ async def gen():
670
+ queue = job.subscribe(after=after)
671
+ try:
672
+ while True:
673
+ try:
674
+ ev = await asyncio.wait_for(queue.get(), timeout=15.0)
675
+ except asyncio.TimeoutError:
676
+ if job.finished_state:
677
+ break
678
+ yield ": keep-alive\n\n"
679
+ continue
680
+ yield f"id: {ev.get('seq', 0)}\nevent: {ev['type']}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
681
+ if ev["type"] in ("done", "error", "cancelled"):
682
+ break
683
+ finally:
684
+ job.unsubscribe(queue)
685
+ yield "data: [DONE]\n\n"
686
+
687
+ return StreamingResponse(gen(), media_type="text/event-stream", headers=_SSE_HEADERS)
688
+
689
+
690
+ @router.delete("/deep/{job_id}", summary="Cancel a Deep research job")
691
+ async def deep_cancel(job_id: str) -> dict:
692
+ job = deep.get_job(job_id)
693
+ if job is None:
694
+ raise HTTPException(status_code=404, detail="Unknown Deep research job")
695
+ job.cancel()
696
+ return {"job_id": job_id, "status": job.status}
697
+
698
+
699
+ # ─────────────────────────────────────────────────────────────────────────────
700
+ # POST /v1/expert/route (debug helper β€” shows routing decision)
701
+ # ─────────────────────────────────────────────────────────────────────────────
702
+
703
+ @router.post("/route", summary="Debug: show provider routing decision")
704
+ async def expert_route(query: str, preferred: str = "auto") -> dict:
705
+ """
706
+ Returns the provider that would be selected for a given query.
707
+ Useful for debugging and tuning routing thresholds.
708
+ """
709
+ complexity = score_complexity(query)
710
+ provider = select_provider(query, preferred=preferred) # type: ignore[arg-type]
711
+ return {
712
+ "query_preview": query[:100],
713
+ "complexity_score": complexity,
714
+ "selected_provider": provider,
715
+ "available_providers": available_expert_providers(),
716
+ "thresholds": {
717
+ "local": f"complexity ≀ {EXPERT_LOCAL_THRESHOLD}",
718
+ "groq": f"complexity ≀ {EXPERT_GROQ_THRESHOLD}",
719
+ "cloud": f"complexity > {EXPERT_GROQ_THRESHOLD}",
720
+ },
721
+ }
722
+
723
+
724
+ @router.post("/ollabridge/chat/completions", summary="OllaBridge-compatible Expert chat")
725
+ async def ollabridge_chat(req: OllabridgeChatRequest) -> dict:
726
+ """
727
+ OpenAI-compatible adapter endpoint so HomePilot can expose Expert through
728
+ OllaBridge cloud persona workflows.
729
+ """
730
+ if req.stream:
731
+ raise HTTPException(status_code=400, detail="stream=true is not supported on this adapter endpoint yet.")
732
+
733
+ if not req.messages:
734
+ raise HTTPException(status_code=422, detail="messages cannot be empty.")
735
+
736
+ user_query = ""
737
+ history = []
738
+ for msg in req.messages:
739
+ d = msg.model_dump()
740
+ if d["role"] == "user":
741
+ user_query = d["content"]
742
+ else:
743
+ history.append(d)
744
+
745
+ if not user_query:
746
+ raise HTTPException(status_code=422, detail="At least one user message is required.")
747
+
748
+ expert_req = ExpertChatRequest(
749
+ query=user_query,
750
+ provider=req.provider,
751
+ thinking_mode=req.thinking_mode,
752
+ history=history,
753
+ model=req.model,
754
+ temperature=req.temperature,
755
+ max_tokens=req.max_tokens,
756
+ )
757
+ expert_resp = await expert_chat(expert_req)
758
+
759
+ return {
760
+ "id": "expert-ollabridge-chat",
761
+ "object": "chat.completion",
762
+ "provider": expert_resp.provider_used,
763
+ "model": expert_resp.model_used or req.model or "expert-auto",
764
+ "choices": [
765
+ {
766
+ "index": 0,
767
+ "message": {"role": "assistant", "content": expert_resp.content},
768
+ "finish_reason": "stop",
769
+ }
770
+ ],
771
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
772
+ }
773
+
774
+
775
+ @router.post("/persona/draft", response_model=PersonaDraftResponse, summary="Create persona draft from Expert")
776
+ async def expert_persona_draft(req: PersonaDraftRequest) -> PersonaDraftResponse:
777
+ """
778
+ Generate a persona draft that can be used by HomePilot persona creation
779
+ flows (including OllaBridge cloud personas).
780
+ """
781
+ constraints = "\\n".join(f"- {c}" for c in req.constraints) if req.constraints else "- Keep safety-first behavior."
782
+ prompt = (
783
+ "Create a high-quality HomePilot persona draft as JSON fields:\\n"
784
+ "name, description, system_prompt, first_message, suggested_tools (array), "
785
+ "memory_policy (object with keys recall, retention_days, pii_redaction).\\n\\n"
786
+ f"Mission: {req.mission}\\n"
787
+ f"Domain: {req.domain}\\n"
788
+ f"Tone: {req.tone}\\n"
789
+ f"Constraints:\\n{constraints}\\n"
790
+ )
791
+
792
+ expert_req = ExpertChatRequest(
793
+ query=prompt,
794
+ provider=req.provider,
795
+ thinking_mode=req.thinking_mode,
796
+ model=req.model,
797
+ temperature=req.temperature,
798
+ max_tokens=req.max_tokens,
799
+ )
800
+ resp = await expert_chat(expert_req)
801
+
802
+ content = resp.content.strip()
803
+ default_name = f"{req.domain.title()} Research Persona"
804
+ default_desc = f"Persona for {req.mission[:120]}"
805
+ return PersonaDraftResponse(
806
+ name=default_name,
807
+ description=default_desc,
808
+ system_prompt=content,
809
+ first_message=f"Hello, I am your {default_name}. I can help with: {req.mission}.",
810
+ suggested_tools=["retrieval", "web_search", "model_compare"],
811
+ memory_policy={"recall": True, "retention_days": 30, "pii_redaction": True},
812
+ provider_used=resp.provider_used,
813
+ thinking_mode_used=resp.thinking_mode_used,
814
+ )
815
+
816
+
817
+ @router.get("/readiness", response_model=ProductionReadinessResponse, summary="Expert production readiness report")
818
+ async def expert_readiness() -> ProductionReadinessResponse:
819
+ """
820
+ Returns a lightweight production-readiness report based on Expert feature
821
+ flags and required runtime configuration.
822
+ """
823
+ report = build_readiness_report()
824
+ return ProductionReadinessResponse(
825
+ ready=report.ready,
826
+ stage=report.stage, # type: ignore[arg-type]
827
+ checks=[ReadinessCheckItem(name=c.name, passed=c.passed, detail=c.detail) for c in report.checks],
828
+ )
829
+
830
+
831
+ @router.get("/mcp/plan", summary="Expert MCP rollout plan and configuration status")
832
+ async def expert_mcp_plan() -> dict:
833
+ gateway = MCPGateway()
834
+ status_by_key = {item.key: item for item in gateway.status()}
835
+ servers = []
836
+
837
+ for spec in ESSENTIAL_MCP_SERVERS:
838
+ status = status_by_key.get(spec.key)
839
+ servers.append(
840
+ {
841
+ "key": spec.key,
842
+ "priority": spec.priority,
843
+ "purpose": spec.purpose,
844
+ "required_apis": spec.required_apis,
845
+ "configured": status.configured if status else False,
846
+ "endpoint": status.endpoint if status else "",
847
+ "forge_tool_id": status.forge_tool_id if status else "",
848
+ "env_key": mcp_key_to_env(spec.key),
849
+ "forge_tool_env_key": mcp_key_to_forge_tool_env(spec.key),
850
+ }
851
+ )
852
+
853
+ return {
854
+ "orchestrator": gateway.orchestrator,
855
+ "context_forge_url": gateway.forge_base_url,
856
+ "tool_mapping": TOOL_MAPPING,
857
+ "servers": servers,
858
+ "minimal_production_set": [
859
+ "mcp-web-search",
860
+ "mcp-doc-retrieval",
861
+ "mcp-memory-store",
862
+ "mcp-safety-policy",
863
+ "mcp-observability",
864
+ ],
865
+ }
expert/backend/app/expert/safety_policy.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List
5
+
6
+
7
+ _BLOCKED_PATTERNS = [
8
+ "how to build a bomb",
9
+ "weaponize",
10
+ "bypass biosafety",
11
+ ]
12
+
13
+
14
+ @dataclass
15
+ class SafetyDecision:
16
+ allowed: bool
17
+ reasons: List[str]
18
+
19
+
20
+ def evaluate_prompt(prompt: str, profile: str = "strict-research") -> SafetyDecision:
21
+ q = prompt.lower()
22
+ reasons: List[str] = []
23
+
24
+ if profile == "off":
25
+ return SafetyDecision(True, reasons)
26
+
27
+ for p in _BLOCKED_PATTERNS:
28
+ if p in q:
29
+ reasons.append(f"Blocked pattern matched: {p}")
30
+
31
+ return SafetyDecision(allowed=not reasons, reasons=reasons)
expert/backend/app/expert/schemas.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/schemas.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Literal, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ExpertMessage(BaseModel):
9
+ """Single turn in conversation history."""
10
+ role: Literal["user", "assistant", "system"]
11
+ content: str
12
+
13
+
14
+ class ExpertTarget(BaseModel):
15
+ """The chat model the user picked in Models / Settings (see ``target.py``)."""
16
+ provider: str = Field(default="", max_length=40, description="'ollama', 'openai_compat', …")
17
+ base_url: Optional[str] = Field(default=None, max_length=500)
18
+ model: Optional[str] = Field(default=None, max_length=300)
19
+ api_key: Optional[str] = Field(default=None, max_length=4096, repr=False)
20
+
21
+
22
+ class ExpertProject(BaseModel):
23
+ """The project a chat runs inside: its instructions reach every step; its documents become
24
+ the ``project_search`` tool when the backend's retrieval is reachable."""
25
+ id: str = Field(..., max_length=120)
26
+ name: Optional[str] = Field(default=None, max_length=200)
27
+ instructions: Optional[str] = Field(default=None, max_length=20000)
28
+ document_count: int = Field(default=0, ge=0)
29
+
30
+
31
+ class ExpertChatRequest(BaseModel):
32
+ """Request body for POST /v1/expert/chat (non-streaming)."""
33
+ query: str = Field(..., min_length=1, description="User's message / question")
34
+ project: Optional[ExpertProject] = Field(default=None, description="The project this chat runs inside.")
35
+ provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = Field(
36
+ default="auto",
37
+ description="Provider to use. 'auto' applies complexity-based routing.",
38
+ )
39
+ thinking_mode: Literal["auto", "fast", "think", "heavy"] = Field(
40
+ default="auto",
41
+ description=(
42
+ "Pipeline mode. "
43
+ "'auto' = complexity-based selection; "
44
+ "'fast' = single LLM call, local model; "
45
+ "'think' = analyze→plan→solve chain (3 LLM calls); "
46
+ "'heavy' = 4-agent research→reason→synthesize→validate pipeline."
47
+ ),
48
+ )
49
+ with_critique: bool = Field(
50
+ default=False,
51
+ description="In 'think' mode, add a self-critique/correction step.",
52
+ )
53
+ history: List[ExpertMessage] = Field(
54
+ default_factory=list,
55
+ description="Previous conversation turns (newest last).",
56
+ )
57
+ model: Optional[str] = Field(
58
+ default=None,
59
+ description="Override the default model for the selected provider.",
60
+ )
61
+ temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
62
+ max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
63
+ system_prompt: Optional[str] = Field(
64
+ default=None,
65
+ description="Override the default Expert system prompt.",
66
+ )
67
+ client_timezone: Optional[str] = Field(
68
+ default=None, max_length=64,
69
+ description="The browser's IANA time zone, so the prompt's 'today' is the user's (grounding.py).",
70
+ )
71
+ context: Optional[str] = Field(
72
+ default=None, max_length=40000,
73
+ description="Grounding for this request β€” e.g. HomePilot's numbered web sources β€” added to every system prompt.",
74
+ )
75
+ session_id: Optional[str] = Field(
76
+ default=None,
77
+ description="Session identifier for memory/trace linkage.",
78
+ )
79
+ has_attachments: bool = Field(
80
+ default=False,
81
+ description="Whether the request includes attachments or external artifacts.",
82
+ )
83
+ feature_hints: Dict[str, Any] = Field(
84
+ default_factory=dict,
85
+ description="Optional non-authoritative hints from UI (e.g. budget tier, tool-use preference).",
86
+ )
87
+ target: Optional[ExpertTarget] = Field(
88
+ default=None,
89
+ description=(
90
+ "The user's chosen chat model. When set, 'local' (and 'auto') answer from it β€” "
91
+ "an Ollama model or an OpenAI-compatible endpoint such as an OllaBridge gateway."
92
+ ),
93
+ )
94
+
95
+
96
+ class ExpertStreamRequest(ExpertChatRequest):
97
+ """Same as ExpertChatRequest β€” exists for clarity in the route signature."""
98
+ pass
99
+
100
+
101
+ class ExpertChatResponse(BaseModel):
102
+ """Response from POST /v1/expert/chat."""
103
+ model_config = {"protected_namespaces": ()}
104
+
105
+ content: str = Field(..., description="The assistant's reply.")
106
+ provider_used: str = Field(..., description="Which provider actually answered.")
107
+ model_used: Optional[str] = Field(default=None)
108
+ complexity_score: int = Field(
109
+ ..., ge=0, le=10,
110
+ description="Complexity score that drove provider selection (0=simple, 10=complex).",
111
+ )
112
+ thinking_mode_used: str = Field(
113
+ default="fast",
114
+ description="Which pipeline actually ran: fast / think / heavy.",
115
+ )
116
+ steps: Optional[Dict[str, Any]] = Field(
117
+ default=None,
118
+ description="Intermediate step outputs from think/heavy pipelines.",
119
+ )
120
+ provider_raw: Optional[Dict[str, Any]] = Field(
121
+ default=None,
122
+ description="Raw provider response for debugging (omitted in production).",
123
+ )
124
+ strategy_used: str = Field(
125
+ default="single-pass",
126
+ description="Resolved execution strategy: single-pass / expert-thinking / heavy-multi-pass.",
127
+ )
128
+ fallback_applied: bool = Field(
129
+ default=False,
130
+ description="True when the request had to fall back from requested provider/pipeline path.",
131
+ )
132
+ notices: List[str] = Field(
133
+ default_factory=list,
134
+ description="User-visible operational notices (fallbacks, policy blocks, etc.).",
135
+ )
136
+ latency_ms: Optional[int] = Field(
137
+ default=None,
138
+ description="Total backend processing latency in milliseconds.",
139
+ )
140
+ tool_trace: List[Dict[str, Any]] = Field(
141
+ default_factory=list,
142
+ description="Tool calls made while answering (name, label, ok, elapsed, source numbers).",
143
+ )
144
+ sources: List[Dict[str, Any]] = Field(
145
+ default_factory=list,
146
+ description="Numbered sources the answer may cite as [n] (n, title, url, domain, snippet).",
147
+ )
148
+ verdict: Optional[str] = Field(
149
+ default=None,
150
+ description="Heavy only: the validator's verdict β€” validated / corrected / needs_review / reviewed.",
151
+ )
152
+ verdict_detail: Optional[Dict[str, Any]] = Field(
153
+ default=None,
154
+ description="Heavy only: issues, method and limitations behind the verdict.",
155
+ )
156
+ model_escalated: Optional[str] = Field(
157
+ default=None,
158
+ description="Heavy only: the stronger model used instead of the selected one, when escalated.",
159
+ )
160
+ effort: Optional[Dict[str, Any]] = Field(
161
+ default=None,
162
+ description="What this request spent: level (direct / standard / full) and why (policies.py).",
163
+ )
164
+
165
+
166
+ class CapabilitiesRequest(BaseModel):
167
+ """Body for POST /v1/expert/capabilities: which connection and model to assess."""
168
+ target: Optional[ExpertTarget] = None
169
+ model: Optional[str] = Field(default=None, max_length=300)
170
+ probe: bool = Field(default=False, description="Run the one-time tool-calling and JSON probes when not cached.")
171
+
172
+
173
+ class ExpertInfoResponse(BaseModel):
174
+ """Response from GET /v1/expert/info β€” lists available providers and routing config."""
175
+ available_providers: List[str]
176
+ default_provider: str
177
+ local_threshold: int
178
+ groq_threshold: int
179
+ local_model: str
180
+ local_fast_model: str
181
+ groq_model: str
182
+ grok_model: str
183
+ gemini_model: str
184
+
185
+
186
+ class OllabridgeChatRequest(BaseModel):
187
+ """OpenAI-compatible request body for OllaBridge Expert adapter endpoint."""
188
+ model: Optional[str] = None
189
+ messages: List[ExpertMessage] = Field(default_factory=list)
190
+ stream: bool = False
191
+ temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
192
+ max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
193
+ provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto"
194
+ thinking_mode: Literal["auto", "fast", "think", "heavy"] = "auto"
195
+
196
+
197
+ class PersonaDraftRequest(BaseModel):
198
+ """Generate a persona draft from Expert for HomePilot persona creation."""
199
+ mission: str = Field(..., min_length=3, description="Primary role and objective of the persona.")
200
+ domain: str = Field(default="general")
201
+ tone: str = Field(default="helpful")
202
+ constraints: List[str] = Field(default_factory=list)
203
+ provider: Literal["auto", "local", "groq", "grok", "gemini", "claude", "openai"] = "auto"
204
+ thinking_mode: Literal["auto", "fast", "think", "heavy"] = "think"
205
+ model: Optional[str] = None
206
+ temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
207
+ max_tokens: Optional[int] = Field(default=None, ge=1, le=8192)
208
+
209
+
210
+ class PersonaDraftResponse(BaseModel):
211
+ name: str
212
+ description: str
213
+ system_prompt: str
214
+ first_message: str
215
+ suggested_tools: List[str] = Field(default_factory=list)
216
+ memory_policy: Dict[str, Any] = Field(default_factory=dict)
217
+ provider_used: str
218
+ thinking_mode_used: str
219
+
220
+
221
+ class ReadinessCheckItem(BaseModel):
222
+ name: str
223
+ passed: bool
224
+ detail: str
225
+
226
+
227
+ class ProductionReadinessResponse(BaseModel):
228
+ ready: bool
229
+ stage: Literal["dev", "preprod", "production"]
230
+ checks: List[ReadinessCheckItem]
expert/backend/app/expert/state_store.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A small durable store for the Expert service (SQLite, one file).
2
+
3
+ What must survive a restart: the capability facts learned about models ("this gateway model
4
+ rejects the tools field"), and Deep research jobs with their event history, so a report is
5
+ not lost and a client can reconnect. Opened per call with a process lock; a handful of rows
6
+ per day, so there is nothing to tune.
7
+
8
+ Location: ``EXPERT_STATE_DB``, else ``<DATA_DIR>/expert_state.sqlite`` (HomePilot's data
9
+ directory), else ``backend/data/expert_state.sqlite`` next to the main database.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import sqlite3
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from .config import EXPERT_STATE_DB
24
+
25
+ logger = logging.getLogger("expert.state")
26
+
27
+ _LOCK = threading.RLock()
28
+ _PATH: Optional[Path] = None
29
+ _READY = False
30
+
31
+
32
+ def _default_path() -> Path:
33
+ if EXPERT_STATE_DB:
34
+ return Path(EXPERT_STATE_DB)
35
+ data_dir = (os.getenv("DATA_DIR") or "").strip()
36
+ if data_dir:
37
+ return Path(data_dir) / "expert_state.sqlite"
38
+ repo = Path(__file__).resolve().parents[4]
39
+ return repo / "backend" / "data" / "expert_state.sqlite"
40
+
41
+
42
+ def configure(path: Optional[os.PathLike] = None) -> Path:
43
+ """Point the store at ``path`` (tests), or at the default. Creates the schema."""
44
+ global _PATH, _READY
45
+ with _LOCK:
46
+ _PATH = Path(path) if path is not None else _default_path()
47
+ _READY = False
48
+ try:
49
+ _PATH.parent.mkdir(parents=True, exist_ok=True)
50
+ _init()
51
+ except Exception as exc: # noqa: BLE001
52
+ fallback = Path(tempfile.gettempdir()) / "homepilot-expert-state.sqlite"
53
+ logger.warning("expert state store at %s unusable (%s); using %s", _PATH, exc, fallback)
54
+ _PATH = fallback
55
+ _init()
56
+ return _PATH
57
+
58
+
59
+ def path() -> Path:
60
+ if _PATH is None:
61
+ configure()
62
+ return _PATH # type: ignore[return-value]
63
+
64
+
65
+ def _connect() -> sqlite3.Connection:
66
+ con = sqlite3.connect(str(path()), timeout=10, check_same_thread=False)
67
+ con.execute("PRAGMA journal_mode=WAL")
68
+ return con
69
+
70
+
71
+ def _init() -> None:
72
+ global _READY
73
+ with sqlite3.connect(str(_PATH), timeout=10) as con:
74
+ con.execute("CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL, expires REAL)")
75
+ con.execute(
76
+ "CREATE TABLE IF NOT EXISTS deep_jobs(id TEXT PRIMARY KEY, created REAL NOT NULL, updated REAL NOT NULL, "
77
+ "status TEXT NOT NULL, snapshot TEXT NOT NULL)"
78
+ )
79
+ con.execute(
80
+ "CREATE TABLE IF NOT EXISTS deep_events(job_id TEXT NOT NULL, seq INTEGER NOT NULL, event TEXT NOT NULL, "
81
+ "PRIMARY KEY(job_id, seq))"
82
+ )
83
+ _READY = True
84
+
85
+
86
+ def _ensure() -> None:
87
+ if not _READY:
88
+ configure(_PATH)
89
+
90
+
91
+ # ── key/value with expiry ────────────────────────────────────────────────────
92
+
93
+ def kv_get(key: str) -> Optional[Dict[str, Any]]:
94
+ with _LOCK:
95
+ _ensure()
96
+ try:
97
+ with _connect() as con:
98
+ row = con.execute("SELECT value, expires FROM kv WHERE key = ?", (key,)).fetchone()
99
+ except sqlite3.Error as exc:
100
+ logger.debug("kv_get %s failed: %s", key, exc)
101
+ return None
102
+ if not row:
103
+ return None
104
+ value, expires = row
105
+ if expires is not None and expires < time.time():
106
+ kv_delete(key)
107
+ return None
108
+ try:
109
+ data = json.loads(value)
110
+ return data if isinstance(data, dict) else None
111
+ except json.JSONDecodeError:
112
+ return None
113
+
114
+
115
+ def kv_set(key: str, value: Dict[str, Any], ttl_s: Optional[float] = None) -> None:
116
+ expires = time.time() + ttl_s if ttl_s else None
117
+ with _LOCK:
118
+ _ensure()
119
+ try:
120
+ with _connect() as con:
121
+ con.execute("INSERT OR REPLACE INTO kv(key, value, expires) VALUES (?, ?, ?)",
122
+ (key, json.dumps(value, ensure_ascii=False), expires))
123
+ except sqlite3.Error as exc:
124
+ logger.debug("kv_set %s failed: %s", key, exc)
125
+
126
+
127
+ def kv_delete(key: str) -> None:
128
+ with _LOCK:
129
+ _ensure()
130
+ try:
131
+ with _connect() as con:
132
+ con.execute("DELETE FROM kv WHERE key = ?", (key,))
133
+ except sqlite3.Error:
134
+ pass
135
+
136
+
137
+ # ── Deep research jobs ───────────────────────────────────────────────────────
138
+
139
+ def job_save(job_id: str, status: str, snapshot: Dict[str, Any], created: Optional[float] = None) -> None:
140
+ now = time.time()
141
+ with _LOCK:
142
+ _ensure()
143
+ try:
144
+ with _connect() as con:
145
+ con.execute(
146
+ "INSERT INTO deep_jobs(id, created, updated, status, snapshot) VALUES (?, ?, ?, ?, ?) "
147
+ "ON CONFLICT(id) DO UPDATE SET updated = excluded.updated, status = excluded.status, snapshot = excluded.snapshot",
148
+ (job_id, created or now, now, status, json.dumps(snapshot, ensure_ascii=False)),
149
+ )
150
+ except sqlite3.Error as exc:
151
+ logger.debug("job_save %s failed: %s", job_id, exc)
152
+
153
+
154
+ def job_load(job_id: str) -> Optional[Dict[str, Any]]:
155
+ with _LOCK:
156
+ _ensure()
157
+ try:
158
+ with _connect() as con:
159
+ row = con.execute("SELECT created, updated, status, snapshot FROM deep_jobs WHERE id = ?", (job_id,)).fetchone()
160
+ except sqlite3.Error:
161
+ return None
162
+ if not row:
163
+ return None
164
+ created, updated, status, snapshot = row
165
+ try:
166
+ data = json.loads(snapshot)
167
+ except json.JSONDecodeError:
168
+ data = {}
169
+ return {"created": created, "updated": updated, "status": status, "snapshot": data}
170
+
171
+
172
+ def jobs_unfinished() -> List[str]:
173
+ with _LOCK:
174
+ _ensure()
175
+ try:
176
+ with _connect() as con:
177
+ rows = con.execute(
178
+ "SELECT id FROM deep_jobs WHERE status NOT IN ('done', 'failed', 'cancelled')"
179
+ ).fetchall()
180
+ except sqlite3.Error:
181
+ return []
182
+ return [r[0] for r in rows]
183
+
184
+
185
+ def event_append(job_id: str, seq: int, event: Dict[str, Any]) -> None:
186
+ with _LOCK:
187
+ _ensure()
188
+ try:
189
+ with _connect() as con:
190
+ con.execute("INSERT OR REPLACE INTO deep_events(job_id, seq, event) VALUES (?, ?, ?)",
191
+ (job_id, seq, json.dumps(event, ensure_ascii=False)))
192
+ except sqlite3.Error as exc:
193
+ logger.debug("event_append %s/%s failed: %s", job_id, seq, exc)
194
+
195
+
196
+ def events_after(job_id: str, after: int = 0) -> List[Dict[str, Any]]:
197
+ with _LOCK:
198
+ _ensure()
199
+ try:
200
+ with _connect() as con:
201
+ rows = con.execute(
202
+ "SELECT event FROM deep_events WHERE job_id = ? AND seq > ? ORDER BY seq", (job_id, after)
203
+ ).fetchall()
204
+ except sqlite3.Error:
205
+ return []
206
+ out: List[Dict[str, Any]] = []
207
+ for (raw,) in rows:
208
+ try:
209
+ out.append(json.loads(raw))
210
+ except json.JSONDecodeError:
211
+ continue
212
+ return out
213
+
214
+
215
+ def jobs_prune(older_than_s: float) -> int:
216
+ cutoff = time.time() - older_than_s
217
+ with _LOCK:
218
+ _ensure()
219
+ try:
220
+ with _connect() as con:
221
+ ids = [r[0] for r in con.execute("SELECT id FROM deep_jobs WHERE updated < ?", (cutoff,)).fetchall()]
222
+ for job_id in ids:
223
+ con.execute("DELETE FROM deep_events WHERE job_id = ?", (job_id,))
224
+ con.execute("DELETE FROM deep_jobs WHERE id = ?", (job_id,))
225
+ return len(ids)
226
+ except sqlite3.Error:
227
+ return 0
expert/backend/app/expert/target.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/target.py
2
+ """The chat model the user picked, for the length of one Expert request.
3
+
4
+ Expert used to choose its own model: a request said ``provider: "auto"`` and Expert answered
5
+ from Ollama's default β€” even after the user had connected an OllaBridge gateway in Models and
6
+ picked ``openwebui/internal-gemma4-31b`` for chat. The reasoning modes (Fast, Think, Heavy)
7
+ therefore talked to a different model from the rest of the chat, and when Ollama was busy the
8
+ user saw ``500 Internal Server Error for url 'http://localhost:11434/api/chat'`` for a model
9
+ they never chose.
10
+
11
+ The client now sends the chat selection as ``target`` (provider, base URL, model, key). It is
12
+ held in a context variable rather than threaded through every pipeline step, so Fast, Think,
13
+ Heavy and their streaming forms all use it without each learning a new parameter β€” the steps
14
+ run inside the request's task, which carries the variable with it.
15
+
16
+ ``local`` means "the user's chosen model" whenever a target is set. A target is never replaced
17
+ by a cloud provider behind the user's back: with one set, ``auto`` routes to it rather than to
18
+ Groq or Gemini on a complex question.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from contextlib import contextmanager
23
+ from contextvars import ContextVar
24
+ from dataclasses import dataclass
25
+ from typing import Any, Iterator, Optional
26
+
27
+ #: Providers answered through Ollama's own API; everything else is OpenAI-compatible.
28
+ OLLAMA_PROVIDERS = ("", "ollama")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ChatTarget:
33
+ provider: str
34
+ base_url: str
35
+ model: str
36
+ api_key: str = ""
37
+
38
+ @property
39
+ def is_ollama(self) -> bool:
40
+ return self.provider in OLLAMA_PROVIDERS
41
+
42
+ @property
43
+ def label(self) -> str:
44
+ """What a response reports as the provider that answered."""
45
+ return "local" if self.is_ollama else self.provider
46
+
47
+ def __repr__(self) -> str: # never print the key
48
+ return f"ChatTarget(provider={self.provider!r}, base_url={self.base_url!r}, model={self.model!r})"
49
+
50
+
51
+ _CURRENT: ContextVar[Optional[ChatTarget]] = ContextVar("expert_chat_target", default=None)
52
+
53
+
54
+ def from_request(raw: Any) -> Optional[ChatTarget]:
55
+ """A target from the request's ``target`` field, or ``None`` when it names nothing.
56
+
57
+ An Ollama target with no model and no URL is the same as no target: Expert's own defaults
58
+ already mean "the local Ollama".
59
+ """
60
+ if raw is None:
61
+ return None
62
+ get = raw.get if isinstance(raw, dict) else (lambda k, d=None: getattr(raw, k, d))
63
+ provider = str(get("provider", "") or "").strip().lower()
64
+ base_url = str(get("base_url", "") or "").strip().rstrip("/")
65
+ model = str(get("model", "") or "").strip()
66
+ api_key = str(get("api_key", "") or "").strip()
67
+ if provider in OLLAMA_PROVIDERS and not model and not base_url:
68
+ return None
69
+ if provider in OLLAMA_PROVIDERS and base_url.lower().endswith("/v1"):
70
+ # "Ollama" with an OpenAI-style Base URL (OllaBridge's http://localhost:11435/v1, or
71
+ # Ollama's own /v1): that URL speaks the OpenAI API, not Ollama's β€” sending
72
+ # /api/chat there answered 404 and the chat said "Ollama rejected model …".
73
+ provider = "openai_compat"
74
+ if provider not in OLLAMA_PROVIDERS and not base_url:
75
+ # An OpenAI-compatible provider with nowhere to send it cannot be honoured; Expert's
76
+ # defaults are a better answer than an error about a URL the user never typed.
77
+ return None
78
+ return ChatTarget(provider=provider, base_url=base_url, model=model, api_key=api_key)
79
+
80
+
81
+ def current() -> Optional[ChatTarget]:
82
+ return _CURRENT.get()
83
+
84
+
85
+ @contextmanager
86
+ def using(target: Optional[ChatTarget]) -> Iterator[Optional[ChatTarget]]:
87
+ """Make ``target`` the chat target for everything awaited inside the block."""
88
+ token = _CURRENT.set(target)
89
+ try:
90
+ yield target
91
+ finally:
92
+ _CURRENT.reset(token)
expert/backend/app/expert/thinking.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # expert/thinking.py
2
+ # Multi-step chain-of-thought pipeline for Expert "think" mode.
3
+ #
4
+ # Instead of a single prompt β†’ response, this pipeline runs 3 sequential
5
+ # LLM calls that mirror how a human expert actually solves hard problems:
6
+ #
7
+ # Step 1 β€” ANALYZE: Decompose the problem, identify what's being asked
8
+ # Step 2 β€” PLAN: Create a step-by-step strategy
9
+ # Step 3 β€” SOLVE: Execute the plan into a final answer
10
+ #
11
+ # Each step is streamed individually so the UI can show progress.
12
+ # The pipeline reuses the same dispatch layer β€” provider-agnostic.
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
17
+
18
+ from .config import EXPERT_MAX_TOKENS, EXPERT_TEMPERATURE, EXPERT_SYSTEM_PROMPT
19
+ from .router import (
20
+ dispatch, dispatch_stream, extract_dispatch_meta, step_messages, stream_provider_events, ProviderName,
21
+ )
22
+ from .tool_loop import run_with_tools, stream_with_tools
23
+
24
+ logger = logging.getLogger("expert.thinking")
25
+
26
+ # Standard effort: the analysis is written (and shown), then answered from β€” no separate plan.
27
+ _SOLVE_FROM_ANALYSIS_PROMPT = """You are an expert solver. Using the analysis below, give a final, complete answer to the original problem. Speak to the user directly; do not repeat the analysis.
28
+
29
+ Original problem:
30
+ {input}
31
+
32
+ Analysis:
33
+ {analysis}
34
+
35
+ Final answer:"""
36
+
37
+
38
+ def _solve_prompt(query: str, *, plan: bool, plan_text: str, analyze: bool, analysis: str) -> str:
39
+ """The solve step's instruction for the stages that ran before it."""
40
+ if plan:
41
+ return _SOLVE_PROMPT.format(input=query, plan=plan_text)
42
+ if analyze and analysis.strip():
43
+ return _SOLVE_FROM_ANALYSIS_PROMPT.format(input=query, analysis=analysis.strip())
44
+ return _SOLVE_DIRECT_PROMPT.format(input=query)
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+ # Step prompts
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ _ANALYZE_PROMPT = """\
51
+ You are an analytical reasoning engine. Your job is ONLY to decompose and analyze the following problem.
52
+
53
+ Do NOT attempt to solve it yet. Instead:
54
+ 1. Identify what is actually being asked (restate it precisely)
55
+ 2. Identify the domain(s) involved
56
+ 3. Identify what information or reasoning is needed
57
+ 4. Flag any ambiguities or unstated assumptions
58
+
59
+ Problem:
60
+ {input}
61
+
62
+ Analysis:"""
63
+
64
+ _PLAN_PROMPT = """\
65
+ You are a strategic planner. Based on the following analysis, create a clear step-by-step plan to solve the original problem.
66
+
67
+ Each step should be concrete and actionable. Do NOT solve yet β€” only plan.
68
+
69
+ Analysis:
70
+ {analysis}
71
+
72
+ Step-by-step plan:"""
73
+
74
+ _SOLVE_PROMPT = """\
75
+ You are an expert solver. Execute the following plan to produce a final, complete answer to the original problem.
76
+
77
+ Be thorough, accurate, and clear. Show your work where relevant.
78
+
79
+ Original problem: {input}
80
+
81
+ Plan to execute:
82
+ {plan}
83
+
84
+ Final answer:"""
85
+
86
+ _SOLVE_DIRECT_PROMPT = """\
87
+ You are an expert solver. Produce a complete, accurate answer to the following problem; show your work where it helps.
88
+
89
+ Problem: {input}
90
+
91
+ Final answer:"""
92
+
93
+ _CRITIQUE_PROMPT = """\
94
+ You are a rigorous critic. Review the following answer and check for:
95
+ - Factual errors
96
+ - Logical gaps
97
+ - Missing edge cases
98
+ - Clarity issues
99
+
100
+ If the answer is correct and complete, say so briefly. Otherwise, provide a corrected version.
101
+
102
+ Answer to review:
103
+ {solution}
104
+
105
+ Review:"""
106
+
107
+
108
+ # ─────────────────────────────────────────────────────────────────────────────
109
+ # Sync thinking pipeline (returns full result + steps for inspection)
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+
112
+ async def think(
113
+ query: str,
114
+ provider: ProviderName = "auto",
115
+ *,
116
+ history: Optional[List[Dict[str, str]]] = None,
117
+ model: Optional[str] = None,
118
+ temperature: float = EXPERT_TEMPERATURE,
119
+ max_tokens: int = EXPERT_MAX_TOKENS,
120
+ with_critique: bool = False,
121
+ system_prompt: Optional[str] = None,
122
+ forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
123
+ plan: bool = True,
124
+ analyze: bool = False,
125
+ registry: Any = None,
126
+ tool_rounds: Optional[int] = None,
127
+ ) -> Dict[str, Any]:
128
+ """
129
+ Run the analyze β†’ plan β†’ solve pipeline, or solve alone when ``plan`` is False (a request
130
+ below the planning threshold, see policies.effort_for).
131
+ Returns a dict with each step's output plus the final answer.
132
+ ``system_prompt`` (the persona's or the user's instructions) replaces the Expert default;
133
+ ``forced_tools`` run before the solve step asks the model anything; ``registry`` is the
134
+ request's tool set (the shared one when None).
135
+ """
136
+ base = system_prompt or EXPERT_SYSTEM_PROMPT
137
+
138
+ # Each step gets the recent conversation ahead of its instruction (see step_messages).
139
+ def _msg(prompt: str) -> List[Dict[str, str]]:
140
+ return step_messages(prompt, system_prompt=base, history=history)
141
+
142
+ kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
143
+ notices: List[str] = []
144
+ fallback_applied = False
145
+ analysis = plan_text = ""
146
+
147
+ if plan or analyze:
148
+ # Step 1: Analyze β€” the visible reasoning (also alone, at standard effort)
149
+ logger.debug("Thinking step 1: analyze")
150
+ raw_analysis = await dispatch(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw)
151
+ analysis = _extract(raw_analysis)
152
+ notices.extend(_collect_notice(raw_analysis))
153
+ fallback_applied = _fallback(raw_analysis)
154
+
155
+ if plan:
156
+ # Step 2: Plan
157
+ logger.debug("Thinking step 2: plan")
158
+ raw_plan = await dispatch(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw)
159
+ plan_text = _extract(raw_plan)
160
+ notices.extend(_collect_notice(raw_plan))
161
+ fallback_applied = fallback_applied or _fallback(raw_plan)
162
+
163
+ # Step 3: Solve β€” with the per-turn tool loop, so the answer can check facts
164
+ logger.debug("Thinking step 3: solve")
165
+ solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis)
166
+ solved = await run_with_tools(
167
+ _msg(solve_prompt), provider, call=dispatch, forced=forced_tools, registry=registry,
168
+ max_rounds=tool_rounds, **kw
169
+ )
170
+ solution = solved.get("content") or ""
171
+ notices.extend(solved.get("notices", []))
172
+ fallback_applied = fallback_applied or bool(solved.get("fallback_applied"))
173
+
174
+ # Step 4 (optional): Self-critique
175
+ critique = None
176
+ if with_critique:
177
+ logger.debug("Thinking step 4: critique")
178
+ raw_critique = await dispatch(
179
+ _msg(_CRITIQUE_PROMPT.format(solution=solution)), provider, **kw
180
+ )
181
+ critique = _extract(raw_critique)
182
+ notices.extend(_collect_notice(raw_critique))
183
+ fallback_applied = fallback_applied or _fallback(raw_critique)
184
+
185
+ return {
186
+ "final_answer": solution,
187
+ "steps": {
188
+ **({"analysis": analysis} if (plan or analyze) else {}),
189
+ **({"plan": plan_text} if plan else {}),
190
+ "solution": solution,
191
+ **({"critique": critique} if critique else {}),
192
+ },
193
+ "planned": plan,
194
+ "provider": provider,
195
+ "model": model,
196
+ "pipeline": "think",
197
+ "fallback_applied": fallback_applied,
198
+ "notices": _dedupe_notices(notices),
199
+ "tool_trace": solved.get("tool_trace", []),
200
+ "sources": solved.get("sources", []),
201
+ }
202
+
203
+
204
+ # ─────────────────────────────────────────────────────────────────────────────
205
+ # Streaming thinking pipeline β€” yields step labels + tokens
206
+ # ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ async def stream_think(
209
+ query: str,
210
+ provider: ProviderName = "auto",
211
+ *,
212
+ history: Optional[List[Dict[str, str]]] = None,
213
+ model: Optional[str] = None,
214
+ temperature: float = EXPERT_TEMPERATURE,
215
+ max_tokens: int = EXPERT_MAX_TOKENS,
216
+ with_critique: bool = False,
217
+ system_prompt: Optional[str] = None,
218
+ forced_tools: Optional[List[Tuple[str, Dict[str, Any]]]] = None,
219
+ plan: bool = True,
220
+ analyze: bool = False,
221
+ registry: Any = None,
222
+ tool_rounds: Optional[int] = None,
223
+ ) -> AsyncIterator[Dict[str, Any]]:
224
+ """
225
+ Stream the thinking pipeline step by step (solve alone when ``plan`` is False).
226
+ Yields dicts with type='step_start'|'token'|'step_end'|'final'|'done', plus the tool
227
+ loop's events (tool_start, tool_end, sources, reset, notice) tagged with their step.
228
+ A step_start carries role='work' or role='answer' (the step whose text is the reply).
229
+
230
+ The route /v1/expert/stream uses this when thinking_mode='think'.
231
+ The UI can render each step in a collapsible panel.
232
+ """
233
+ base = system_prompt or EXPERT_SYSTEM_PROMPT
234
+
235
+ def _msg(prompt: str) -> List[Dict[str, str]]:
236
+ return step_messages(prompt, system_prompt=base, history=history)
237
+
238
+ kw = dict(model=model, temperature=temperature, max_tokens=max_tokens)
239
+ analysis = plan_text = ""
240
+
241
+ if plan or analyze:
242
+ # ── Step 1: Analyze β€” the visible reasoning, shown as ChatGPT and Claude show theirs
243
+ yield {"type": "step_start", "step": "analyze", "label": "πŸ” Analyzing problem…", "role": "work"}
244
+ analysis_chunks: List[str] = []
245
+ async for chunk in dispatch_stream(_msg(_ANALYZE_PROMPT.format(input=query)), provider, **kw):
246
+ analysis_chunks.append(chunk)
247
+ yield {"type": "token", "step": "analyze", "content": chunk}
248
+ analysis = "".join(analysis_chunks)
249
+ yield {"type": "step_end", "step": "analyze"}
250
+
251
+ if plan:
252
+ # ── Step 2: Plan ─────────────────────────────────────────────────────
253
+ yield {"type": "step_start", "step": "plan", "label": "πŸ“‹ Building plan…", "role": "work"}
254
+ plan_chunks: List[str] = []
255
+ async for chunk in dispatch_stream(_msg(_PLAN_PROMPT.format(analysis=analysis)), provider, **kw):
256
+ plan_chunks.append(chunk)
257
+ yield {"type": "token", "step": "plan", "content": chunk}
258
+ plan_text = "".join(plan_chunks)
259
+ yield {"type": "step_end", "step": "plan"}
260
+
261
+ # ── Step 3: Solve β€” with the per-turn tool loop ─────────────────────────
262
+ yield {"type": "step_start", "step": "solve", "label": "⚑ Solving…" if plan else "⚑ Answering…", "role": "answer"}
263
+ solution_text = ""
264
+ solve_prompt = _solve_prompt(query, plan=plan, plan_text=plan_text, analyze=analyze, analysis=analysis)
265
+ async for ev in stream_with_tools(
266
+ _msg(solve_prompt), provider,
267
+ stream=stream_provider_events, forced=forced_tools, registry=registry, max_rounds=tool_rounds, **kw
268
+ ):
269
+ if ev["type"] == "token":
270
+ yield {"type": "token", "step": "solve", "content": ev["content"]}
271
+ elif ev["type"] == "done":
272
+ solution_text = ev.get("content") or ""
273
+ else:
274
+ yield {**ev, "step": "solve"}
275
+ yield {"type": "step_end", "step": "solve"}
276
+
277
+ # ── Step 4 (optional): Critique ──────────────────────────────────────────
278
+ if with_critique:
279
+ yield {"type": "step_start", "step": "critique", "label": "πŸ”Ž Self-reviewing…", "role": "work"}
280
+ async for chunk in dispatch_stream(
281
+ _msg(_CRITIQUE_PROMPT.format(solution=solution_text)), provider, **kw
282
+ ):
283
+ yield {"type": "token", "step": "critique", "content": chunk}
284
+ yield {"type": "step_end", "step": "critique"}
285
+
286
+ yield {"type": "final", "content": solution_text}
287
+ yield {"type": "done"}
288
+
289
+
290
+ # ─────────────────────────────────────────────────────────────────────────────
291
+ # Helper
292
+ # ─────────────────────────────────────────────────────────────────────────────
293
+
294
+ def _extract(raw: Dict[str, Any]) -> str:
295
+ try:
296
+ return raw["choices"][0]["message"]["content"] or ""
297
+ except (KeyError, IndexError):
298
+ return ""
299
+
300
+
301
+ def _fallback(raw: Dict[str, Any]) -> bool:
302
+ return bool(extract_dispatch_meta(raw).get("fallback_applied"))
303
+
304
+
305
+ def _collect_notice(raw: Dict[str, Any]) -> List[str]:
306
+ notice = extract_dispatch_meta(raw).get("notice")
307
+ return [notice] if isinstance(notice, str) and notice else []
308
+
309
+
310
+ def _dedupe_notices(notices: List[str]) -> List[str]:
311
+ return list(dict.fromkeys(notices))