rebuild source discovery workflows
CI / compose (push) Failing after 5m46s

This commit is contained in:
Marco0300
2026-09-04 20:52:37 +02:00
parent 3a9b553440
commit 6aefbdc1f3
18 changed files with 853 additions and 106 deletions
+170
View File
@@ -0,0 +1,170 @@
import json
import os
import sqlite3
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from unittest.mock import Mock
from app.ai_opportunity import normalize_assessment
from app.ai_research import configure_db as configure_ai_research_db
from app.main import create_server, hash_password
class OpportunityNormalizationTests(unittest.TestCase):
def test_normalizes_scores_enums_and_known_evidence_only(self):
provider = Mock(return_value={
"opportunity_score": 81,
"confidence_score": 0.82,
"recommendation": "contact",
"priority": "high",
"reasons": ["Two independent public listings corroborate the business."],
"missing_evidence": [],
"website_assessment": {"status": "healthy", "broken": False, "outdated": False, "mobile_issue": False, "https_issue": False, "performance_issue": False},
"domain_assessment": {"status": "registered"},
"contactability": {"public_business_contact_found": True, "contact_type": "general_business"},
"recommended_services": ["seo"],
"evidence_references": [2, 1, 2],
"human_review_required": False,
})
result = normalize_assessment(provider(), {1, 2})
self.assertEqual(result["opportunity_score"], 81)
self.assertEqual(result["confidence_score"], 82)
self.assertEqual(result["evidence_references"], [1, 2])
self.assertFalse(result["human_review_required"])
self.assertEqual(result["recommendation"], "contact")
self.assertEqual(result["reasons"], ["Two independent public listings corroborate the business."])
self.assertEqual(result["missing_evidence"], [])
self.assertEqual(result["website_assessment"]["status"], "healthy")
self.assertEqual(result["domain_assessment"]["status"], "registered")
self.assertEqual(result["contactability"]["contact_type"], "general_business")
self.assertEqual(result["recommended_services"], ["seo"])
def test_contact_recommendation_is_an_internal_no_send_recommendation(self):
payload = {"recommendation": "contact", "evidence_references": [1, 2], "confidence_score": 90}
self.assertEqual(normalize_assessment(payload, {1, 2})["recommendation"], "contact")
def test_unknown_values_default_and_weak_evidence_requires_human_review(self):
result = normalize_assessment({
"opportunity_score": "not-a-number",
"confidence_score": 20,
"recommendation": "email_them_now",
"priority": "urgent",
"evidence_references": [],
"human_review_required": False,
}, set())
self.assertEqual(result["opportunity_score"], 0)
self.assertEqual(result["confidence_score"], 20)
self.assertEqual(result["recommendation"], "insufficient_evidence")
self.assertEqual(result["priority"], "low")
self.assertTrue(result["human_review_required"])
def test_unknown_evidence_reference_is_rejected(self):
with self.assertRaisesRegex(ValueError, "unknown_evidence_reference"):
normalize_assessment({"evidence_references": [99]}, {1})
def test_suppression_overrides_provider_recommendation(self):
result = normalize_assessment({
"opportunity_score": 99,
"confidence_score": 99,
"recommendation": "contact",
"priority": "high",
"evidence_references": [1, 2],
"human_review_required": False,
}, {1, 2}, suppressed=True)
self.assertEqual(result["recommendation"], "do_not_contact")
self.assertTrue(result["human_review_required"])
class OpportunityAssessmentApiTests(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.old = {key: os.environ.get(key) for key in ("AI_PROVIDER", "BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")}
os.environ.update({"AI_PROVIDER": "local", "BOOTSTRAP_ADMIN_EMAIL": "opportunity-owner@example.test", "BOOTSTRAP_ADMIN_PASSWORD": "password"})
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/opportunity.db")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3); self.cookie = None
self.request("POST", "/api/v1/auth/login", {"email": "opportunity-owner@example.test", "password": "password"})
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2)
configure_ai_research_db("")
self.tmp.cleanup()
for key, value in self.old.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
def request(self, method, path, payload=None):
headers = {"Content-Type": "application/json"}
if self.cookie: headers["Cookie"] = self.cookie
self.conn.request(method, path, json.dumps(payload).encode() if payload is not None else None, headers)
response = self.conn.getresponse(); cookie = response.getheader("Set-Cookie")
if cookie: self.cookie = cookie.split(";", 1)[0]
return response.status, json.loads(response.read() or b"{}")
def selected_business_at_threshold(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Selected Co"})
self.assertEqual(status, 201)
db = sqlite3.connect(self.tmp.name + "/opportunity.db")
db.execute("UPDATE businesses SET score=70 WHERE id=?", (business["id"],)); db.commit(); db.close()
return business["id"]
def test_manual_selected_business_after_threshold_returns_grounded_review_only_assessment(self):
bid = self.selected_business_at_threshold()
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Needs a modern website"})
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
self.assertEqual(status, 201)
self.assertEqual(result["business_id"], bid)
self.assertEqual(result["assessment"]["evidence_references"], [1])
self.assertIn("opportunity_score", result["assessment"])
self.assertIn("confidence_score", result["assessment"])
self.assertTrue(result["assessment"]["human_review_required"])
self.assertFalse(result["network_send"])
self.assertFalse(result["automatic_outreach"])
status, runs = self.request("GET", "/api/v1/ai-runs")
self.assertEqual(status, 200)
self.assertEqual(runs["items"][0]["output"]["assessment"], result["assessment"])
self.assertEqual(len(runs["items"][0]["input_evidence_hashes"]), 1)
def test_manual_assessment_requires_deterministic_threshold(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Below threshold"})
self.assertEqual(status, 201)
status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/ai/opportunity-assessment", {})
self.assertEqual(status, 409)
self.assertEqual(result["error"], "deterministic_threshold_not_met")
def test_unconfigured_provider_fails_closed_without_assessment_output(self):
bid = self.selected_business_at_threshold()
with unittest.mock.patch("app.main.provider_status", return_value={"status": "not_configured", "provider": ""}):
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
self.assertEqual(status, 409)
self.assertEqual(result["error"], "ai_provider_not_configured")
self.assertFalse(result["network_send"])
self.assertNotIn("assessment", result)
def test_suppressed_selected_business_assessment_is_do_not_contact(self):
bid = self.selected_business_at_threshold()
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Evidence"})
self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "selected.test"})
db = sqlite3.connect(self.tmp.name + "/opportunity.db")
db.execute("UPDATE businesses SET website_domain='selected.test' WHERE id=?", (bid,)); db.commit(); db.close()
status, result = self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})
self.assertEqual(status, 201)
self.assertEqual(result["assessment"]["recommendation"], "do_not_contact")
self.assertTrue(result["assessment"]["human_review_required"])
def test_manual_assessment_is_tenant_scoped(self):
bid = self.selected_business_at_threshold()
password_hash, password_salt = hash_password("other-password")
db = sqlite3.connect(self.tmp.name + "/opportunity.db")
db.execute("INSERT INTO organizations(id,name) VALUES(?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users(organization_id,email,password_hash,password_salt,role) VALUES(?,?,?,?,?)", ("other-tenant", "other-opportunity@example.test", password_hash, password_salt, "owner"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other-opportunity@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/opportunity-assessment", {})[0], 404)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,59 @@
import os
import unittest
from unittest.mock import patch
from app.sources import GoogleBrowserSearchBlocked, GoogleBrowserSearchSource
class GoogleBrowserSearchSourceTests(unittest.TestCase):
def setUp(self):
self.old_enabled = os.environ.pop("GOOGLE_BROWSER_SEARCH_ENABLED", None)
def tearDown(self):
GoogleBrowserSearchSource._last_request_at = None
if self.old_enabled is None:
os.environ.pop("GOOGLE_BROWSER_SEARCH_ENABLED", None)
else:
os.environ["GOOGLE_BROWSER_SEARCH_ENABLED"] = self.old_enabled
def test_disabled_feature_blocks_without_network_io(self):
source = GoogleBrowserSearchSource()
with patch("app.sources.urlopen") as network:
with self.assertRaises(GoogleBrowserSearchBlocked) as raised:
source.discover(
{"approved": True, "public_access": True, "terms_accepted": True, "rate_limit": 6},
criteria={"keywords": ["solar installers"], "city": "Cape Town"},
limits={"max_records": 5},
)
self.assertEqual(raised.exception.code, "GOOGLE_BROWSER_BLOCKED")
self.assertEqual(raised.exception.reason, "feature_disabled")
network.assert_not_called()
def test_enabled_fetch_builds_query_from_criteria_and_parses_visible_result_links(self):
os.environ["GOOGLE_BROWSER_SEARCH_ENABLED"] = "true"
class Response:
def read(self, _limit):
return b'<html><body><a href="https://acme.example/about"><h3>Acme Solar</h3></a><a href="https://www.google.com/preferences"><h3>Settings</h3></a></body></html>'
def __enter__(self):
return self
def __exit__(self, *_):
return False
with patch("app.sources.urlopen", return_value=Response()) as network:
page = GoogleBrowserSearchSource().discover(
{"approved": True, "public_access": True, "terms_accepted": True, "rate_limit": 6},
criteria={"keywords": ["solar"], "city": "Cape Town"},
limits={"max_records": 4},
)
self.assertEqual(page.records, [{"name": "Acme Solar", "website": "https://acme.example/about", "email": "", "phone": "", "description": "Public Google search result", "location": ""}])
request = network.call_args.args[0]
self.assertIn("q=solar+Cape+Town", request.full_url)
self.assertNotIn("query", request.full_url)
self.assertTrue(page.metadata["public_html_only"])
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -84,6 +84,46 @@ class ScopedDiscoveryApiTests(unittest.TestCase):
run = self.request('GET', '/api/v1/discovery-runs')[1]['items'][0]
self.assertEqual(run['seed_urls'], []); self.assertEqual(run['result']['candidates'][0]['provenance']['mechanism'], 'criteria_search_provider')
def test_selected_disabled_source_is_rejected_before_job_creation(self):
status, source = self.request('POST', '/api/v1/sources', {
'name': 'Disabled manual', 'kind': 'manual',
'config': {'rows': [{'name': 'Not runnable'}]},
})
self.assertEqual(status, 201)
status, body = self.request('POST', '/api/v1/discovery', {
'criteria': {'category': 'plumbers'}, 'source_ids': [source['id']],
'idempotency_key': 'disabled-source',
})
self.assertEqual(status, 409)
self.assertEqual(body['error'], 'selected_source_not_ready')
self.assertEqual(self.request('GET', '/api/v1/discovery-runs')[1]['items'], [])
def test_source_dry_run_validates_enabled_source_without_persisting_candidates(self):
status, source = self.request('POST', '/api/v1/sources', {
'name': 'Preview manual', 'kind': 'manual', 'enabled': True,
'config': {'rows': [{'name': 'Preview Plumbing', 'website': 'https://preview.example.test'}]},
})
self.assertEqual(status, 201)
with patch('app.main.enrich_source_business') as enrich:
status, job = self.request('POST', '/api/v1/discovery', {
'criteria': {'category': 'plumbers', 'city': 'Cape Town'},
'source_ids': [source['id']], 'dry_run': True,
'idempotency_key': 'source-dry-run', 'max_candidates': 5,
})
self.assertEqual(status, 202)
for _ in range(100):
_, current = self.request('GET', '/api/v1/jobs/' + str(job['id']))
if current['status'] in ('succeeded', 'failed'):
break
time.sleep(.02)
self.assertEqual(current['status'], 'succeeded')
self.assertFalse(enrich.called)
self.assertEqual(self.request('GET', '/api/v1/businesses')[1]['items'], [])
self.assertEqual(self.request('GET', '/api/v1/source-records')[1]['items'], [])
run = self.request('GET', '/api/v1/discovery-runs')[1]['items'][0]
self.assertTrue(run['dry_run'])
self.assertEqual(run['result_count'], 1)
def test_results_are_tenant_isolated(self):
ph, salt = hash_password('other-password')
db = sqlite3.connect(self.server.db_path); db.execute("INSERT INTO organizations VALUES ('other-tenant','Other',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ('other-tenant','other@example.test',ph,salt,'owner')); db.commit(); db.close()
+52 -4
View File
@@ -37,13 +37,14 @@ class SourceAdapterTests(unittest.TestCase):
def read(self, _): return b'{"elements":[{"tags":{"name":"Cape Plumber","craft":"plumber"}}]}'
def __enter__(self): return self
def __exit__(self, *_): return False
config={'provider':'openstreetmap','query':'plumbers','location':'Cape Town','approved':True,'public_access':True,'terms_accepted':True,'rate_limit':1}
config={'provider':'openstreetmap','approved':True,'public_access':True,'terms_accepted':True,'rate_limit':1}
criteria={'keywords':['plumbers'],'city':'Cape Town'}
with patch('app.sources.urlopen',return_value=Response()) as request:
page=ApprovedDirectorySource().discover(config)
result=ApprovedDirectorySource().discover(config,criteria=criteria,limits={'max_records':10})
query=request.call_args.args[0].data.decode()
self.assertIn('"craft"~"plumber|plumbers",i]',query)
self.assertIn('"shop"~"plumber|plumbers",i]',query)
self.assertEqual(page.records[0]['name'],'Cape Plumber')
self.assertEqual(result.records[0]['name'],'Cape Plumber')
def test_legacy_source_kind_constraint_is_migrated(self):
with TemporaryDirectory() as tmp:
@@ -54,7 +55,8 @@ class SourceAdapterTests(unittest.TestCase):
db=connect(path)
self.assertEqual(db.execute("SELECT kind FROM sources WHERE name='Existing manual'").fetchone()[0],'manual')
db.execute("INSERT INTO sources(organization_id,name,kind,source_code) VALUES(?,?,?,?)",(ORGANIZATION_ID,'OpenStreetMap / Overpass · plumbers','approved_directory','openstreetmap'))
db.commit(); self.assertEqual(db.execute("SELECT kind FROM sources WHERE source_code='openstreetmap'").fetchone()[0],'approved_directory'); db.close()
db.execute("INSERT INTO sources(organization_id,name,kind,source_code) VALUES(?,?,?,?)",(ORGANIZATION_ID,'Experimental Google','google_browser_search','google_browser_search'))
db.commit(); self.assertEqual(db.execute("SELECT kind FROM sources WHERE source_code='openstreetmap'").fetchone()[0],'approved_directory'); self.assertEqual(db.execute("SELECT kind FROM sources WHERE source_code='google_browser_search'").fetchone()[0],'google_browser_search'); db.close()
class SourceApiTests(unittest.TestCase):
def setUp(self):
@@ -177,6 +179,52 @@ class SourceApiTests(unittest.TestCase):
threading.Event().wait(.01)
self.assertEqual(current['status'],'failed'); self.assertEqual(current['error_code'],'SOURCE_DISABLED')
def test_google_browser_source_cannot_enable_without_runtime_feature_flag(self):
with patch.dict(os.environ, {'GOOGLE_BROWSER_SEARCH_ENABLED': 'false'}):
status, source = self.req('POST', '/api/v1/sources', {
'name': 'Disabled Experimental Google', 'kind': 'google_browser_search',
'config': {'approved': True, 'public_access': True, 'terms_accepted': True, 'rate_limit': 6},
})
self.assertEqual(status, 201)
status, body = self.req('PATCH', f"/api/v1/sources/{source['id']}", {'enabled': True})
self.assertEqual(status, 409)
self.assertEqual(body['error'], 'source_feature_disabled')
with patch.dict(os.environ, {'GOOGLE_BROWSER_SEARCH_ENABLED': 'false'}):
status, body = self.req('POST', '/api/v1/sources', {
'name': 'Still Disabled Experimental Google', 'kind': 'google_browser_search', 'enabled': True,
'config': {'approved': True, 'public_access': True, 'terms_accepted': True, 'rate_limit': 6},
})
self.assertEqual(status, 409)
self.assertEqual(body['error'], 'source_feature_disabled')
def test_google_browser_block_is_structured_and_stops_source_job(self):
class Response:
def read(self, _limit): return b"<html>Our systems have detected unusual traffic from your computer network.</html>"
def __enter__(self): return self
def __exit__(self, *_): return False
with patch.dict(os.environ, {'GOOGLE_BROWSER_SEARCH_ENABLED': 'true'}):
status, source = self.req('POST', '/api/v1/sources', {
'name': 'Experimental Google', 'kind': 'google_browser_search', 'enabled': True,
'config': {'approved': True, 'public_access': True, 'terms_accepted': True, 'rate_limit': 6},
})
self.assertEqual(status, 201)
status, job = self.req('POST', '/api/v1/discovery', {
'criteria': {'keywords': ['solar'], 'city': 'Cape Town'},
'selected_adapters': ['google_browser_search'], 'idempotency_key': 'google-blocked', 'max_records': 2,
})
self.assertEqual(status, 202)
with patch('app.sources.urlopen', return_value=Response()) as network:
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'], 'failed')
self.assertEqual(current['error_code'], 'GOOGLE_BROWSER_BLOCKED')
self.assertEqual(network.call_count, 1)
events = self.req('GET', f"/api/v1/jobs/{job['id']}/events")[1]['items']
self.assertIn('GOOGLE_BROWSER_BLOCKED', [event.get('error_code') for event in events])
def test_source_discovery_persists_pipeline_and_is_idempotent(self):
status, source = self.req('POST', '/api/v1/sources', {
'name': 'Manual leads', 'kind': 'manual', 'enabled': True,