complete source discovery scope
CI / compose (push) Successful in 14m21s

This commit is contained in:
Marco0300
2026-09-04 13:36:40 +02:00
parent f860d04762
commit b532e39f7c
6 changed files with 103 additions and 26 deletions
+69 -17
View File
@@ -5,6 +5,7 @@ from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
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
@@ -109,13 +110,44 @@ def row_json(row):
def source_config_with_credentials(db, source, config):
"""Inject a credential only into the in-process adapter call; never persist/return it."""
if source["kind"] != "google_places": return config
if (source["source_code"] or source["kind"]) != "google_places": return config
row = db.execute("SELECT secret_ref FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (source["id"], source["organization_id"], "google_places", "api_key")).fetchone()
if not row or not row["secret_ref"]: return config
try: config["_api_key"] = decrypt_provider_secret(row["secret_ref"])
except Exception: pass
return config
def resolve_selected_source_codes(db, org, selected):
"""Accept dashboard source IDs or adapter codes and normalize to codes."""
if not isinstance(selected, list): return []
codes=[]
for value in selected:
row = db.execute("SELECT source_code,kind FROM sources WHERE id=? AND organization_id=?", (int(value), org)).fetchone() if str(value).isdigit() else None
code = (row["source_code"] or row["kind"]) if row else str(value).strip().lower()
if code and code not in codes: codes.append(code)
return codes
def enrich_source_business(db, org, bid, website_url, actor):
"""Bounded post-discovery enrichment for source records with a public website."""
if not website_url: return
try: scan = scan_website(website_url, max_pages=1)
except Exception: return
now=datetime.now(timezone.utc).replace(microsecond=0); cache_key=hashlib.sha256(str(website_url).encode()).hexdigest()
website=db.execute("SELECT id FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,website_url)).fetchone()
existing_scan=db.execute("SELECT id FROM website_scans WHERE organization_id=? AND business_id=? AND cache_key=? ORDER BY id DESC LIMIT 1",(org,bid,cache_key)).fetchone()
scan_id=existing_scan["id"] if existing_scan else db.execute("INSERT INTO website_scans(organization_id,business_id,website_id,input_url,classification,result_json,cache_key,scanned_at,cache_expires_at) VALUES(?,?,?,?,?,?,?,?,?)",(org,bid,website["id"] if website else None,website_url,scan.get("classification","unknown"),json.dumps(scan,sort_keys=True),cache_key,now.isoformat(),(now+timedelta(seconds=WEBSITE_SCAN_CACHE_SECONDS)).isoformat())).lastrowid
html=scan.get("html","")
suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1",(org,))]
for item in extract_contacts(html,website_url,suppressions=suppressions,max_results=25):
key=hashlib.sha256((str(bid)+item["source_url"]+item["kind"]+item["value"]).encode()).hexdigest()
db.execute("INSERT OR IGNORE INTO contact_extractions(organization_id,business_id,website_scan_id,extraction_key,kind,value,label,classification,confidence,source_url,public_business,mx_status,suppressed,do_not_contact,provenance) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",(org,bid,scan_id,key,item["kind"],item["value"],item["label"],item["classification"],item["confidence"],item["source_url"],1,item["mx_status"],int(item["suppressed"]),int(item["do_not_contact"]),item["provenance"]))
if item["kind"]=="email": db.execute("UPDATE businesses SET email=CASE WHEN email='' THEN ? ELSE email END WHERE id=? AND organization_id=?",(item["value"],bid,org))
if item["kind"]=="phone": db.execute("UPDATE businesses SET phone=CASE WHEN phone='' THEN ? ELSE phone END WHERE id=? AND organization_id=?",(item["value"],bid,org))
db.execute("UPDATE businesses SET website_class=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(scan.get("classification","unknown"),bid,org))
current=db.execute("SELECT * FROM businesses WHERE id=? AND organization_id=?",(bid,org)).fetchone()
if current:
scored=score_business_opportunity(row_json(current),website=scan,sources=[actor]); db.execute("UPDATE businesses SET score=?,score_version=?,score_factors=? WHERE id=? AND organization_id=?",(scored["score"],scored["score_version"],json.dumps(scored["explanations"]),bid,org))
class ApiHandler(BaseHTTPRequestHandler):
server_version = "ProspectPlatform/0.1"
def send_json(self, status, payload, extra_headers=None):
@@ -869,7 +901,7 @@ class ApiHandler(BaseHTTPRequestHandler):
def create_scoped_discovery(self, payload, db, user):
criteria = payload.get("criteria", {}); seeds = payload.get("seed_urls")
selected_adapters = payload.get("selected_adapters", payload.get("sources", []))
selected_adapters = resolve_selected_source_codes(db, user["organization_id"], payload.get("selected_adapters", payload.get("source_ids", payload.get("sources", []))))
source_mode = bool(selected_adapters) and seeds is None
criteria_only = seeds is None and not source_mode
if not isinstance(criteria, dict): return self.send_json(400, {"error": "invalid_criteria"})
@@ -1367,16 +1399,18 @@ class ApiHandler(BaseHTTPRequestHandler):
out.append(x)
return self.send_json(200,{"organization_id":org,"items":out,"limit":limit,"offset":offset,"has_more":len(rows)>limit})
def create_source(self,payload,db,user):
name=str(payload.get('name','')).strip(); kind=str(payload.get('source_code',payload.get('kind',''))).strip().lower(); config=payload.get('config',{})
optional = kind not in ('csv','manual')
if not name or kind not in ('csv','manual','google_places','bing_local','approved_directory','public_website','permitted_social','ct_logs','dns','rdap') or not isinstance(config,dict):return self.send_json(400,{"error":"invalid_source"})
name=str(payload.get('name','')).strip(); requested=str(payload.get('source_code') or payload.get('kind','')).strip().lower(); config=payload.get('config',{})
provider_kinds={'openstreetmap','wikidata','common_crawl'}
kind='approved_directory' if requested in provider_kinds else requested
source_code=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"})
try:
validation=adapter_for(kind).validate_config(config)
validation=adapter_for(source_code).validate_config(config)
# Registration may precede the actual local payload or optional provider
# approval. Discovery/ingest still validates the effective configuration.
if config and not validation.valid:return self.send_json(400,{"error":"invalid_source_config","details":validation.errors})
cur=db.execute("INSERT INTO sources(organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json) VALUES(?,?,?,?,?,?,?,?,?,?)",(user['organization_id'],name,kind,kind,str(payload.get('display_name') or adapter_for(kind).display_name),int(bool(payload.get('enabled',False))),int(bool(payload.get('approved',config.get('approved',False)))),json.dumps(config,sort_keys=True),json.dumps(payload.get('policy',{}),sort_keys=True),json.dumps(payload.get('quota',{}),sort_keys=True)))
cur=db.execute("INSERT INTO sources(organization_id,name,kind,source_code,display_name,enabled,approved,config_json,policy_json,quota_json) VALUES(?,?,?,?,?,?,?,?,?,?)",(user['organization_id'],name,kind,source_code,str(payload.get('display_name') or adapter_for(source_code).display_name),int(bool(payload.get('enabled',False))),int(bool(payload.get('approved',config.get('approved',False)))),json.dumps(config,sort_keys=True),json.dumps(payload.get('policy',{}),sort_keys=True),json.dumps(payload.get('quota',{}),sort_keys=True)))
except sqlite3.IntegrityError:return self.send_json(409,{"error":"duplicate_source"})
self.audit(db,user,'source.created',str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT id,organization_id,name,kind,source_code,display_name,enabled,approved,policy_json,quota_json,health_status,consecutive_failures,circuit_open,last_success_at,last_failure_at,last_error,created_at,updated_at FROM sources WHERE id=?",(cur.lastrowid,)).fetchone()))
def update_source(self,sid,payload,db,user):
@@ -1385,7 +1419,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if 'config' in payload:
config=payload.get('config')
if not isinstance(config,dict) or contains_secret(config): return self.send_json(400,{"error":"invalid_source_config"})
adapter=adapter_for(source['kind']); validation=adapter.validate_config(config)
adapter=adapter_for(source['source_code'] or source['kind']); validation=adapter.validate_config(config)
if not validation.valid: return self.send_json(400,{"error":"invalid_source_config","details":validation.errors})
db.execute("UPDATE sources SET config_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(json.dumps(config,sort_keys=True),sid,user['organization_id']))
self.audit(db,user,'source.configured',str(sid)); db.commit()
@@ -1393,7 +1427,7 @@ class ApiHandler(BaseHTTPRequestHandler):
row=db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone(); return self.send_json(200,row_json(row))
value=int(bool(payload['enabled']))
if value:
adapter=adapter_for(source['kind'])
adapter=adapter_for(source['source_code'] or source['kind'])
try: config=json.loads(source['config_json'] or '{}')
except (TypeError,ValueError): config={}
metadata=next((item for item in available_adapters() if item['source_code']==source['kind']), {})
@@ -1404,12 +1438,12 @@ class ApiHandler(BaseHTTPRequestHandler):
# before they are enabled.
if not validation.valid and not (source['kind'] == 'manual' and not config):
return self.send_json(409,{"error":"source_not_configured","details":validation.errors})
if source['kind'] == 'google_places' and not db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (sid,user['organization_id'],'google_places','api_key')).fetchone():
if source['source_code'] == 'google_places' and not db.execute("SELECT 1 FROM source_credentials WHERE source_id=? AND organization_id=? AND provider=? AND key_name=?", (sid,user['organization_id'],'google_places','api_key')).fetchone():
return self.send_json(409,{"error":"source_not_configured","details":["Google Places API key is required"]})
db.execute("UPDATE sources SET enabled=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(value,sid));self.audit(db,user,'source.enabled' if value else 'source.disabled',str(sid));db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM sources WHERE id=?",(sid,)).fetchone()))
def create_query(self,payload,db,user):
sid=payload.get('source_id');name=str(payload.get('name','')).strip();query=payload.get('query',{})
selected=payload.get('selected_adapters',payload.get('sources',[])); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
selected=resolve_selected_source_codes(db, user['organization_id'], payload.get('selected_adapters',payload.get('sources',[]))); location=str(payload.get('location','')).strip(); category=str(payload.get('category','')).strip(); schedule=str(payload.get('schedule','')).strip()
try: max_records=int(payload.get('max_records',100)); daily_limit=int(payload.get('daily_limit',1000))
except (TypeError,ValueError): return self.send_json(400,{"error":"invalid_limits"})
if not isinstance(selected,list) or any(str(x) not in {a["source_code"] for a in available_adapters()} for x in selected): return self.send_json(400,{"error":"invalid_adapters"})
@@ -1431,7 +1465,7 @@ class ApiHandler(BaseHTTPRequestHandler):
def test_source(self,sid,db,user):
source=db.execute("SELECT * FROM sources WHERE id=? AND organization_id=?",(sid,user['organization_id'])).fetchone()
if not source:return self.send_json(404,{"error":"not_found"})
try: result=adapter_for(source['kind']).validate(json.loads(source['config_json'])); ok=result.valid; error='; '.join(result.errors) if not ok else None
try: result=adapter_for(source['source_code'] or source['kind']).validate(json.loads(source['config_json'])); ok=result.valid; error='; '.join(result.errors) if not ok else None
except Exception as exc:ok=False;error=str(exc)[:300]
if ok:db.execute("UPDATE sources SET health_status='healthy',consecutive_failures=0,circuit_open=0,last_success_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?",(sid,));action='source.test.succeeded'
else:db.execute("UPDATE sources SET health_status='unhealthy',consecutive_failures=consecutive_failures+1,circuit_open=CASE WHEN consecutive_failures+1>=3 THEN 1 ELSE circuit_open END,last_failure_at=CURRENT_TIMESTAMP,last_error=? WHERE id=?",(error,sid));action='source.test.failed'
@@ -1445,7 +1479,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if len(json.dumps(config).encode())>5*1024*1024:return self.send_json(400,{"error":"ingest_limits"})
if isinstance(config.get('rows'),list) and (len(config['rows'])>1000 or any(not isinstance(r,dict) or len(r)>50 or any(len(str(v))>10000 for v in r.values()) for r in config['rows'])):return self.send_json(400,{"error":"ingest_limits"})
if isinstance(config.get('csv'),str) and config['csv'].count('\n')>1001:return self.send_json(400,{"error":"ingest_limits"})
try:page=adapter_for(source['kind']).discover(source_config_with_credentials(db, source, config))
try:page=adapter_for(source['source_code'] or source['kind']).discover(source_config_with_credentials(db, source, config))
except (ValueError,KeyError) as exc:return self.send_json(400,{"error":"invalid_ingest","detail":str(exc)})
inserted=0
for record in page.records[:1000]:
@@ -1460,7 +1494,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if not source:return self.send_json(404,{"error":"not_found"})
try: config=json.loads(source["config_json"] or "{}")
except (TypeError,ValueError): config={}
health=adapter_for(source["kind"]).health_check(config)
health=adapter_for(source["source_code"] or source["kind"]).health_check(config)
return self.send_json(200,{"id":sid,"source_code":source["source_code"] or source["kind"],"display_name":source["display_name"] or source["name"],"status":source["health_status"],"configured":health.status=="healthy","circuit_open":bool(source["circuit_open"]),"consecutive_failures":source["consecutive_failures"],"last_error":source["last_error"]})
def discovery_run_action(self,rid,action,db,user):
run=db.execute("SELECT * FROM discovery_runs WHERE id=? AND organization_id=?",(rid,user["organization_id"])).fetchone()
@@ -1583,7 +1617,7 @@ def _run_source_discovery(db, job, handler):
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["kind"]).discover(source_config_with_credentials(db, source, config))
page=adapter_for(source["source_code"] or source["kind"]).discover(source_config_with_credentials(db, source, config))
except Exception as exc:
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"]))
@@ -1613,6 +1647,7 @@ def _run_source_discovery(db, job, handler):
if normalized["website_domain"] and not db.execute("SELECT 1 FROM domains WHERE organization_id=? AND business_id=? AND domain=?",(org,bid,normalized["website_domain"])).fetchone(): db.execute("INSERT INTO domains(business_id,organization_id,domain,kind) VALUES(?,?,?,?)",(bid,org,normalized["website_domain"],"website"))
if normalized["website"] and not db.execute("SELECT 1 FROM websites WHERE organization_id=? AND business_id=? AND url=?",(org,bid,normalized["website"])).fetchone(): db.execute("INSERT INTO websites(business_id,organization_id,url,website_class) VALUES(?,?,?,?)",(bid,org,normalized["website"],"business_site"))
if normalized["website"] or normalized["website_domain"]: db.execute("INSERT OR IGNORE INTO evidence(business_id,organization_id,kind,url,claim) VALUES(?,?,?,?,?)",(bid,org,"source_record",normalized["website"],"Discovered by "+source["kind"]))
if normalized["website"]: enrich_source_business(db,org,bid,normalized["website"],source["source_code"] or source["kind"])
if normalized["email"] or normalized["phone"]:
if not db.execute("SELECT 1 FROM contacts WHERE organization_id=? AND business_id=? AND email=? AND phone=?",(org,bid,normalized["email"],normalized["phone"])).fetchone(): db.execute("INSERT INTO contacts(business_id,organization_id,email,phone) VALUES(?,?,?,?)",(bid,org,normalized["email"],normalized["phone"]))
if record_id: db.execute("UPDATE source_records SET processing_status='processed',normalized_json=?,normalized_key=? WHERE id=? AND organization_id=?",(norm,nkey,record_id,org)); db.execute("INSERT OR IGNORE INTO enrichment_queue(organization_id,source_record_id,status) VALUES(?,?,?)",(org,record_id,"completed")); db.execute("UPDATE enrichment_queue SET status='completed',updated_at=CURRENT_TIMESTAMP WHERE source_record_id=? AND organization_id=?",(record_id,org))
@@ -1669,13 +1704,30 @@ def _job_worker(server):
db.commit()
finally: db.close()
def _schedule_worker(server):
"""Lightweight durable scheduler for daily/weekday/weekly discovery queries."""
while not server.job_stop.is_set():
db=connect(server.db_path)
try:
now=datetime.now(timezone.utc); today=now.date().isoformat()
for query in db.execute("SELECT * FROM discovery_queries WHERE enabled=1 AND schedule IN ('daily','weekdays','weekly')"):
if now.hour < 9 or (query["schedule"]=='weekdays' and now.weekday()>4) or (query["schedule"]=='weekly' and now.weekday()!=0): continue
if db.execute("SELECT 1 FROM discovery_runs WHERE organization_id=? AND criteria_json=? AND date(created_at)=date('now') LIMIT 1",(query["organization_id"],query["query_json"])).fetchone(): continue
key=f"scheduled-query-{query['id']}-{today}"
if db.execute("SELECT 1 FROM jobs WHERE organization_id=? AND idempotency_key=?",(query["organization_id"],key)).fetchone(): continue
cur=db.execute("INSERT INTO jobs(organization_id,idempotency_key,type,payload) VALUES(?,?,?,?)",(query["organization_id"],key,"source_discovery",json.dumps({"discovery_query_id":query["id"],"selected_adapters":json.loads(query["selected_adapters_json"] or '[]'),"max_records":query["max_records"],"daily_limit":query["daily_limit"]})))
db.execute("INSERT INTO discovery_runs(organization_id,job_id,selected_adapters_json,location,category,max_records,daily_limit,schedule,dry_run,lifecycle,criteria_json,seed_urls_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",(query["organization_id"],cur.lastrowid,query["selected_adapters_json"],query["location"],query["category"],query["max_records"],query["daily_limit"],query["schedule"],query["dry_run"],"queued",query["query_json"],"[]"))
db.commit()
finally: db.close()
server.job_wakeup.set(); server.job_stop.wait(30)
def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"):
load_config()
configure_ai_research_db(db_path)
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start()
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start();server.schedule_thread=threading.Thread(target=_schedule_worker,args=(server,),daemon=True);server.schedule_thread.start()
original_close=server.server_close
def close():
server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);original_close()
server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);server.schedule_thread.join(timeout=2);original_close()
server.server_close=close
return server
if __name__=="__main__":
+26 -1
View File
@@ -315,6 +315,7 @@ class ApprovedDirectorySource(GatedSource):
display_name = "Free public directories"
available = True
requires_credentials = False
optional = False
def validate_config(self, config):
result=super().validate_config(config)
@@ -375,12 +376,36 @@ class GooglePlacesSource(GatedSource):
records.append(normalize_record({"name":name, "website":place.get("websiteUri", ""), "phone":place.get("nationalPhoneNumber") or place.get("internationalPhoneNumber", ""), "location":place.get("formattedAddress", ""), "source_url":place.get("googleMapsUri", "") , "description":"Google Places result"}))
return DiscoveryPage(records, metadata={"adapter":self.source_code,"record_count":len(records),"provider":"google_places"})
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)
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)
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)
return DiscoveryPage(page.records, page.next_cursor, {**page.metadata, "adapter": self.source_code})
def _gated(code, name):
return type(name.replace(" ", ""), (GatedSource,), {"kind":code, "source_code":code, "display_name":name})
BingLocalSource = _gated("bing_local", "Bing / approved local API")
PermittedSocialSource = _gated("permitted_social", "Permitted social")
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
ADAPTERS = {x.source_code: x for x in (ManualSource, CsvSource, GooglePlacesSource, BingLocalSource, ApprovedDirectorySource, OpenStreetMapSource, WikidataSource, CommonCrawlSource, PublicWebsiteSource, PermittedSocialSource, CtLogsSource, DnsSource, RdapSource)}
# common aliases used by clients
ADAPTER_REGISTRY = ADAPTERS
+1 -1
View File
@@ -13,7 +13,7 @@ class SourceAdapterTests(unittest.TestCase):
self.assertFalse(catalog[code]['available'], code)
self.assertTrue(catalog[code]['optional'], code)
self.assertTrue(catalog['approved_directory']['available'])
self.assertTrue(catalog['approved_directory']['optional'])
self.assertFalse(catalog['approved_directory']['optional'])
self.assertTrue(catalog['google_places']['available'])
self.assertTrue(catalog['google_places']['optional'])
+3 -3
View File
@@ -1,6 +1,6 @@
{
"schema": 1,
"version": "phase-24",
"version": "phase-26",
"entrypoints": [
"config.js",
"app.js",
@@ -13,10 +13,10 @@
"healthz"
],
"integrity": {
"config.js": "sha256-29953b8de308d7831c606d2116b30ae59744fc0e45f998d970fe71fcbd80fc2a",
"config.js": "sha256-036e9b1a2d16cf89ffc1cb23f22aab71608b1330e8323d2d42a6c6b86051698c",
"app.js": "sha256-44809719afe72cd0ddc30819b3c73f36019319bfbda4e59d62055fd040b5c6c0",
"styles.css": "sha256-a9fd194b001c0de98775bcf5fbba5da676ccfdd607a889eae84bd30a54c15e08",
"index.html": "sha256-f72befe537a05b7172f94346b367c75f364b0895b9dac233cc6bd78220a222d9",
"index.html": "sha256-f48882419575ac49c389f07ba25791115981330a1dd623ce3c21b026a0dedd3c",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
+1 -1
View File
@@ -1,5 +1,5 @@
/* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */
window.__PROSPECT_CONFIG__ = Object.freeze({
apiBase: '',
assetVersion: 'phase-24'
assetVersion: 'phase-26'
});
+3 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ProspectOS · Pipeline intelligence</title>
<meta name="description" content="Prospect discovery and review dashboard">
<link rel="stylesheet" href="styles.css?v=phase-24">
<link rel="stylesheet" href="styles.css?v=phase-26">
</head>
<body>
<section class="login-screen" id="loginScreen" aria-labelledby="loginTitle">
@@ -130,7 +130,7 @@
<div class="merge-dialog" id="mergeDialog" hidden role="dialog" aria-modal="true" aria-labelledby="mergeDialogTitle">
<div class="merge-dialog-card"><div class="panel-heading"><div><p class="eyebrow">REVIEW REQUIRED</p><h2 id="mergeDialogTitle">Confirm merge</h2></div><button class="icon-button" id="cancelMergeBtn" type="button" aria-label="Close merge confirmation">×</button></div><p id="mergeDialogCopy"></p><div class="merge-warning"><strong>This action is reversible.</strong> The merge will be recorded in history and can be reversed later.</div><p id="mergeDialogMessage" class="form-message" role="alert" aria-live="polite"></p><div class="merge-dialog-actions"><button class="button ghost" id="cancelMergeBtnSecondary" type="button">Cancel</button><button class="button primary" id="confirmMergeBtn" type="button">Confirm merge</button></div></div>
</div>
<script src="config.js?v=phase-24"></script>
<script src="app.js?v=phase-24"></script>
<script src="config.js?v=phase-26"></script>
<script src="app.js?v=phase-26"></script>
</body>
</html>