deploy Nous v14 update
This commit is contained in:
+116
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user