diff --git a/.env.example b/.env.example
index 5ca5cf1..8d66861 100644
--- a/.env.example
+++ b/.env.example
@@ -12,8 +12,10 @@ GMAPS_API_KEY=
GMAPS_REQUEST_TIMEOUT=900
GMAPS_POLL_SECONDS=2
-# Optional OpenAI-compatible provider for AI analysis and tailored drafts.
-# Keep these empty to use the rest of the app without AI.
+# AI provider for analysis and tailored drafts: nous or custom.
+AI_PROVIDER=custom
+# For Nous, use https://portal.nousresearch.com to create an API key;
+# the app uses https://inference-api.nousresearch.com/v1 automatically.
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
diff --git a/README.md b/README.md
index d5eb17d..5c06968 100644
--- a/README.md
+++ b/README.md
@@ -47,10 +47,29 @@ error rather than remaining stuck forever.
| `GMAPS_REQUEST_TIMEOUT` | `900` | Maximum wait for the Maps worker in seconds; raise it for very large searches |
| `GMAPS_POLL_SECONDS` | `2` | Poll interval while a job is running |
| `DISCOVERY_BACKEND` | `gmaps` when `GMAPS_URL` is set | Set to `browser` only for the bounded public-search fallback |
+| `AI_PROVIDER` | `custom` | Set to `nous` for the Nous Research Portal, or `custom` for another OpenAI-compatible endpoint |
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
| `AI_MODEL` | empty | Model name used for evidence-grounded drafts |
+### Nous Research Portal
+
+Choose **Nous Portal · free models** in Settings, paste a Nous API key, select a
+free model, save, and use **Test connection** before drafting a message. The
+app uses `https://inference-api.nousresearch.com/v1` automatically and tries
+the live `/models` catalog; if the catalog is unavailable it shows a stable
+fallback list of the free models currently published by Nous. Availability and
+model IDs can change, so refresh Settings periodically.
+
+The bundled fallback entries are LongCat 2.0, Solar Pro 4, Step 3.7 Flash,
+Laguna S 2.1, Laguna XS 2.1, Ling 3.0 Flash Fin, and Ling 3.0 Flash Sante
+(using the Portal's `:free` model suffix).
+
+Hermes Agent has its own browser-based OAuth flow, but that flow stores a
+Hermes refresh token and is not a portable credential for this standalone
+Docker app. ProspectOS therefore uses a server-side Nous API key here. The key
+is never returned by the Settings API or sent to the browser after saving.
+
The app stores its catalogue in the `prospect_data` Docker volume. The worker
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
history. To inspect status:
diff --git a/api/server.py b/api/server.py
index 2fd6dcc..e2d1297 100644
--- a/api/server.py
+++ b/api/server.py
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index b74095b..473b630 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,6 +24,7 @@ services:
GMAPS_API_KEY: ${GMAPS_API_KEY:-}
GMAPS_REQUEST_TIMEOUT: ${GMAPS_REQUEST_TIMEOUT:-900}
GMAPS_POLL_SECONDS: ${GMAPS_POLL_SECONDS:-2}
+ AI_PROVIDER: ${AI_PROVIDER:-custom}
AI_BASE_URL: ${AI_BASE_URL:-}
AI_API_KEY: ${AI_API_KEY:-}
AI_MODEL: ${AI_MODEL:-}
diff --git a/tests/test_app.py b/tests/test_app.py
index 4ed86bd..5856816 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -93,6 +93,29 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertTrue(first_call.args[2]["email"])
self.assertEqual(http_bytes.call_args.args[1], "http://gmaps:8080/api/v1/jobs/job-1/download")
+ def test_nous_provider_exposes_models_and_tests_connection(self):
+ free_models = [{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True}]
+ with patch.object(server, "nous_models", return_value=free_models):
+ status, body = self.request("GET", "/api/ai/models?provider=nous")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["models"], free_models)
+ status, body = self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "nous", "AI_MODEL": "meituan/longcat-2.0:free", "AI_API_KEY": "nous-test-key"}})
+ self.assertEqual(status, 200)
+ self.assertEqual(body["ai"]["provider"], "nous")
+ status, body = self.request("GET", "/api/settings")
+ self.assertEqual(status, 200)
+ self.assertTrue(body["ai"]["configured"])
+ status, body = self.request("GET", "/api/config")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["ai_provider"], "nous")
+ self.assertNotIn("nous-test-key", json.dumps(body))
+
+ with patch("api.server.http_json", return_value={"choices": [{"message": {"content": "OK"}}]}) as http_json:
+ status, body = self.request("POST", "/api/ai/test", {})
+ self.assertEqual(status, 200)
+ self.assertTrue(body["ok"])
+ self.assertEqual(http_json.call_args.args[0:2], ("POST", "https://inference-api.nousresearch.com/v1/chat/completions"))
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/app.js b/web/app.js
index 92ec321..674e1ab 100644
--- a/web/app.js
+++ b/web/app.js
@@ -11,13 +11,13 @@ function renderDashboard(){const total=prospects.length,pending=prospects.filter
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`
NEW RUN
Find potential customers
Search Google Maps, deduplicate the catalogue, then enrich each result.
Email collection always on
HOW IT WORKS
Evidence-first pipeline
SearchMaps worker finds public business listings.
EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
ReviewYou decide what is relevant before any contact.
`)}
function renderProspects(){ $('view').innerHTML=layout('Prospects','Search, filter and review your catalogue. Click a row to inspect the evidence.',`
⌕
${prospectTable()}
`)}
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`
Run
Status
Results
Started
Source
${runs.map(r=>`
#${r.id}${esc(r.query)}
${esc(r.status)}${esc(r.stage||'')}
${r.added||0} added / ${r.found||0} found
${date(r.created_at)}
${esc(r.source||'google_maps')}
`).join('')}
${runs.length?'':empty('No discovery runs yet')}`)}
-function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`
SOURCE
Google Maps worker
The bundled standard gosom worker runs locally and does not require an API key.
`)}
+function renderSettings(){const ai=settings.ai||{};const sch=settings.schedules||[];const sup=settings.suppressions||[];const provider=ai.provider||'custom';const modelList=Array.isArray(ai.models)?ai.models:[];const selectedModel=ai.model||'';const options=modelList.map(m=>``).join('')+(selectedModel&&!modelList.some(m=>m.id===selectedModel)?``:'');$('view').innerHTML=layout('Settings','Configure providers, automation and safety controls here. These settings are separate from daily discovery.',`
SOURCE
Google Maps worker
The bundled standard gosom worker runs locally and does not require an API key.
`)}
async function load(){[prospects,runs,settings]=await Promise.all([api('/api/prospects').then(x=>x.items||[]),api('/api/runs').then(x=>x.items||[]),Promise.all([api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]).then(([s,sch,sup])=>({...s,schedules:sch.items||[],suppressions:sup.items||[]}))]);}
async function route(){const r=location.hash.slice(1)||'dashboard';document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,settings:renderSettings}[r]||renderDashboard)();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});}catch(e){$('view').innerHTML=empty(e.message)}}
async function openMessage(id){selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;$('offerInput').value='';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageDialog').showModal()}
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${p.opportunity_score||0}
Opportunity scoreExplainable triage signal, not an AI verdict