This commit is contained in:
+10
-5
@@ -9,7 +9,7 @@ from urllib.request import Request, urlopen
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, normalize_domain, normalize_phone, match_businesses
|
||||
from app.sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open
|
||||
from app.sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open, DISCOVERY_CRITERIA_FIELDS
|
||||
from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
|
||||
from app.website_scanner import scan_website, validate_url
|
||||
from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS
|
||||
@@ -22,7 +22,7 @@ if __package__ in (None, ""):
|
||||
from app.provider_config import validate_payload as validate_remote_provider, encrypt as encrypt_provider_secret, decrypt as decrypt_provider_secret, safe_status as remote_provider_status, test_connectivity as test_remote_connectivity
|
||||
else:
|
||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, normalize_domain, normalize_phone, match_businesses
|
||||
from .sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open
|
||||
from .sources import adapter_for, contains_secret, available_adapters, normalize_record, circuit_is_open, DISCOVERY_CRITERIA_FIELDS
|
||||
from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains
|
||||
from .website_scanner import scan_website, validate_url
|
||||
from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS
|
||||
@@ -1446,6 +1446,7 @@ class ApiHandler(BaseHTTPRequestHandler):
|
||||
source_code=str(config.get('provider','')).strip().lower() if requested == 'approved_directory' and str(config.get('provider','')).strip().lower() in {'openstreetmap','wikidata','common_crawl'} else requested
|
||||
if not name or requested not in ('csv','manual','google_places','bing_local','approved_directory','openstreetmap','wikidata','common_crawl','public_website','permitted_social','ct_logs','dns','rdap') or not isinstance(config,dict):return self.send_json(400,{"error":"invalid_source"})
|
||||
if contains_secret(config):return self.send_json(400,{"error":"secret_not_permitted"})
|
||||
if any(field in config for field in DISCOVERY_CRITERIA_FIELDS):return self.send_json(400,{"error":"source_configuration_contains_criteria"})
|
||||
try:
|
||||
validation=adapter_for(source_code).validate_config(config)
|
||||
# Registration may precede the actual local payload or optional provider
|
||||
@@ -1660,15 +1661,19 @@ def _run_source_discovery(db, job, handler):
|
||||
handler.add_job_event(db,job["id"],org,"source.started",f"Starting {source['display_name'] or source['kind']}",5)
|
||||
try:
|
||||
config=json.loads(source["config_json"] or "{}")
|
||||
if query and query["source_id"]==source["id"]:
|
||||
config.update(json.loads(query["query_json"] or "{}"))
|
||||
quota=json.loads(source["quota_json"] or "{}")
|
||||
daily_limit=int(quota.get("daily_limit", payload.get("daily_limit", 100000)))
|
||||
per_run_limit=int(quota.get("per_run_limit", max_records))
|
||||
used_today=db.execute("SELECT COUNT(*) FROM source_records WHERE organization_id=? AND source_id=? AND date(created_at)=date('now')",(org,source["id"])).fetchone()[0]
|
||||
if used_today >= daily_limit or per_run_limit <= 0:
|
||||
handler.add_job_event(db,job["id"],org,"source.quota_exceeded",f"Quota reached for {source['kind']}",10,"SOURCE_QUOTA_EXCEEDED"); continue
|
||||
page=adapter_for(source["source_code"] or source["kind"]).discover(source_config_with_credentials(db, source, config))
|
||||
criteria = json.loads(query["query_json"] or "{}") if query else payload.get("criteria", {})
|
||||
limits = {
|
||||
"max_records": max_records,
|
||||
"daily_limit": daily_limit,
|
||||
"per_run_limit": per_run_limit,
|
||||
}
|
||||
page=adapter_for(source["source_code"] or source["kind"]).discover(source_config_with_credentials(db, source, config), criteria=criteria, limits=limits)
|
||||
except Exception as exc:
|
||||
blocked_count+=1; failures=int(source["consecutive_failures"])+1
|
||||
db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=?,circuit_open=?,last_failure_at=CURRENT_TIMESTAMP,last_error=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(failures,int(circuit_is_open(failures)),str(exc)[:300],source["id"]))
|
||||
|
||||
+18
-18
@@ -18,6 +18,7 @@ except ImportError:
|
||||
|
||||
SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "private_key", "credential"}
|
||||
NETWORK_KINDS = {"google_places", "bing_local", "approved_directory", "public_website", "permitted_social", "ct_logs", "dns", "rdap"}
|
||||
DISCOVERY_CRITERIA_FIELDS = {"query", "category", "city", "location", "keywords", "province", "country", "language", "search", "phrase", "industry", "keyword"}
|
||||
|
||||
|
||||
def contains_secret(value: Any, path: str = "") -> str | None:
|
||||
@@ -123,7 +124,7 @@ class _Base:
|
||||
def health_check(self, config):
|
||||
result = self.validate_config(config)
|
||||
return SourceHealth("healthy" if result.valid else "unhealthy", last_error=None if result.valid else "; ".join(result.errors))
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
raise RuntimeError("source_not_configured")
|
||||
@@ -135,7 +136,7 @@ class ManualSource(_Base):
|
||||
if not result.valid: return result
|
||||
if not isinstance(config.get("rows"), list): return ValidationResult(False, ["rows must be a list"])
|
||||
return ValidationResult(True)
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
return DiscoveryPage([normalize_record(row) for row in config["rows"] if isinstance(row, Mapping)], metadata={"adapter":self.source_code})
|
||||
@@ -151,7 +152,7 @@ class CsvSource(_Base):
|
||||
if not reader.fieldnames: return ValidationResult(False, ["CSV header is required"])
|
||||
except csv.Error as exc: return ValidationResult(False, [f"invalid CSV: {exc}"])
|
||||
return ValidationResult(True)
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
reader = csv.DictReader(io.StringIO(config["csv"].replace("\r\n", "\n")))
|
||||
@@ -194,7 +195,7 @@ class PublicWebsiteSource(_Base):
|
||||
except ValueError: return ValidationResult(False, ["unsafe public website URL"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
urls = config.get("urls", config.get("url"))
|
||||
@@ -225,10 +226,10 @@ class CtLogsSource(_HttpJsonSource):
|
||||
return ValidationResult(False, ["domain or query is required"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
query = str(config.get("domain", config.get("query"))).strip()
|
||||
query = str(config.get("domain", config.get("query", ""))).strip()
|
||||
endpoint = "https://crt.sh/?" + urlencode({"q": "%25." + query.lstrip("%.") if not query.startswith("%") else query, "output": "json"})
|
||||
payload, source_url = self._get_json(endpoint)
|
||||
if not isinstance(payload, list): raise ValueError("invalid_ct_response")
|
||||
@@ -254,7 +255,7 @@ class DnsSource(_Base):
|
||||
if not isinstance(domains, list) or not domains or len(domains) > 100: return ValidationResult(False, ["domains must contain 1 to 100 names"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
import socket
|
||||
@@ -280,7 +281,7 @@ class RdapSource(_HttpJsonSource):
|
||||
if not domain or "." not in domain: return ValidationResult(False, ["domain is required"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
domain = str(config["domain"]).strip().lower().rstrip(".")
|
||||
@@ -303,7 +304,7 @@ class GatedSource(_Base):
|
||||
if not isinstance(config.get("rate_limit", 1), (int, float)) or config.get("rate_limit", 1) <= 0:
|
||||
return ValidationResult(False, ["positive rate_limit is required"])
|
||||
return ValidationResult(True)
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
# Network execution is delegated to an explicitly approved provider; never guess.
|
||||
@@ -322,10 +323,9 @@ class ApprovedDirectorySource(GatedSource):
|
||||
if not result.valid:return result
|
||||
provider=str(config.get("provider", "")).strip().lower()
|
||||
if provider not in {"openstreetmap", "wikidata", "common_crawl"}: return ValidationResult(False,["provider must be openstreetmap, wikidata, or common_crawl"])
|
||||
if not str(config.get("query", "")).strip(): return ValidationResult(False,["query is required"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result=self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
provider=str(config["provider"]).lower(); query=str(config["query"]).strip(); limit=max(1,min(100,int(config.get("max_records",50))))
|
||||
@@ -363,7 +363,7 @@ class GooglePlacesSource(GatedSource):
|
||||
if not str(config.get("query", "")).strip(): return ValidationResult(False, ["query is required"])
|
||||
return ValidationResult(True)
|
||||
|
||||
def discover(self, config, cursor=None):
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
result = self.validate_config(config)
|
||||
if not result.valid: raise ValueError(result.errors[0])
|
||||
api_key = str(config.get("_api_key", "")).strip()
|
||||
@@ -383,24 +383,24 @@ class OpenStreetMapSource(ApprovedDirectorySource):
|
||||
kind = source_code = "openstreetmap"
|
||||
display_name = "OpenStreetMap / Overpass"
|
||||
optional = False
|
||||
def discover(self, config, cursor=None):
|
||||
page = super().discover({**dict(config), "provider": "openstreetmap"}, cursor)
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
page = super().discover({**dict(config), "provider": "openstreetmap"}, cursor, criteria=criteria, limits=limits)
|
||||
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
|
||||
|
||||
class WikidataSource(ApprovedDirectorySource):
|
||||
kind = source_code = "wikidata"
|
||||
display_name = "Wikidata"
|
||||
optional = False
|
||||
def discover(self, config, cursor=None):
|
||||
page = super().discover({**dict(config), "provider": "wikidata"}, cursor)
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
page = super().discover({**dict(config), "provider": "wikidata"}, cursor, criteria=criteria, limits=limits)
|
||||
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
|
||||
|
||||
class CommonCrawlSource(ApprovedDirectorySource):
|
||||
kind = source_code = "common_crawl"
|
||||
display_name = "Common Crawl index"
|
||||
optional = False
|
||||
def discover(self, config, cursor=None):
|
||||
page = super().discover({**dict(config), "provider": "common_crawl"}, cursor)
|
||||
def discover(self, config, cursor=None, criteria=None, limits=None):
|
||||
page = super().discover({**dict(config), "provider": "common_crawl"}, cursor, criteria=criteria, limits=limits)
|
||||
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
|
||||
|
||||
def _gated(code, name):
|
||||
|
||||
@@ -81,8 +81,8 @@ class SourceApiTests(unittest.TestCase):
|
||||
self.assertEqual(self.req('POST',f'/api/v1/sources/{sid}/ingest',payload)[0],409)
|
||||
db=sqlite3.connect(self.tmp.name+'/x.db'); self.assertTrue(db.execute("select 1 from audit_log where action='source.disabled'").fetchone()); db.close()
|
||||
def test_queries_enqueue_and_records_are_tenant_scoped(self):
|
||||
_,source=self.req('POST','/api/v1/sources',{'name':'CSV','kind':'csv','enabled':True})
|
||||
_,q=self.req('POST','/api/v1/discovery-queries',{'source_id':source['id'],'name':'q','query':{'csv':'name\nA'}})
|
||||
_,source=self.req('POST','/api/v1/sources',{'name':'CSV','kind':'csv','enabled':True,'config':{'csv':'name\nA'}})
|
||||
_,q=self.req('POST','/api/v1/discovery-queries',{'source_id':source['id'],'name':'q','query':{}})
|
||||
status,job=self.req('POST',f"/api/v1/discovery-queries/{q['id']}/run",{})
|
||||
self.assertEqual(status,202); self.assertEqual(job['type'],'source_discovery')
|
||||
for _ in range(100):
|
||||
@@ -105,12 +105,52 @@ class SourceApiTests(unittest.TestCase):
|
||||
self.assertEqual(self.req('PATCH', f"/api/v1/sources/{website['id']}", {'enabled': True})[0], 409)
|
||||
|
||||
def test_duplicate_source_registration_is_idempotent(self):
|
||||
payload={'name':'OpenStreetMap / Overpass · plumbers','kind':'openstreetmap','config':{'provider':'openstreetmap','query':'plumbers','location':'Cape Town','approved':True,'public_access':True,'terms_accepted':True,'rate_limit':1}}
|
||||
payload={'name':'OpenStreetMap / Overpass · plumbers','kind':'openstreetmap','config':{'provider':'openstreetmap','approved':True,'public_access':True,'terms_accepted':True,'rate_limit':1}}
|
||||
status, created=self.req('POST','/api/v1/sources',payload); self.assertEqual(status,201); self.assertTrue(created['created'])
|
||||
status, reused=self.req('POST','/api/v1/sources',payload); self.assertEqual(status,200); self.assertFalse(reused['created']); self.assertEqual(reused['id'],created['id'])
|
||||
second={**payload,'name':'OpenStreetMap / Overpass · plumbers · Durban','config':{**payload['config'],'location':'Durban'}}
|
||||
second={**payload,'name':'OpenStreetMap / Overpass · plumbers · Durban'}
|
||||
status, other=self.req('POST','/api/v1/sources',second); self.assertEqual(status,201); self.assertTrue(other['created']); self.assertNotEqual(other['id'],created['id'])
|
||||
|
||||
def test_source_configuration_rejects_discovery_criteria(self):
|
||||
forbidden = {'query': 'plumbers', 'category': 'trades', 'city': 'Cape Town', 'location': 'Western Cape'}
|
||||
for field, value in forbidden.items():
|
||||
with self.subTest(field=field):
|
||||
status, body = self.req('POST', '/api/v1/sources', {
|
||||
'name': 'Manual ' + field, 'kind': 'manual',
|
||||
'config': {'rows': [], field: value},
|
||||
})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(body['error'], 'source_configuration_contains_criteria')
|
||||
|
||||
def test_source_worker_passes_query_criteria_and_effective_limits_to_connector(self):
|
||||
status, source = self.req('POST', '/api/v1/sources', {
|
||||
'name': 'Criteria manual', 'kind': 'manual', 'enabled': True,
|
||||
'config': {'rows': [{'name': 'Criteria Acme'}]},
|
||||
})
|
||||
self.assertEqual(status, 201)
|
||||
status, query = self.req('POST', '/api/v1/discovery-queries', {
|
||||
'source_id': source['id'], 'name': 'Cape solar',
|
||||
'query': {'keywords': ['solar'], 'city': 'Cape Town'},
|
||||
'max_records': 7, 'daily_limit': 9,
|
||||
})
|
||||
self.assertEqual(status, 201)
|
||||
observed = []
|
||||
from app.sources import ManualSource
|
||||
original = ManualSource.discover
|
||||
def spy(adapter, config, cursor=None, criteria=None, limits=None):
|
||||
observed.append((criteria, limits))
|
||||
return original(adapter, config, cursor, criteria=criteria, limits=limits)
|
||||
with patch('app.sources.ManualSource.discover', new=spy):
|
||||
status, job = self.req('POST', f"/api/v1/discovery-queries/{query['id']}/run", {})
|
||||
self.assertEqual(status, 202)
|
||||
for _ in range(100):
|
||||
_, current = self.req('GET', f"/api/v1/jobs/{job['id']}")
|
||||
if current['status'] in ('succeeded', 'failed'):
|
||||
break
|
||||
threading.Event().wait(.01)
|
||||
self.assertEqual(current['status'], 'succeeded')
|
||||
self.assertEqual(observed, [({'keywords': ['solar'], 'city': 'Cape Town'}, {'max_records': 7, 'daily_limit': 9, 'per_run_limit': 7})])
|
||||
|
||||
def test_source_configuration_can_be_saved_before_enablement(self):
|
||||
status, source = self.req('POST', '/api/v1/sources', {'name': 'DNS', 'kind': 'dns', 'config': {}})
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
+1
-1
@@ -318,7 +318,7 @@
|
||||
$('directDiscoveryResultStatus').textContent = discoveryLabel(status);
|
||||
const controls = '<div class="discovery-controls"><button class="button ghost compact" type="button" data-run-action="pause" ' + (!['queued','running'].includes(status) ? 'disabled' : '') + '>Ⅱ Pause</button><button class="button ghost compact" type="button" data-run-action="resume" ' + (status !== 'paused' ? 'disabled' : '') + '>▶ Resume</button><button class="button danger compact" type="button" data-run-action="cancel" ' + (!['queued','running','paused'].includes(status) ? 'disabled' : '') + '>Cancel</button><span class="small-label">Pause/resume require the job-control API.</span></div>';
|
||||
const sourceRows = run.sources || run.source_health || [];
|
||||
const health = sourceRows.length ? '<section class="source-health"><div class="subheading"><h4>Source health</h4><span class="small-label">' + sourceRows.length + ' sources</span></div><div class="source-health-grid">' + sourceRows.map(source => '<article><strong>' + esc(source.name || source.source_name || ('Source ' + (source.source_id || ''))) + '</strong><span class="source-health-status ' + esc(source.status || source.health || 'unknown') + '">' + esc(source.status || source.health || 'Unknown') + '</span><small>' + esc(source.result_count ?? source.results ?? 0) + ' results · ' + esc(source.error || 'No errors') + '</small><div class="source-health-actions"><button class="button ghost compact" type="button" data-source-retry="' + esc(source.source_id || source.id || '') + '">Retry</button><button class="button ghost compact" type="button" data-source-circuit="' + esc(source.source_id || source.id || '') + '">Circuit</button></div></article>').join('') + '</div></section>' : '<section class="source-health"><div class="subheading"><h4>Source health</h4></div><p class="muted">Per-source health will appear when the API returns source telemetry.</p></section>';
|
||||
const health = sourceRows.length ? '<section class="source-health"><div class="subheading"><h4>Source health</h4><span class="small-label">' + sourceRows.length + ' sources</span></div><div class="source-health-grid">' + sourceRows.map(source => '<article><strong>' + esc(source.name || source.source_name || ('Source ' + (source.source_id || ''))) + '</strong><span class="source-health-status ' + esc(source.status || source.health || 'unknown') + '">' + esc(source.status || source.health || 'Unknown') + '</span><small>' + esc(source.result_count ?? source.results ?? 0) + ' results · ' + esc(source.error || 'No errors') + '</small></article>').join('') + '</div></section>' : '<section class="source-health"><div class="subheading"><h4>Source health</h4></div><p class="muted">Per-source health will appear when the API returns source telemetry.</p></section>';
|
||||
const log = events.length ? events.map(event => '<li><span class="timeline-dot"></span><div><strong>' + esc(event.message || event.type || 'Run event') + '</strong><small>' + esc(event.created_at || event.timestamp || '') + '</small></div></li>').join('') : '<li class="muted">No persisted events returned yet.</li>';
|
||||
const stats = '<div class="discovery-stat-grid"><div><small>Progress</small><strong>' + progress + '%</strong></div><div><small>Records</small><strong>' + esc(run.result_count ?? candidates.length) + '</strong></div><div><small>Daily limit</small><strong>' + esc(run.daily_limit ?? '—') + '</strong></div><div><small>Errors</small><strong>' + (Array.isArray(errors) ? errors.length : 0) + '</strong></div></div>';
|
||||
const cards = candidates.length ? candidates.slice(0, 50).map(item => '<article class="discovery-candidate"><strong>' + esc(item.name || item.business_name || item.title || 'Unnamed candidate') + '</strong><span>' + esc(item.website || item.url || item.domain || 'No URL returned') + '</span><small>' + esc(item.source_url || item.source || 'Source provenance unavailable') + ' · ' + esc(item.confidence || item.score || 'Confidence unknown') + '</small></article>').join('') : '<p class="muted">No candidates returned by this run.</p>';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"integrity": {
|
||||
"config.js": "sha256-7792697d8640937cb0573d314897e8be96230b39418fc1b8a40b66c8138005df",
|
||||
"app.js": "sha256-fe50db5a4d978922cd761e03c80502b775611e14e2e1118f29147a2de689dddc",
|
||||
"app.js": "sha256-662a5eb000a9ce90bc4a75c4cb5fbfbe097f5ebff5c06afa9452635872a60bf1",
|
||||
"styles.css": "sha256-ba90290ab11e82a6b2639dfd70d1e74502c1cacb2b26cf1db92b45beb67ac03f",
|
||||
"index.html": "sha256-3bd7fda6c92bdb3b65e61ac4457c855c99e5bbd82fcfc26959ce70d93b064aa9",
|
||||
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Source and Discovery Rebuild Plan
|
||||
|
||||
## Audit snapshot
|
||||
|
||||
- **Frontend:** `apps/web/index.html`, `apps/web/app.js`, `apps/web/styles.css`; static same-origin delivery via `apps/web/server.py`.
|
||||
- **Backend:** threaded Python/SQLite HTTP API in `apps/api/app/main.py`; adapters in `apps/api/app/sources.py`; bounded crawler/discovery in `apps/api/app/discovery.py`.
|
||||
- **Persistence:** additive SQLite schema in `apps/api/schema.sql`, with compatibility upgrades in `connect()`.
|
||||
- **Background work:** in-process `source_discovery` and `scoped_discovery` job worker plus schedule worker in `main.py`.
|
||||
- **Existing boundaries to preserve:** tenant filtering, role checks, write-only provider credentials, suppression precedence, deterministic scoring, CRM, evidence, no automated outreach, and same-origin web/API delivery.
|
||||
|
||||
## Acceptance slices
|
||||
|
||||
1. **Reusable source registry**
|
||||
- Separate connector configuration from discovery criteria.
|
||||
- Add safe migration fields for owner, terms, rate/quota, credential status, and connector configuration.
|
||||
- Make duplicate handling identity-based and return the existing registered source visibly.
|
||||
- Keep previews separate from real persisted source IDs.
|
||||
|
||||
2. **Connector contract and safe adapters**
|
||||
- Normalize connector methods around configuration validation, connection tests, criteria-based discovery, health, limits, normalized evidence, and structured errors.
|
||||
- Preserve CSV/manual behavior and repair OSM/Wikidata/Common Crawl/CT/RDAP/DNS/public crawler connectors.
|
||||
- Add a disabled-by-default Google Browser Search connector that reports challenge/blocked states without bypassing controls. Runtime/dependency requirements will be documented and feature-gated.
|
||||
|
||||
3. **Criteria-based discovery runs**
|
||||
- Persist explicit criteria independently from source configuration.
|
||||
- Select only enabled, valid sources; validate limits and dry-runs.
|
||||
- Pass criteria and limits to connectors, retain source-level result/error status, record provenance, deduplicate candidates, and distinguish complete, partial, blocked, and failed outcomes.
|
||||
|
||||
4. **Evidence/enrichment and AI boundaries**
|
||||
- Preserve bounded website/domain/contact checks and provenance.
|
||||
- Keep AI evidence-driven, strict-schema, write-only-provider-configured, suppression-overridden, and review-only.
|
||||
|
||||
5. **Sources and Discovery UI**
|
||||
- Rebuild the registry, source details, source setup, criteria builder, run history, live logs, per-source status, candidate provenance, and actionable empty/error states.
|
||||
- Every action presents loading, success, validation, or API-error feedback; no fake IDs, silent actions, demo health, or fabricated records.
|
||||
|
||||
6. **Verification, documentation, and deployment**
|
||||
- Add migration, source identity, criteria, connector mock, dedupe/provenance, blocked/partial, Google challenge, AI schema, and suppression tests.
|
||||
- Run backend/full/frontend/desktop/Compose checks and live authenticated workflow checks where available.
|
||||
- Update deployment/source architecture documentation, deploy only after data backup and verified live results.
|
||||
|
||||
## Known operational limits
|
||||
|
||||
- SQLite/in-process jobs remain a controlled single-node/pilot architecture.
|
||||
- Public connectors must remain bounded and may return an honest blocked/network/rate-limit result.
|
||||
- Google Browser Search remains disabled unless its reviewed browser runtime is installed and explicitly enabled; it will never bypass CAPTCHA, authentication, or anti-bot controls.
|
||||
Reference in New Issue
Block a user