add internal Degoog prospect search fallback

This commit is contained in:
Marco0300
2026-09-14 08:57:05 +02:00
parent 43bb639093
commit 16e070df20
5 changed files with 113 additions and 2 deletions
+8
View File
@@ -31,6 +31,14 @@ BROWSER_USE_LLM_TIMEOUT_SECONDS=90
ANONYMIZED_TELEMETRY=false ANONYMIZED_TELEMETRY=false
LOG_RETENTION=20000 LOG_RETENTION=20000
# Optional internal Degoog website-search fallback. Keep disabled until you
# install/review engines in the Degoog settings UI. Degoog is not published
# on a host port; ProspectOS reaches it over the private Compose network.
DEGOOG_ENABLED=false
DEGOOG_URL=http://degoog:4444
DEGOOG_ENGINES=
DEGOOG_SETTINGS_PASSWORD=change-this-before-exposing
# Authentication and workspace ownership. Leave OWNER_* blank to use the # Authentication and workspace ownership. Leave OWNER_* blank to use the
# first-run setup screen; setting them seeds the owner on an unattended start. # first-run setup screen; setting them seeds the owner on an unattended start.
AUTH_REQUIRED=true AUTH_REQUIRED=true
+19 -1
View File
@@ -132,7 +132,25 @@ error rather than remaining stuck forever.
| `AI_API_KEY` | empty | Optional provider key; keep server-side | | `AI_API_KEY` | empty | Optional provider key; keep server-side |
| `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) | | `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) |
### Nous Research Portal ### Degoog website-search fallback
ProspectOS can optionally query an internal Degoog instance when looking for a
possible official website for a Maps prospect. Maps remains the structured
business source; Degoog is only a criteria/name-to-website discovery fallback.
Enable it only after installing and reviewing Degoog engines in its settings:
```bash
DEGOOG_ENABLED=true docker compose up -d
```
Degoog is private to the Compose network and is not published on a host port.
The adapter uses Degoog's JSON `POST /api/search` endpoint, applies the same
bounded result and domain/identity filters as the browser-search path, and
stores `source=degoog` in candidate provenance. Do not install unreviewed
third-party Degoog extensions; the project supports extensions/transports that
may execute server-side code.
Choose **Nous Portal · free models** in Settings, paste a Nous API key, select a 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 free model, save, and use **Test connection** before drafting a message. The
+62
View File
@@ -13,6 +13,8 @@ import json
import os import os
import re import re
import time import time
import urllib.error
import urllib.request
from typing import Any, Callable from typing import Any, Callable
from urllib.parse import parse_qs, quote_plus, unquote, urlparse from urllib.parse import parse_qs, quote_plus, unquote, urlparse
@@ -275,6 +277,63 @@ def _safe_log(log_callback: Callable[[str, str, dict[str, Any]], Any] | None,
return return
def _degoog_search_candidates(
prospect: dict[str, Any],
queries: list[str],
*,
requested: int,
timeout_seconds: int,
log_callback: Callable[[str, str, dict[str, Any]], Any] | None = None,
) -> list[dict[str, Any]]:
"""Use an internal Degoog instance as a bounded public-search source."""
base = os.environ.get("DEGOOG_URL", "").strip().rstrip("/")
enabled = os.environ.get("DEGOOG_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
if not enabled or not base:
return []
engines_env = [item.strip() for item in os.environ.get("DEGOOG_ENGINES", "").split(",") if item.strip()]
found: dict[str, dict[str, Any]] = {}
deadline = time.monotonic() + max(5, min(60, int(timeout_seconds)))
for query in queries:
if time.monotonic() >= deadline:
break
body: dict[str, Any] = {"query": query}
if engines_env:
body["engines"] = engines_env
request = urllib.request.Request(
base + "/api/search",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": os.environ.get("OUTBOUND_USER_AGENT", "ProspectOS/2.0 (+personal-use)")},
method="POST",
)
try:
_safe_log(log_callback, "search", "degoog_search_started", {"query": query, "url": base + "/api/search"})
with urllib.request.urlopen(request, timeout=max(3, min(20, int(deadline - time.monotonic())))) as response:
payload = json.loads(response.read(1_000_000).decode("utf-8", "replace"))
raw_results = payload.get("results", []) if isinstance(payload, dict) else []
if not isinstance(raw_results, list):
continue
for item in raw_results[: requested * 5]:
if not isinstance(item, dict):
continue
href = _normalise_search_href(item.get("url") or item.get("href") or item.get("link"))
if not href:
continue
domain = _candidate_domain(href)
if not domain or domain in found:
continue
title = str(item.get("title") or item.get("name") or "")[:300]
context = str(item.get("content") or item.get("snippet") or item.get("description") or "")[:1200]
score, evidence = _candidate_score(prospect, href, title, context)
if score < 48:
continue
found[domain] = {"url": href, "domain": domain, "confidence": score, "evidence": evidence + ["visible on Degoog search results"], "status": "candidate", "source": "degoog", "search_engine": "degoog", "title": title, "snippet": context, "queries": [query]}
if len(found) >= requested:
return sorted(found.values(), key=lambda item: item["confidence"], reverse=True)[:requested]
except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
_safe_log(log_callback, "search", "degoog_search_error", {"query": query, "error": str(exc)[:300]})
results = sorted(found.values(), key=lambda item: item["confidence"], reverse=True)[:requested]
_safe_log(log_callback, "search", "degoog_search_completed", {"candidate_count": len(results), "candidates": results})
return results
def direct_search_candidates( def direct_search_candidates(
prospect: dict[str, Any], prospect: dict[str, Any],
*, *,
@@ -298,6 +357,9 @@ def direct_search_candidates(
queries = _search_query_variants(prospect) queries = _search_query_variants(prospect)
if not queries: if not queries:
return [] return []
degoog_results = _degoog_search_candidates(prospect, queries, requested=requested, timeout_seconds=min(timeout_seconds, 60), log_callback=log_callback)
if degoog_results:
return degoog_results
try: try:
hard_timeout = max(15, min(180, int(timeout_seconds))) hard_timeout = max(15, min(180, int(timeout_seconds)))
except (TypeError, ValueError): except (TypeError, ValueError):
+12
View File
@@ -1,4 +1,12 @@
services: services:
degoog:
image: ghcr.io/degoog-org/degoog:latest
environment:
DEGOOG_SETTINGS_PASSWORDS: ${DEGOOG_SETTINGS_PASSWORD:-change-this-before-exposing}
volumes:
- degoog_data:/app/data
restart: unless-stopped
gmaps: gmaps:
image: gosom/google-maps-scraper:latest image: gosom/google-maps-scraper:latest
command: ["-web", "-addr", ":8080", "-data-folder", "/gmapsdata"] command: ["-web", "-addr", ":8080", "-data-folder", "/gmapsdata"]
@@ -30,6 +38,9 @@ services:
BROWSER_USE_MAX_STEPS: ${BROWSER_USE_MAX_STEPS:-10} BROWSER_USE_MAX_STEPS: ${BROWSER_USE_MAX_STEPS:-10}
BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-600} BROWSER_USE_TIMEOUT_SECONDS: ${BROWSER_USE_TIMEOUT_SECONDS:-600}
BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90} BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90}
DEGOOG_ENABLED: ${DEGOOG_ENABLED:-false}
DEGOOG_URL: ${DEGOOG_URL:-http://degoog:4444}
DEGOOG_ENGINES: ${DEGOOG_ENGINES:-}
ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false} ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false}
LOG_RETENTION: ${LOG_RETENTION:-20000} LOG_RETENTION: ${LOG_RETENTION:-20000}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true} AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
@@ -60,5 +71,6 @@ services:
retries: 5 retries: 5
volumes: volumes:
degoog_data:
prospect_data: prospect_data:
gmaps_data: gmaps_data:
+12 -1
View File
@@ -100,7 +100,18 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertTrue(first_call.args[2]["email"]) self.assertTrue(first_call.args[2]["email"])
self.assertEqual(http_bytes.call_args.args[1], "http://gmaps:8080/api/v1/jobs/job-1/download") 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): def test_degoog_search_normalises_candidates_and_keeps_provenance(self):
class Response:
def __enter__(self): return self
def __exit__(self, *args): return None
def read(self, _limit=None):
return json.dumps({"results": [{"url": "https://www.acme.co.za/", "title": "Acme Plumbing", "content": "Acme Plumbing Cape Town"}]}).encode()
prospect = {"name": "Acme Plumbing", "address": "Cape Town", "category": "Plumbers", "phone": ""}
with patch.dict(os.environ, {"DEGOOG_ENABLED": "true", "DEGOOG_URL": "http://degoog:4444"}, clear=False), patch("api.browser_investigator.urllib.request.urlopen", return_value=Response()):
results = browser_investigator._degoog_search_candidates(prospect, ['"Acme Plumbing" Cape Town'], requested=3, timeout_seconds=10)
self.assertEqual(results[0]["source"], "degoog")
self.assertEqual(results[0]["domain"], "acme.co.za")
free_models = [{"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True}] 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): with patch.object(server, "nous_models", return_value=free_models):
status, body = self.request("GET", "/api/ai/models?provider=nous") status, body = self.request("GET", "/api/ai/models?provider=nous")