1 #!/usr/bin/env python3 2 """ 3 SHIRE Brain API Server. 4 5 Laptop-only bridge between ARMOR Pi Core and local Ollama. 6 Binds to the laptop Tailscale IP so no public router port is needed. 7 """ 8 9 import json 10 import os 11 import socket 12 import subprocess 13 import sys 14 import time 15 import urllib.error 16 import urllib.request 17 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 18 from pathlib import Path 19 20 21 ROOT = Path(__file__).resolve().parent.parent 22 if str(ROOT) not in sys.path: 23 sys.path.insert(0, str(ROOT)) 24 25 from codex.prompt_builder import build_prompt 26 from services.blender_brain_contract import ( 27 BlenderBrainContractError, 28 DEEP_MODEL as BLENDER_DEEP_MODEL, 29 FAST_MODEL as BLENDER_FAST_MODEL, 30 prepare_route, 31 ) 32 33 34 OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/") 35 FAST_MODEL = os.environ.get("SHIRE_FAST_MODEL", "shire-fast:qwen3-1.7b") 36 DEEP_MODEL = os.environ.get( 37 "SHIRE_DEEP_MODEL", 38 os.environ.get("SHIRE_BRAIN_MODEL", "shire-brain:qwen3-8b"), 39 ) 40 PORT = int(os.environ.get("SHIRE_BRAIN_PORT", "8765")) 41 HOST_OVERRIDE = os.environ.get("SHIRE_BRAIN_HOST", "").strip() 42 OLLAMA_TIMEOUT = int(os.environ.get("SHIRE_OLLAMA_TIMEOUT", "300")) 43 44 45 def tailscale_ip(): 46 try: 47 result = subprocess.run( 48 ["tailscale", "ip", "-4"], 49 check=False, 50 capture_output=True, 51 text=True, 52 timeout=5, 53 ) 54 for line in result.stdout.splitlines(): 55 ip = line.strip() 56 if ip: 57 return ip 58 except Exception: 59 return "" 60 return "" 61 62 63 def bind_host(): 64 if HOST_OVERRIDE: 65 return HOST_OVERRIDE 66 return tailscale_ip() or "127.0.0.1" 67 68 69 def json_response(handler, payload, status=200): 70 body = json.dumps(payload, indent=2).encode("utf-8") 71 handler.send_response(status) 72 handler.send_header("Content-Type", "application/json") 73 handler.send_header("Content-Length", str(len(body))) 74 handler.end_headers() 75 handler.wfile.write(body) 76 77 78 def choose_model(prompt, mode): 79 mode = (mode or "").strip().lower() 80 clean_prompt = (prompt or "").strip() 81 upper_prompt = clean_prompt.upper() 82 lower_prompt = clean_prompt.lower() 83 84 if mode in {"deep", "slow", "qwen8b"} or upper_prompt.startswith("DEEP "): 85 if upper_prompt.startswith("DEEP "): 86 clean_prompt = clean_prompt[5:].strip() 87 return DEEP_MODEL, "deep", clean_prompt, "manual deep request" 88 89 if mode in {"fast", "quick", "qwen1.7b"} or upper_prompt.startswith("FAST "): 90 if upper_prompt.startswith("FAST "): 91 clean_prompt = clean_prompt[5:].strip() 92 return FAST_MODEL, "fast", clean_prompt, "manual fast request" 93 94 deep_keywords = [ 95 "debug", "traceback", "error", "exception", "fix this", "repair", 96 "architecture", "design a system", "build a system", "engineering", 97 "plan", "strategy", "compare", "analyse", "analyze", "review", 98 "step by step", "full guide", "deep", "complex", "hard", 99 "code", "script", "patch", "refactor", "security", "threat", 100 "legal", "medical", "financial", "mortgage", "ndis", 101 "long answer", "detailed", "explain properly", 102 ] 103 104 if len(clean_prompt) > 220: 105 return DEEP_MODEL, "deep", clean_prompt, "long prompt" 106 107 for keyword in deep_keywords: 108 if keyword in lower_prompt: 109 return DEEP_MODEL, "deep", clean_prompt, f"matched keyword: {keyword}" 110 111 return FAST_MODEL, "fast", clean_prompt, "simple prompt" 112 113 114 def blender_system_context(route): 115 """Build an isolated, non-executing Blender specialist context.""" 116 denied = sorted( 117 name 118 for name, allowed in route["capabilities"].items() 119 if allowed is False 120 ) 121 122 return ( 123 "SHIRE BLENDER SPECIALIST ROUTE — DRAFT ONLY\n" 124 f"Task: {route['task']}\n" 125 f"Project: {route['project_id']}\n" 126 f"Revision: {route['revision']}\n" 127 f"Bound artifact SHA-256: {route['artifact_sha256']}\n" 128 f"Authority effect: {route['authority_effect']}\n" 129 "Denied capabilities: " + ", ".join(denied) + "\n" 130 "Use only the supplied prompt and binding. " 131 "Do not call tools, access files, browse, run commands, run scripts, " 132 "invoke Blender, invoke BlenderMCP, render, export, install models, " 133 "modify project state, grant approval, or claim execution occurred. " 134 "Return only the requested draft content." 135 ) 136 137 138 def resolve_request_route(payload, prompt): 139 """Resolve locked Blender routing or preserve normal SHIRE routing.""" 140 blender_task = str( 141 payload.get("blender_task", "") 142 ).strip().lower() 143 144 if blender_task: 145 route = prepare_route( 146 blender_task, 147 payload.get("project_id", ""), 148 payload.get("revision"), 149 payload.get("artifact_sha256", ""), 150 ) 151 152 model = route["model"] 153 154 if model == BLENDER_FAST_MODEL: 155 mode = "blender_fast" 156 elif model == BLENDER_DEEP_MODEL: 157 mode = "blender_deep" 158 else: 159 raise BlenderBrainContractError( 160 "Blender route selected an unapproved model" 161 ) 162 163 return ( 164 model, 165 mode, 166 str(prompt).strip(), 167 f"locked Blender task: {blender_task}", 168 route["max_tokens"], 169 route, 170 ) 171 172 requested_tokens = int(payload.get("max_tokens", 180)) 173 max_tokens = max(1, min(requested_tokens, 800)) 174 175 model, mode, clean_prompt, route_reason = choose_model( 176 prompt, 177 payload.get("mode", ""), 178 ) 179 180 return ( 181 model, 182 mode, 183 clean_prompt, 184 route_reason, 185 max_tokens, 186 None, 187 ) 188 189 190 191 def guarded_prompt(prompt, mode): 192 clean_prompt = (prompt or "").strip() 193 194 if mode != "deep": 195 return clean_prompt 196 197 guard = """ 198 ARMOR DEEP BRAIN SAFETY GUARD: 199 - If the user asks for debugging or repair but gives no real traceback/log/output/file content, ask for the missing output first. 200 - Do not invent service names, file paths, package names, commands, logs, or results. 201 - Do not suggest sudo install/delete/restart commands unless the supplied evidence supports them. 202 - Prefer one safe diagnostic step. 203 - Keep it short. 204 """ 205 206 return guard.strip() + "\n\nUSER REQUEST:\n" + clean_prompt 207 208 def ask_ollama(model, prompt, max_tokens, system_context=""): 209 messages = [] 210 if system_context: 211 messages.append({"role": "system", "content": system_context}) 212 messages.append({"role": "user", "content": prompt}) 213 214 payload = { 215 "model": model, 216 "think": False, 217 "stream": False, 218 "messages": messages, 219 "options": { 220 "temperature": 0.2, 221 "num_predict": max_tokens, 222 }, 223 } 224 225 request = urllib.request.Request( 226 OLLAMA_URL + "/api/chat", 227 data=json.dumps(payload).encode("utf-8"), 228 headers={"Content-Type": "application/json"}, 229 method="POST", 230 ) 231 232 with urllib.request.urlopen(request, timeout=OLLAMA_TIMEOUT) as response: 233 return json.loads(response.read().decode("utf-8")) 234 235 236 class BrainHandler(BaseHTTPRequestHandler): 237 server_version = "SHIREBrainAPI/0007" 238 239 def log_message(self, fmt, *args): 240 print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), fmt % args)) 241 242 def do_GET(self): 243 if self.path not in {"/", "/health"}: 244 json_response(self, {"ok": False, "error": "Not found"}, status=404) 245 return 246 247 json_response( 248 self, 249 { 250 "ok": True, 251 "service": "SHIRE Brain API", 252 "host": socket.gethostname(), 253 "model": FAST_MODEL, 254 "fast_model": FAST_MODEL, 255 "deep_model": DEEP_MODEL, 256 "blender_fast_model": BLENDER_FAST_MODEL, 257 "blender_deep_model": BLENDER_DEEP_MODEL, 258 "blender_routing_contract": "shire.blender_brain.route.v1", 259 "knowledge_context": "ray-approved registry", 260 "ollama_url": OLLAMA_URL, 261 "message": "SHIRE brain online.", 262 }, 263 ) 264 265 def do_POST(self): 266 if self.path != "/ask": 267 json_response(self, {"ok": False, "error": "Not found"}, status=404) 268 return 269 270 try: 271 length = int(self.headers.get("Content-Length", "0")) 272 raw = self.rfile.read(length).decode("utf-8") 273 payload = json.loads(raw or "{}") 274 275 prompt = str(payload.get("prompt", "")).strip() 276 if not prompt: 277 json_response(self, {"ok": False, "error": "Missing prompt"}, status=400) 278 return 279 280 ( 281 model, 282 mode, 283 clean_prompt, 284 route_reason, 285 max_tokens, 286 blender_route, 287 ) = resolve_request_route(payload, prompt) 288 289 if blender_route is not None: 290 guarded = clean_prompt 291 system_context = blender_system_context(blender_route) 292 else: 293 guarded = guarded_prompt(clean_prompt, mode) 294 system_context = build_prompt(clean_prompt) 295 296 start = time.time() 297 ollama_data = ask_ollama( 298 model, 299 guarded, 300 max_tokens, 301 system_context=system_context, 302 ) 303 elapsed = round(time.time() - start, 2) 304 305 message = ollama_data.get("message", {}) 306 answer = message.get("content", "").strip() 307 308 json_response( 309 self, 310 { 311 "ok": True, 312 "mode": mode, 313 "model": model, 314 "route_reason": route_reason, 315 "blender_route": blender_route, 316 "elapsed_seconds": elapsed, 317 "answer": answer, 318 }, 319 ) 320 321 except BlenderBrainContractError as exc: 322 json_response( 323 self, 324 { 325 "ok": False, 326 "error": f"Blender routing blocked: {exc}", 327 }, 328 status=400, 329 ) 330 except urllib.error.HTTPError as exc: 331 try: 332 detail = exc.read().decode("utf-8", errors="replace") 333 except Exception: 334 detail = str(exc) 335 json_response( 336 self, 337 { 338 "ok": False, 339 "error": f"Ollama HTTP error: {exc.code}", 340 "detail": detail, 341 }, 342 status=500, 343 ) 344 except Exception as exc: 345 json_response( 346 self, 347 { 348 "ok": False, 349 "error": str(exc), 350 }, 351 status=500, 352 ) 353 354 355 def main(): 356 host = bind_host() 357 server = ThreadingHTTPServer((host, PORT), BrainHandler) 358 print(f"SHIRE Brain API listening on http://{host}:{PORT}") 359 print(f"Fast model: {FAST_MODEL}") 360 print(f"Deep model: {DEEP_MODEL}") 361 print(f"Ollama URL: {OLLAMA_URL}") 362 server.serve_forever() 363 364 365 if __name__ == "__main__": 366 main()