052591281d
- Complete agent system with planning and execution - Bridge API with FastAPI server - WhatsApp-style mobile dashboard - Enhanced web search with query improvement - Security guards and policy checks - Comprehensive documentation
220 lines
6.8 KiB
Python
220 lines
6.8 KiB
Python
"""Ollama adapter — real local LLM via Ollama HTTP API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from urllib.error import URLError
|
|
|
|
from adapters.base import AdapterResult
|
|
|
|
_DIAG_DIR = Path(__file__).resolve().parents[3] / "data" / "diagnostics"
|
|
|
|
|
|
def _resolve_host() -> str:
|
|
"""Normalize OLLAMA_HOST to a full http:// URL.
|
|
|
|
Ollama on Windows sets OLLAMA_HOST=0.0.0.0 (bind address, no scheme).
|
|
We always talk to localhost:11434 for outbound connections.
|
|
"""
|
|
raw = os.environ.get("OLLAMA_HOST", "")
|
|
if not raw or raw in ("0.0.0.0", "[::]"):
|
|
return "http://localhost:11434"
|
|
if raw.startswith("http://") or raw.startswith("https://"):
|
|
return raw
|
|
return f"http://{raw}"
|
|
|
|
|
|
_HOST: str = _resolve_host()
|
|
# Use DECISION_MODEL if set, fall back to OLLAMA_MODEL for backwards compatibility
|
|
_MODEL: str = os.environ.get("DECISION_MODEL") or os.environ.get(
|
|
"OLLAMA_MODEL", "qwen2.5:7b"
|
|
)
|
|
_FALLBACK_MODEL: str = "llama3.2:3b"
|
|
|
|
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
|
|
|
|
def _scan_unicode(text: str, label: str) -> list[dict]:
|
|
"""Scan text for CJK codepoints, return list of findings."""
|
|
findings = []
|
|
for m in _CJK_RE.finditer(text):
|
|
findings.append(
|
|
{
|
|
"pos": m.start(),
|
|
"char": m.group(),
|
|
"codepoint": f"U+{ord(m.group()):04X}",
|
|
"context": text[max(0, m.start() - 20) : m.end() + 20],
|
|
}
|
|
)
|
|
if findings:
|
|
print(f"[CJK DETECTED] in {label}: {len(findings)} occurrence(s)")
|
|
for f in findings:
|
|
print(f" pos={f['pos']} {f['codepoint']} context={f['context']!r}")
|
|
return findings
|
|
|
|
|
|
def _save_diag(trace_id: str, kind: str, payload: dict) -> None:
|
|
"""Save diagnostic JSON to data/diagnostics/."""
|
|
try:
|
|
_DIAG_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = _DIAG_DIR / f"llm_raw_{kind}_{trace_id}.json"
|
|
path.write_text(
|
|
json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
except Exception as exc:
|
|
print(f"[DIAG] save failed: {exc}")
|
|
|
|
|
|
def execute(
|
|
prompt: str,
|
|
model: str | None = None,
|
|
temperature: float = 0.1,
|
|
max_tokens: int = 150,
|
|
timeout: float = 30.0,
|
|
trace_id: str | None = None,
|
|
) -> AdapterResult:
|
|
print("=== THIS ADAPTER IS USED: ollama_adapter ===")
|
|
m = model or _MODEL
|
|
call_id = (
|
|
trace_id or f"notrace-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%f')}"
|
|
)
|
|
|
|
# Hard timeout 60s enforced at request layer
|
|
hard_timeout = 60.0
|
|
|
|
request_payload = {
|
|
"model": m,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": temperature,
|
|
"num_predict": max_tokens,
|
|
},
|
|
}
|
|
|
|
# --- Log + scan RAW REQUEST ---
|
|
print(
|
|
f"[RAW REQUEST] trace_id={call_id} model={m} temp={temperature} max_tokens={max_tokens}"
|
|
)
|
|
print(f"[RAW REQUEST] prompt ({len(prompt)} chars):\n{prompt}")
|
|
req_cjk = _scan_unicode(prompt, "request/prompt")
|
|
_save_diag(
|
|
call_id,
|
|
"request",
|
|
{
|
|
"trace_id": call_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"model": m,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"prompt": prompt,
|
|
"prompt_len": len(prompt),
|
|
"cjk_in_request": req_cjk,
|
|
},
|
|
)
|
|
|
|
encoded = json.dumps(request_payload).encode()
|
|
req = urllib.request.Request(
|
|
f"{_HOST}/api/generate",
|
|
data=encoded,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
|
|
_t = time.time()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=hard_timeout) as resp:
|
|
data = json.loads(resp.read())
|
|
# RAW response text — before ANY processing
|
|
raw_text = data.get("response", "")
|
|
elapsed = time.time() - _t
|
|
tokens_in = data.get("prompt_eval_count", "?")
|
|
tokens_out = data.get("eval_count", "?")
|
|
print(
|
|
f"[TIMING] ollama_http: {elapsed:.3f}s tokens_in={tokens_in} tokens_out={tokens_out}"
|
|
)
|
|
|
|
# --- Log + scan RAW RESPONSE ---
|
|
print(
|
|
f"[RAW RESPONSE] trace_id={call_id} ({len(raw_text)} chars):\n{raw_text}"
|
|
)
|
|
resp_cjk = _scan_unicode(raw_text, "response")
|
|
_save_diag(
|
|
call_id,
|
|
"response",
|
|
{
|
|
"trace_id": call_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"model": m,
|
|
"elapsed_s": round(elapsed, 3),
|
|
"tokens_in": tokens_in,
|
|
"tokens_out": tokens_out,
|
|
"raw_response": raw_text,
|
|
"raw_response_len": len(raw_text),
|
|
"cjk_in_response": resp_cjk,
|
|
"full_ollama_payload": {
|
|
k: v for k, v in data.items() if k != "response"
|
|
},
|
|
},
|
|
)
|
|
|
|
return AdapterResult(
|
|
response=raw_text,
|
|
error=None,
|
|
exit_code=0,
|
|
mode="real",
|
|
metadata={"model": m},
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
elapsed = time.time() - _t
|
|
is_timeout = isinstance(exc, URLError) and "timed out" in str(exc).lower()
|
|
if is_timeout:
|
|
print(f"[OLLAMA TIMEOUT] trace_id={call_id} timeout=60s")
|
|
else:
|
|
print(f"[TIMING] ollama_http: {elapsed:.3f}s [ERROR]")
|
|
_save_diag(
|
|
call_id,
|
|
"response",
|
|
{
|
|
"trace_id": call_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"model": m,
|
|
"elapsed_s": round(elapsed, 3),
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
"timeout": is_timeout,
|
|
"timeout_seconds": 60 if is_timeout else None,
|
|
"raw_response": None,
|
|
"cjk_in_response": [],
|
|
},
|
|
)
|
|
return AdapterResult(
|
|
response=None,
|
|
error=f"{type(exc).__name__}: {exc}",
|
|
exit_code=1,
|
|
mode="real",
|
|
metadata={
|
|
"model": m,
|
|
"timeout": is_timeout,
|
|
"timeout_seconds": 60 if is_timeout else None,
|
|
},
|
|
)
|
|
|
|
|
|
def available() -> bool:
|
|
"""Quick reachability check (used at startup)."""
|
|
try:
|
|
urllib.request.urlopen(f"{_HOST}/api/tags", timeout=3.0)
|
|
return True
|
|
except (URLError, OSError):
|
|
return False
|
|
|
|
|
|
def mode() -> str:
|
|
return "real" if available() else "stub"
|