deploy Nous v14 update

This commit is contained in:
Marco0300
2026-09-05 18:48:39 +02:00
parent 5eb413bb1d
commit 3127a97fb5
8 changed files with 169 additions and 23 deletions
+116 -15
View File
@@ -46,6 +46,19 @@ GMAPS_API_KEY = os.environ.get("GMAPS_API_KEY", "").strip()
AI_BASE_URL = os.environ.get("AI_BASE_URL", "").strip().rstrip("/")
AI_API_KEY = os.environ.get("AI_API_KEY", "").strip()
AI_MODEL = os.environ.get("AI_MODEL", "").strip()
AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower()
NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
NOUS_FREE_MODELS = [
{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True},
{"id": "upstage/solar-pro-4:free", "label": "Upstage · Solar Pro 4", "free": True},
{"id": "stepfun/step-3.7-flash:free", "label": "StepFun · Step 3.7 Flash", "free": True},
{"id": "poolside/laguna-s-2.1:free", "label": "Poolside · Laguna S 2.1", "free": True},
{"id": "poolside/laguna-xs-2.1:free", "label": "Poolside · Laguna XS 2.1", "free": True},
{"id": "inclusionai/ling-3.0-flash-fin:free", "label": "inclusionAI · Ling 3.0 Flash Fin", "free": True},
{"id": "inclusionai/ling-3.0-flash-sante:free", "label": "inclusionAI · Ling 3.0 Flash Sante", "free": True},
]
_nous_models_cache: list[dict[str, Any]] = NOUS_FREE_MODELS.copy()
_nous_models_cached_at = 0.0
ALLOWED_REVIEW = {"pending", "approved", "rejected", "contacted"}
_discover_lock = threading.Lock()
_schema_lock = threading.Lock()
@@ -479,6 +492,55 @@ def http_bytes(method: str, url: str, payload: Any = None, headers: dict[str, st
raise RuntimeError(f"gmaps_unreachable:{exc.reason}") from exc
def ai_runtime_settings() -> dict[str, str]:
saved: dict[str, str] = {}
try:
conn = db()
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
conn.close()
except Exception:
pass
provider = saved.get("AI_PROVIDER", AI_PROVIDER).strip().lower() or "custom"
base = saved.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
if provider == "nous":
base = NOUS_BASE_URL
return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": saved.get("AI_MODEL", AI_MODEL).strip()}
def nous_models() -> list[dict[str, Any]]:
"""Return the live Nous catalog when reachable, with stable free fallbacks."""
global _nous_models_cache, _nous_models_cached_at
# Avoid making every Settings refresh wait on an external catalog request.
# A successful catalog is reused for ten minutes; fallback data is also
# cached after a failed request and can be replaced on the next refresh.
if time.monotonic() - _nous_models_cached_at < 600:
return _nous_models_cache
try:
response = http_json("GET", NOUS_BASE_URL + "/models", timeout=10)
raw = response.get("data", response) if isinstance(response, dict) else response
models: list[dict[str, Any]] = []
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
model_id = str(item.get("id") or "").strip()
if not model_id:
continue
pricing = item.get("pricing") if isinstance(item.get("pricing"), dict) else {}
prices = [str(pricing[k]).strip() for k in ("prompt", "completion", "input", "output") if k in pricing]
free = bool(prices) and all(price in {"0", "0.0", "0.00"} for price in prices)
models.append({"id": model_id, "label": model_id, "free": free, "context_length": item.get("context_length")})
free = [item for item in models if item.get("free")]
if free:
_nous_models_cache = free
_nous_models_cached_at = time.monotonic()
return free
except Exception:
pass
_nous_models_cached_at = time.monotonic()
return _nous_models_cache
def gmaps_headers() -> dict[str, str]:
if GMAPS_API_MODE == "saas":
if not GMAPS_API_KEY:
@@ -810,6 +872,11 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]],
except Exception as exc:
item.update({"website_status": "unknown", "website_checked_at": now(), "website_error": "analysis_failed:" + str(exc)[:240], "email_evidence": item.get("email_evidence", "[]"), "score_reasons": "[]"})
item["opportunity_score"] = score_opportunity(item)[0]
# Release SQLite's read/write transaction before the progress
# callback opens its own connection to update the run row. Without
# this commit, a multi-result enrichment can deadlock forever in
# the website_analysis stage while waiting on the catalogue read.
conn.commit()
if progress:
progress(index, total)
emails = set(normalize_public_emails(item.get("emails")))
@@ -918,14 +985,12 @@ def scheduler_loop() -> None:
def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"""Generate an evidence-grounded draft through an OpenAI-compatible endpoint."""
runtime = {}
try:
c = db(); runtime = {str(r["key"]): str(r["value"]) for r in c.execute("SELECT key,value FROM settings")}; c.close()
except Exception:
pass
ai_base_url = runtime.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/")
ai_api_key = runtime.get("AI_API_KEY", AI_API_KEY).strip()
ai_model = runtime.get("AI_MODEL", AI_MODEL).strip()
runtime = ai_runtime_settings()
ai_base_url = runtime["base_url"]
ai_api_key = runtime["api_key"]
ai_model = runtime["model"]
if runtime["provider"] == "nous" and not ai_api_key:
raise RuntimeError("nous_api_key_required_for_standalone_app")
if not ai_base_url or not ai_model:
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
evidence = {
@@ -942,7 +1007,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str) -> dict[str, Any]:
"The message must be plain text, personalised, transparent, and include a polite opt-out line.")
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000]}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
result = http_json("POST", ai_base_url + "/chat/completions", {"model": ai_model, "temperature": 0.4, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}, headers=headers, timeout=60)
request_payload: dict[str, Any] = {
"model": ai_model,
"temperature": 0.4,
"max_tokens": 700,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
}
# Nous exposes an OpenAI-compatible chat endpoint, but not every free model
# accepts OpenAI's optional response_format parameter. The prompt still
# requires strict JSON and the parser below validates the response.
if runtime["provider"] != "nous":
request_payload["response_format"] = {"type": "json_object"}
result = http_json("POST", ai_base_url + "/chat/completions", request_payload, headers=headers, timeout=90)
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
try:
parsed = json.loads(content)
@@ -987,7 +1063,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/api/config":
auth_required = GMAPS_API_MODE == "saas"
ready = backend_mode() == "browser" or (bool(GMAPS_URL) and (not auth_required or bool(GMAPS_API_KEY)))
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
ai_runtime = ai_runtime_settings()
return self.send_json(200, {
"backend": backend_mode(),
"gmaps_url": bool(GMAPS_URL),
@@ -997,11 +1073,18 @@ class Handler(BaseHTTPRequestHandler):
"ready": ready,
"email_extraction_default": True,
"email_extraction_always": True,
"ai_configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL)),
"ai_provider": ai_runtime["provider"],
"ai_configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])),
})
if parsed.path == "/api/ai/models":
provider = (parse_qs(parsed.query).get("provider", [AI_PROVIDER])[0] or AI_PROVIDER).strip().lower()
if provider == "nous":
return self.send_json(200, {"provider": "nous", "base_url": NOUS_BASE_URL, "models": nous_models()})
return self.send_json(200, {"provider": provider or "custom", "models": []})
if parsed.path == "/api/settings":
saved = {str(r["key"]): str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
ai_runtime = ai_runtime_settings()
models = nous_models() if ai_runtime["provider"] == "nous" else []
return self.send_json(200, {"maps": {"backend": backend_mode(), "worker_url": GMAPS_URL, "ready": backend_mode() == "browser" or bool(GMAPS_URL)}, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, "email_collection": {"always_on": True, "public_only": True}, "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": 8}})
if parsed.path == "/api/prospects":
params = parse_qs(parsed.query)
q = (params.get("q", [""])[0]).strip().lower()
@@ -1084,14 +1167,32 @@ class Handler(BaseHTTPRequestHandler):
try:
payload = self.read_json(); ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {}
conn = db()
for key in ("AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
for key in ("AI_PROVIDER", "AI_BASE_URL", "AI_API_KEY", "AI_MODEL"):
if key in ai:
value = str(ai.get(key) or "").strip()[:500]
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, value, now()))
conn.commit(); saved={str(r["key"]):str(r["value"]) for r in conn.execute("SELECT key,value FROM settings")}; conn.close()
return self.send_json(200, {"saved": True, "ai": {"base_url": saved.get("AI_BASE_URL", AI_BASE_URL), "model": saved.get("AI_MODEL", AI_MODEL), "configured": bool(saved.get("AI_BASE_URL", AI_BASE_URL) and saved.get("AI_MODEL", AI_MODEL))}})
ai_runtime = ai_runtime_settings()
return self.send_json(200, {"saved": True, "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))}})
except Exception as exc:
return self.send_json(400, {"error": str(exc)})
if urlparse(self.path).path == "/api/ai/test":
try:
runtime = ai_runtime_settings()
if not runtime["base_url"] or not runtime["model"]:
return self.send_json(400, {"error": "ai_not_configured_set_provider_and_model"})
if runtime["provider"] == "nous" and not runtime["api_key"]:
return self.send_json(400, {"error": "nous_api_key_required_for_standalone_app"})
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
result = http_json("POST", runtime["base_url"] + "/chat/completions", {"model": runtime["model"], "temperature": 0, "max_tokens": 8, "messages": [{"role": "user", "content": "Reply with exactly OK."}]}, headers=headers, timeout=30)
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
if not str(content).strip():
return self.send_json(502, {"error": "ai_empty_response"})
return self.send_json(200, {"ok": True, "provider": runtime["provider"], "model": runtime["model"], "reply": str(content).strip()[:200]})
except RuntimeError as exc:
return self.send_json(502, {"error": str(exc)})
except Exception as exc:
return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]})
message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path)
if message_match:
conn = None