replace ProspectOS with BrowserOnly build

This commit is contained in:
Marco0300
2026-09-05 14:05:33 +02:00
parent f5b6f6c739
commit d68355f626
118 changed files with 405 additions and 12569 deletions
-40
View File
@@ -1,40 +0,0 @@
#!/bin/sh
set -eu
umask 077
DB_PATH=${1:-${PROSPECT_API_DB:-${DATA_DIR:-/data}/prospects.db}}
BACKUP_DIR=${2:-${BACKUP_DIR:-/var/backups/prospect-platform}}
RETENTION=${3:-${BACKUP_RETENTION:-30}}
case "$RETENTION" in ''|*[!0-9]*) echo 'retention must be a non-negative integer' >&2; exit 2;; esac
case "$BACKUP_DIR" in /*) ;; *) echo 'backup directory must be an absolute path' >&2; exit 2;; esac
case "$DB_PATH" in *.env|*secret*|*credentials*) echo 'refusing secret-like database path' >&2; exit 2;; esac
mkdir -p -- "$BACKUP_DIR"
python3 - "$DB_PATH" "$BACKUP_DIR" "$RETENTION" <<'PY'
import hashlib, os, sqlite3, sys, tempfile, time
from pathlib import Path
src, out_dir, retention = Path(sys.argv[1]).expanduser(), Path(sys.argv[2]).expanduser(), int(sys.argv[3])
if not src.is_file() or not src.is_absolute(): raise SystemExit('database must be an existing absolute regular file')
out_dir = out_dir.resolve()
if src.resolve() == out_dir: raise SystemExit('backup directory must differ from database')
out_dir.mkdir(parents=True, exist_ok=True)
name = f"{os.environ.get('BACKUP_PREFIX', 'prospects')}-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{os.getpid()}"
final = out_dir / (name + '.db')
fd, temp_name = tempfile.mkstemp(prefix='.backup-', suffix='.tmp', dir=out_dir)
os.close(fd)
try:
with sqlite3.connect(f'file:{src}?mode=ro', uri=True) as source, sqlite3.connect(temp_name) as target:
source.backup(target)
target.execute('PRAGMA integrity_check')
target.commit()
with open(temp_name, 'rb') as handle:
os.fsync(handle.fileno())
os.chmod(temp_name, 0o600); os.replace(temp_name, final)
digest = hashlib.sha256(final.read_bytes()).hexdigest()
sidecar = Path(str(final) + '.sha256')
sidecar.write_text(f'{digest} {final.name}\n', encoding='ascii'); os.chmod(sidecar, 0o600)
candidates = sorted([*out_dir.glob('prospects-*.db'), *out_dir.glob('pre-restore-*.db')], key=lambda p: p.stat().st_mtime, reverse=True)
for old in candidates[retention:]:
old.unlink(missing_ok=True); Path(str(old)+'.sha256').unlink(missing_ok=True)
print(final)
finally:
Path(temp_name).unlink(missing_ok=True)
PY
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Offline, deterministic pilot benchmark for Phase 16.
The benchmark never performs network I/O. It evaluates the Phase 6/9/8/10 pure
functions against a versioned synthetic fixture and writes JSON suitable for CI
or trend collection.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
from functools import lru_cache
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API = ROOT / "apps" / "api"
FIXTURE_PATH = API / "fixtures" / "phase16.json"
DEFAULT_REPORT = ROOT / "docs" / "benchmarks" / "phase16.latest.json"
if str(API) not in sys.path:
sys.path.insert(0, str(API))
from app.contact_extractor import extract_contacts
from app.domain import match_businesses, normalize_business
from app.scoring import DEFAULT_RULES, evaluate_score
from app.website_scanner import classify_website
ACCEPTANCE_THRESHOLDS = {
"normalization_accuracy": 1.0,
"matching_precision": 0.90,
"matching_recall": 0.90,
"contact_precision": 0.85,
"contact_recall": 0.85,
"website_accuracy": 0.90,
"score_reproducible": True,
"tenant_leakage": 0,
}
def _pr(predicted: set, expected: set) -> dict:
tp = len(predicted & expected)
fp = len(predicted - expected)
fn = len(expected - predicted)
return {"true_positive": tp, "false_positive": fp, "false_negative": fn,
"precision": round(tp / (tp + fp), 4) if tp + fp else (1.0 if not expected else 0.0),
"recall": round(tp / (tp + fn), 4) if tp + fn else 1.0}
def _timed(callable_, iterations: int = 1000) -> dict:
samples = []
for _ in range(iterations):
start = time.perf_counter_ns()
callable_()
samples.append((time.perf_counter_ns() - start) / 1_000_000)
ordered = sorted(samples)
return {"iterations": iterations, "median_ms": round(statistics.median(samples), 4),
"p95_ms": round(ordered[max(0, int(iterations * 0.95) - 1)], 4),
"max_ms": round(max(samples), 4)}
def run_benchmark(fixtures: dict | None = None, *, measure_latency: bool = True) -> dict:
data = fixtures or json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
normalization_correct = 0
for case in data.get("normalization", []):
actual = normalize_business(case["input"])
fields_ok = all(actual.get(field) == expected for field, expected in case["expected"].items())
normalization_correct += int(fields_ok)
normalization_total = len(data.get("normalization", []))
matching_predicted, matching_expected = set(), set()
matching_false_positive = 0
matching_cases = []
for case in data["matching"]:
expected = set(case["expected_ids"])
predicted = {item["id"] for item in match_businesses(case["source"], case["candidates"], threshold=case["threshold"])}
matching_predicted |= predicted
matching_expected |= expected
matching_false_positive += len(predicted - expected)
matching_cases.append({"name": case["name"], "predicted_ids": sorted(predicted), "expected_ids": sorted(expected)})
matching = _pr(matching_predicted, matching_expected)
matching["cases"] = matching_cases
matching["false_positive_cases"] = matching_false_positive
contact_predicted, contact_expected = set(), set()
contact_cases = []
for case in data["contacts"]:
predicted = {(item["kind"], item["value"]) for item in extract_contacts(case["html"], case["source_url"])}
expected = {tuple(item) for item in case["expected"]}
contact_predicted |= predicted
contact_expected |= expected
contact_cases.append({"name": case["name"], "predicted_count": len(predicted), "expected_count": len(expected), "false_positives": sorted([list(x) for x in predicted - expected])})
contacts = _pr(contact_predicted, contact_expected)
contacts["cases"] = contact_cases
website_correct = sum(classify_website(c["status"], c["url"], c["body"], error=c.get("error")) == c["expected"] for c in data["websites"])
website_total = len(data["websites"])
website = {"correct": website_correct, "total": website_total, "accuracy": round(website_correct / website_total, 4) if website_total else 1.0}
score_case = data["scoring"][0]
first_score = evaluate_score(score_case["signals"], DEFAULT_RULES)
score_reproducible = all(evaluate_score(c["signals"], DEFAULT_RULES) == evaluate_score(c["signals"], DEFAULT_RULES) for c in data["scoring"])
scoring = {"reproducible": score_reproducible, "fixture_scores": [{"name": c["name"], "score": evaluate_score(c["signals"], DEFAULT_RULES)["score"], "eligible": evaluate_score(c["signals"], DEFAULT_RULES)["eligible"]} for c in data["scoring"]]}
if measure_latency:
scoring["latency"] = _timed(lambda: evaluate_score(score_case["signals"], DEFAULT_RULES))
@lru_cache(maxsize=128)
def cached_score(payload: str) -> dict:
return evaluate_score(json.loads(payload), DEFAULT_RULES)
cache_payload = json.dumps(score_case["signals"], sort_keys=True, separators=(",", ":"))
cached_score.cache_clear()
if measure_latency:
cold = _timed(lambda: (cached_score.cache_clear(), cached_score(cache_payload)), 100)
warm = _timed(lambda: cached_score(cache_payload), 1000)
cache = {"enabled": True, "cold": cold, "warm": warm, "speedup": round(cold["median_ms"] / warm["median_ms"], 4) if warm["median_ms"] else None}
else:
cache = {"enabled": True, "timing_omitted": True}
tenant_a = set(data["tenant_isolation"]["tenant_a"]["business_ids"])
tenant_b = set(data["tenant_isolation"]["tenant_b"]["business_ids"])
# This is the same allow-list operation required before a tenant query.
visible_to_a = [ident for ident in sorted(tenant_a | tenant_b) if ident in tenant_a]
leakage = len(set(visible_to_a) & tenant_b)
tenant = {"tenant_a_visible_ids": visible_to_a, "tenant_b_ids": sorted(tenant_b), "leakage": leakage, "isolated": leakage == 0}
latency = {}
if measure_latency:
latency = {
"normalization": _timed(lambda: normalize_business(data["normalization"][0]["input"])),
"matching": _timed(lambda: match_businesses(data["matching"][0]["source"], data["matching"][0]["candidates"], threshold=data["matching"][0]["threshold"])),
"contact_extraction": _timed(lambda: extract_contacts(data["contacts"][0]["html"], data["contacts"][0]["source_url"])),
"website_classification": _timed(lambda: classify_website(data["websites"][0]["status"], data["websites"][0]["url"], data["websites"][0]["body"])),
}
results = {
"normalization": {"correct": normalization_correct, "total": normalization_total, "accuracy": round(normalization_correct / normalization_total, 4) if normalization_total else 1.0},
"matching": matching, "contacts": contacts, "website_classification": website,
"scoring": scoring, "cache": cache, "tenant_isolation": tenant, "latency": latency,
}
checks = {
"normalization_accuracy": results["normalization"]["accuracy"] >= ACCEPTANCE_THRESHOLDS["normalization_accuracy"],
"matching_precision": matching["precision"] >= ACCEPTANCE_THRESHOLDS["matching_precision"],
"matching_recall": matching["recall"] >= ACCEPTANCE_THRESHOLDS["matching_recall"],
"contact_precision": contacts["precision"] >= ACCEPTANCE_THRESHOLDS["contact_precision"],
"contact_recall": contacts["recall"] >= ACCEPTANCE_THRESHOLDS["contact_recall"],
"website_accuracy": website["accuracy"] >= ACCEPTANCE_THRESHOLDS["website_accuracy"],
"score_reproducible": scoring["reproducible"], "tenant_leakage": leakage == 0,
}
return {"benchmark": "phase16", "version": 1, "fixture": str(FIXTURE_PATH.relative_to(ROOT)),
"offline": True, "limitations": data["limitations"], "acceptance_thresholds": ACCEPTANCE_THRESHOLDS,
"results": results, "checks": checks, "passed": all(checks.values())}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--no-latency", action="store_true", help="omit variable timing measurements")
args = parser.parse_args()
report = run_benchmark(measure_latency=not args.no_latency)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2, sort_keys=True))
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env python3
"""Run the deterministic, local Phase 17 final-acceptance gate.
This is a release-evidence collector, not a deployment tool. It performs only
local tests and static/configuration checks; it never sends outreach or calls a
provider. Capacity figures are bounded synthetic smoke measurements, not
production capacity claims.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API = ROOT / "apps" / "api"
REPORT = ROOT / "docs" / "benchmarks" / "final_acceptance.latest.json"
SCHEMA = ROOT / "docs" / "benchmarks" / "final_acceptance.schema.json"
def deterministic_json(value: object) -> str:
return json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
def build_capacity_smoke() -> dict:
"""Measure fixed, in-memory bounds with no clock or network dependence."""
total = 1000
requested = 100
page = list(range(total))[0:requested]
batch_input = 5000
retained = min(batch_input, 100)
return {
"bounded": len(page) <= requested and retained <= 100,
"limits": {"max_page_size": 100, "max_batch_items_retained": 100},
"pagination": {
"synthetic_total": total,
"requested_page_size": requested,
"returned_items": len(page),
"has_more": total > requested,
},
"large_batch": {
"input_items": batch_input,
"retained_items": retained,
"truncated": batch_input > retained,
},
"interpretation": "Deterministic in-memory smoke only; not a production capacity or throughput claim.",
}
def validate_report(report: dict) -> list[str]:
required = {"report", "version", "scope", "passed", "checks", "capacity_smoke", "blockers", "limitations"}
errors = [f"missing:{key}" for key in sorted(required - set(report))]
if report.get("report") != "phase17-final-acceptance": errors.append("report:const")
if type(report.get("version")) is not int or report.get("version") != 1: errors.append("version:type-or-const")
if report.get("scope") != "local-repository-acceptance": errors.append("scope:const")
if not isinstance(report.get("passed"), bool): errors.append("passed:type")
if not isinstance(report.get("checks"), dict): errors.append("checks:type")
if not isinstance(report.get("capacity_smoke"), dict): errors.append("capacity_smoke:type")
if not isinstance(report.get("limitations"), list) or not all(isinstance(x, str) for x in report.get("limitations", [])):
errors.append("limitations:type")
blockers = report.get("blockers", [])
if not isinstance(blockers, list) or not all(isinstance(x, dict) for x in blockers):
errors.append("blockers:type")
else:
for index, blocker in enumerate(blockers):
if not {"gate", "status", "reason"}.issubset(blocker): errors.append(f"blockers[{index}]:required")
return errors
def run_command(name: str, args: list[str], *, cwd: Path = ROOT, timeout: int = 300) -> dict:
if shutil.which(args[0]) is None:
return {"passed": False, "status": "unavailable", "command": args, "summary": f"{args[0]} not installed"}
try:
completed = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
except subprocess.TimeoutExpired:
return {"passed": False, "status": "timeout", "command": args, "summary": f"{name} exceeded {timeout}s"}
output = completed.stdout or ""
match = re.search(r"Ran (\d+) tests?", output)
summary = f"exit={completed.returncode}"
if match: summary += f", tests={match.group(1)}"
if completed.returncode and output:
summary += ": " + " ".join(output.strip().splitlines()[-2:])[:500]
return {"passed": completed.returncode == 0, "status": "passed" if completed.returncode == 0 else "failed", "command": args, "summary": summary}
def validate_json_files() -> dict:
files = [ROOT / "docs" / "benchmarks" / "phase16.latest.json", ROOT / "docs" / "benchmarks" / "phase16.schema.json", SCHEMA]
results = {}
parsed = {}
for path in files:
key = str(path.relative_to(ROOT))
try:
parsed[key] = json.loads(path.read_text(encoding="utf-8"))
results[key] = {"passed": True, "status": "valid_json"}
except (OSError, json.JSONDecodeError) as exc:
results[key] = {"passed": False, "status": "invalid_json", "summary": str(exc)}
phase16 = parsed.get("docs/benchmarks/phase16.latest.json")
if isinstance(phase16, dict):
required = {"benchmark", "version", "offline", "limitations", "acceptance_thresholds", "results", "checks", "passed"}
schema_ok = required.issubset(phase16) and phase16.get("benchmark") == "phase16" and phase16.get("version") == 1 and phase16.get("offline") is True and isinstance(phase16.get("passed"), bool)
results["phase16_schema_contract"] = {"passed": schema_ok, "status": "schema_valid" if schema_ok else "schema_invalid"}
else:
results["phase16_schema_contract"] = {"passed": False, "status": "schema_invalid"}
return {"passed": all(item["passed"] for item in results.values()), "files": results}
def safety_checks() -> dict:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
config = (API / "app" / "config.py").read_text(encoding="utf-8")
main = (API / "app" / "main.py").read_text(encoding="utf-8")
tests = "\n".join(p.read_text(encoding="utf-8") for p in (API / "tests").glob("test_*.py"))
checks = {
"outreach_disabled": 'AUTOMATED_OUTREACH_ENABLED: "false"' in compose and '"false"' in config and 'outreach_enabled' in main,
"no_send_network": '"network_send": False' in main and "urllib.request" not in main and "smtplib" not in main,
"tenant_routes": "organization_id=?" in main and "session_user" in main and "require_auth" in main and "tenant" in tests.lower(),
}
return {"passed": all(checks.values()), "checks": checks}
def git_state() -> dict:
result = run_command("git-state", ["git", "status", "--short", "--branch"])
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True, capture_output=True, check=False)
porcelain = subprocess.run(["git", "status", "--porcelain"], cwd=ROOT, text=True, capture_output=True, check=False)
return {
"passed": result["passed"] and head.returncode == 0 and bool(head.stdout.strip()),
"status": result["status"],
"branch_status": result.get("summary", ""),
"head": head.stdout.strip(),
"working_tree_clean": porcelain.returncode == 0 and not porcelain.stdout.strip(),
}
def build_report() -> dict:
checks = {
"api_tests": run_command("api-tests", [sys.executable, "-m", "unittest", "discover", "-v", "-s", "apps/api/tests", "-t", "apps/api"]),
"py_compile": run_command("py_compile", [sys.executable, "-m", "py_compile", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.py"))], *[str(p.relative_to(ROOT)) for p in sorted((API / "app").glob("*.py"))]]),
"benchmark_no_latency": run_command("benchmark-no-latency", [sys.executable, "scripts/benchmark_phase16.py", "--no-latency", "--output", "/tmp/prospect-phase16-acceptance.json"]),
"shell_syntax": run_command("shell-syntax", ["bash", "-n", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.sh"))]]),
"json_validation": validate_json_files(),
"compose_config": run_command("compose-config", ["docker", "compose", "-f", "docker-compose.yml", "config", "--quiet"]),
"git_state": git_state(),
"safety_invariants": safety_checks(),
}
blockers = [
{"gate": "remote_auth", "status": "blocked", "reason": "Remote repository authentication and branch permission are not available to this local acceptance run."},
{"gate": "deployment_access", "status": "blocked", "reason": "No production host, Docker/Compose, DNS/TLS, secrets, or deployment access is available; no deployment was attempted."},
{"gate": "real_provider_legal", "status": "blocked", "reason": "Real source/provider enablement, consent/legal basis, terms, and operational approval remain explicit gates; outreach stays disabled."},
]
return {
"report": "phase17-final-acceptance", "version": 1,
"scope": "local-repository-acceptance",
"passed": all(item.get("passed", False) for item in checks.values()),
"checks": checks,
"capacity_smoke": build_capacity_smoke(),
"blockers": blockers,
"limitations": ["Synthetic capacity smoke measurements do not establish production throughput, concurrency, durability, or availability."],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=REPORT)
args = parser.parse_args()
report = build_report()
errors = validate_report(report)
if errors: raise SystemExit("invalid acceptance report: " + ", ".join(errors))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(deterministic_json(report), encoding="utf-8")
print(deterministic_json(report), end="")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
set -eu
URL=${HEALTHCHECK_URL:-http://127.0.0.1:8000/api/v1/health/ready}
TIMEOUT=${HEALTHCHECK_TIMEOUT:-5}
python3 - "$URL" "$TIMEOUT" <<'PY'
import json, sys, urllib.request
url, timeout = sys.argv[1], float(sys.argv[2])
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
payload=json.loads(response.read())
if response.status != 200 or payload.get('status') != 'ok' or payload.get('ready') is not True:
raise RuntimeError('service is not ready')
if payload.get('outreach_enabled') is not False: raise RuntimeError('outreach safety check failed')
except Exception as exc:
print(f'healthcheck failed: {type(exc).__name__}', file=sys.stderr)
raise SystemExit(1)
print('ok')
PY
-43
View File
@@ -1,43 +0,0 @@
#!/bin/sh
set -eu
umask 077
if [ "${3:-}" != "--confirm-restore" ]; then
echo 'Refusing restore: pass --confirm-restore explicitly.' >&2
exit 2
fi
SOURCE=${1:-}
TARGET=${2:-${PROSPECT_API_DB:-${DATA_DIR:-/data}/prospects.db}}
BACKUP_DIR=${BACKUP_DIR:-$(dirname -- "$SOURCE")}
case "$SOURCE$TARGET" in *secret*|*credentials*|*.env*) echo 'refusing secret-like path' >&2; exit 2;; esac
[ -f "$SOURCE" ] || { echo 'restore source does not exist' >&2; exit 2; }
case "$TARGET" in /*) ;; *) echo 'restore target must be an absolute path' >&2; exit 2;; esac
mkdir -p -- "$BACKUP_DIR"
if [ -f "$TARGET" ]; then
pre_restore=$("$(dirname -- "$0")/backup_sqlite.sh" "$TARGET" "$BACKUP_DIR" 30)
pre_restore_db=$(printf '%s\n' "$pre_restore" | tail -n 1)
pre_restore_name="$BACKUP_DIR/pre-restore-$(date -u +%Y%m%dT%H%M%SZ)-$$.db"
mv -- "$pre_restore_db" "$pre_restore_name"
if [ -f "$pre_restore_db.sha256" ]; then mv -- "$pre_restore_db.sha256" "$pre_restore_name.sha256"; fi
fi
python3 - "$SOURCE" "$TARGET" <<'PY'
import hashlib, os, sqlite3, sys, tempfile
from pathlib import Path
source, target = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
if not source.is_file(): raise SystemExit('restore source must be a regular file')
sidecar = Path(str(source)+'.sha256')
if sidecar.exists():
expected = sidecar.read_text(encoding='ascii').split()[0]
actual = hashlib.sha256(source.read_bytes()).hexdigest()
if expected != actual: raise SystemExit('restore checksum mismatch')
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix='.restore-', suffix='.tmp', dir=target.parent); os.close(fd)
try:
with sqlite3.connect(f'file:{source}?mode=ro', uri=True) as src, sqlite3.connect(tmp) as dst:
src.backup(dst); result = dst.execute('PRAGMA integrity_check').fetchone()[0]
if result != 'ok': raise SystemExit('restored database integrity check failed')
dst.commit()
os.chmod(tmp, 0o600); os.replace(tmp, target)
print(target)
finally:
Path(tmp).unlink(missing_ok=True)
PY
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
cat >&2 <<'EOF'
Rollback is a documented, non-destructive procedure. No rollback command is executed.
1. Identify the last known-good immutable image digest/tag and configuration revision.
2. Confirm the database backup is recent and run scripts/healthcheck.sh against the candidate.
3. Review the exact rendered config: docker compose config.
4. Change the deployment's image tag/digest in the deployment system (or pin IMAGE_TAG), then restart the service through the approved change process.
5. Verify /api/v1/health/ready, representative authenticated reads, logs, and outreach_enabled=false.
6. Record the rollback reason, old/new image digests, config revision, backup/checksum, and operator.
This helper intentionally does not stop containers, delete images, restore databases, or alter production state.
EOF