diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 774f495..cd920c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: print('all entry points import cleanly') " + - name: Unit tests + run: uv run python -m unittest discover -v + # Exercises extractors, slicers, prompt templates and the programmatic # checkers end to end. --dry-run makes no API calls, so this needs no key # and costs nothing. diff --git a/DEPLOY.md b/DEPLOY.md index ee300fb..6a0620a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -21,11 +21,16 @@ environment variables below, attach a domain, and deploy. | `HOST` | No | Set to `0.0.0.0` by the image; do not override | | `PORT` | No | Defaults to 8000; Coolify may inject its own | | `HOST_PORT` | No | Local-only: host port for `docker compose up` (default 8000) | +| `DAT_DAILY_MONITOR_EMAIL` | For daily monitoring | Recipient for the 24-hour usage and health report | +| `DAT_MONITOR_COLLECTION` | No | Firestore monitor-run collection; defaults to `dat_site_audit_monitor_runs` | +| `DAT_USAGE_COLLECTION` | No | Privacy-safe synchronous-audit usage events; defaults to `dat_audit_usage_events` | +| `DAT_SITE_PASSWORD` | For administrator tools | Shared password for Analytics and both bulk-audit modes; store in Secret Manager | +| `DAT_OPENAI_API_KEY` | For administrator tools | Saved OpenAI key available only to authenticated administrators; store in Secret Manager | -**No provider API keys are configured, deliberately.** Each user pastes their -own key into the web form, so the deployment never bills a shared account. -Per-request keys take priority over the environment -(`api_server.py:_resolve_api_key`). +Public users paste their own key into the web form and never inherit provider +keys from the service environment. An authenticated administrator may use the +saved `DAT_OPENAI_API_KEY`; per-request keys take priority. The raw saved key is +never returned to the browser. Consequence worth knowing: **a request with no resolvable key silently runs a dry run** — programmatic checks only, no LLM findings, no CSV report, and a @@ -56,7 +61,7 @@ the clone), but local `docker compose up` is. ## Proxy configuration — read this one -`POST /api/audit`, `/api/audit/url`, and `/api/audit/url/nested` return an +`POST /api/audit` and `/api/audit/url` return an **NDJSON stream** that stays open for the whole audit, emitting progress events as it goes. Two default proxy behaviours will break this: @@ -64,9 +69,8 @@ as it goes. Two default proxy behaviours will break this: until the audit finishes, so the browser shows a frozen progress bar and then all results at once. Disable buffering for `/api/`. 2. **Read timeouts.** A single-page audit runs 17+ sequential LLM calls and - commonly takes 1–3 minutes; a nested crawl audits every discovered page and - can run far longer. Anything under ~10 minutes risks cutting audits off - mid-run. Traefik's default is generous, but Coolify installs vary — verify. + commonly takes 1–3 minutes. Anything under ~10 minutes risks cutting audits + off mid-run. Traefik's default is generous, but Coolify installs vary — verify. In Coolify, set these as Traefik labels on the service, or raise the equivalent values in the proxy settings: @@ -96,8 +100,52 @@ Audits are I/O-bound on the LLM API, not CPU-bound. 512 MB RAM and 0.5 vCPU is enough for light use. The server is a stdlib `ThreadingHTTPServer`, so each concurrent audit holds a thread and its own temp directory — fine for a handful of simultaneous users, not for public high traffic. The `tmpfs` mount is capped -at 512 MB; a large nested crawl of very large pages could approach that, so -raise it if you see failures writing temp files. +at 512 MB; large pages could approach that, so raise it if you see failures +writing temp files. + +## Daily DAT usage and health monitor + +The authenticated endpoint `POST /api/internal/site-audits/daily-monitor` +collects audits active during the preceding 24 hours. It reports +each scanned site, mode, status, model, pages processed, page failures, and +estimated AI cost. It also checks the public health endpoint, Firestore, report +storage, background-audit configuration, the saved model credential, email +configuration, and jobs that have stopped progressing for more than six hours. +It also saves an HTML copy in private report storage. The protected Analytics +page lists the latest 30 copies; older daily report records and objects are +removed automatically. +Synchronous modes store only operational totals (site host when available, +model, status, token totals, estimated cost, and page counts), never uploaded +HTML, provider credentials, or requester information. + +Cloud Scheduler should call the endpoint at 6:00 a.m. in the +`America/New_York` timezone. Use the same Secret Manager value already supplied +to the service as `DAT_JOB_TOKEN`; never place the token in source control. + +```powershell +$datJobToken = (gcloud secrets versions access latest ` + --secret ability-bazaar-dat-job-token ` + --project ability-bazaar-2026 | Out-String).Trim() + +gcloud scheduler jobs create http ability-bazaar-dat-daily-monitor ` + --project ability-bazaar-2026 ` + --location us-east1 ` + --schedule "0 6 * * *" ` + --time-zone "America/New_York" ` + --uri "https://ability-bazaar-dat-621739355963.us-east1.run.app/api/internal/site-audits/daily-monitor" ` + --http-method POST ` + --headers "Content-Type=application/json,X-DAT-Job-Token=$datJobToken" ` + --message-body "{}" ` + --attempt-deadline 5m ` + --max-retry-attempts 3 + +$datJobToken = $null +``` + +The endpoint deduplicates successful scheduled reports by Eastern calendar +date, so a Scheduler retry does not send a second daily email. Failed email +attempts remain retryable. A manual verification can pass +`{"send_email":false}` to exercise the health and usage checks without sending. ## Serving diff --git a/Dockerfile b/Dockerfile index 555501e..d318c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,15 @@ RUN uv sync --frozen --no-dev FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + DAT_CHROMIUM_EXECUTABLE=/usr/bin/chromium + +# SiteGround and similar CDNs can return JavaScript security interstitials to +# cloud data-center traffic. Chromium executes the legitimate public-site +# challenge so the auditor receives the requested page, never the interstitial. +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium \ + && rm -rf /var/lib/apt/lists/* # Single chowned COPY: the virtualenv lands in exactly one layer. Copying it # after creating it, rather than chown -R'ing it in place, keeps the image from diff --git a/README.md b/README.md index 909446b..b584b0c 100644 --- a/README.md +++ b/README.md @@ -244,9 +244,23 @@ GEMINI_API_KEY=AIza... uv run python entry_points/api_server.py # http://localhost:8000 ``` -Serves the UI and the audit API from one process. Users can paste their own API -key into the form instead of configuring one server-side; per-request keys take -priority over the environment. +Serves the UI and the audit API from one process. The public page has no +password and offers **HTML File** and **URL — Single Page**. Public users may +paste their own provider key; public requests never inherit a server credential. + +The **Analytics** link opens the password-protected administrator area. It +provides cumulative privacy-safe usage totals, the most recent 30 daily web +reports, and two asynchronous bulk-audit modes with the same model, saved-key +support, email, progress, consolidated findings, and token/cost reporting: + +- **Full Site — Crawl and Email**: starts with one public URL, + discovers same-site pages, and audits up to 200 pages. +- **Full Site — Batch and Email**: accepts a UTF-8/plain-text `.txt` file or a + Word `.docx` file containing up to 200 public HTTP(S) URLs. It deduplicates + the list and audits the supplied pages directly without crawling. + +The list upload is limited to 2 MB. Legacy `.doc` files are not supported; +save them as `.docx` or plain text first. The audit endpoints stream **NDJSON** — progress events, one JSON object per line, then a final `{"type":"result"}` object. Parsing that body with a single diff --git a/analytics.html b/analytics.html new file mode 100644 index 0000000..6816077 --- /dev/null +++ b/analytics.html @@ -0,0 +1,165 @@ + + + + + + + Analytics | Vision Aid DAT + + + + + +
+
+
+ +

Analytics

+

Private usage totals, administrator audit tools, and the last 30 daily reports.

+
+ +
+

Usage totals

+
+

Loading analytics…

+
+

+
+ +
+

Administrator audit tools

+ +
+ +
+

Daily reports

+

Reports are retained here for 30 days.

+
+ + + + + + + + + + + + + + +
DateHealthAuditsPagesCostReport
Loading daily reports…
+
+ +
+
+
+ + + diff --git a/deploy/report-bucket-lifecycle.json b/deploy/report-bucket-lifecycle.json new file mode 100644 index 0000000..c6ccc16 --- /dev/null +++ b/deploy/report-bucket-lifecycle.json @@ -0,0 +1,8 @@ +{ + "rule": [ + { + "action": {"type": "Delete"}, + "condition": {"age": 30} + } + ] +} diff --git a/entry_points/api_server.py b/entry_points/api_server.py index 0f61b4f..98b2fd4 100644 --- a/entry_points/api_server.py +++ b/entry_points/api_server.py @@ -11,14 +11,16 @@ Then open http://localhost:8000 in your browser. -API keys can be supplied per-request in the POST body, or set in the .env -file / environment. Per-request keys take priority. +Public API keys can be supplied per request. Saved server credentials are used +only for requests carrying a valid administrator cookie. - Anthropic models → field ``api_key`` / env ``ANTHROPIC_API_KEY`` - OpenAI models → field ``openai_api_key`` / env ``OPENAI_API_KEY`` + Anthropic models → field ``api_key`` + OpenAI models → field ``openai_api_key`` """ import json +import hashlib +import hmac import os import re import shutil @@ -26,6 +28,7 @@ import tempfile from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from urllib.parse import parse_qs, urlparse from dotenv import load_dotenv @@ -37,6 +40,9 @@ from entry_points.generate_report import generate_report # noqa: E402 from vision_aid.ingestion.file_crawler import fetch_page, fetch_pages_nested # noqa: E402 from processing_scripts.llm_client.client import is_openai_model, is_gemini_model # noqa: E402 +from vision_aid.site_audit.crawler import validate_public_url # noqa: E402 +from vision_aid.site_audit.jobs import get_coordinator # noqa: E402 +from vision_aid.site_audit.url_list import decode_uploaded_urls # noqa: E402 # ── Multi-page splitting ───────────────────────────────────────────────────── @@ -67,28 +73,26 @@ def split_pages(html: str) -> list[tuple[str, str]]: # ── Key resolution ──────────────────────────────────────────────────────────── -def _resolve_api_key(data: dict, model: str) -> str: - """Return the appropriate API key for *model* from the request body or env. - - OpenAI models use the ``openai_api_key`` field / ``OPENAI_API_KEY`` env. - Gemini models use the ``gemini_api_key`` field / ``GEMINI_API_KEY`` env. - Anthropic models use the ``api_key`` field / ``ANTHROPIC_API_KEY`` env. - Per-request keys take priority over environment variables. +def _resolve_api_key( + data: dict, + model: str, + *, + saved_openai_api_key: str = "", +) -> str: + """Return a request key, with an explicit admin-only OpenAI fallback. + + Public requests never inherit provider credentials from the process + environment. The caller may supply the saved OpenAI key only after it has + independently verified the admin session. """ if is_openai_model(model): return ( data.get("openai_api_key", "").strip() - or os.getenv("OPENAI_API_KEY", "") + or str(saved_openai_api_key or "").strip() ) if is_gemini_model(model): - return ( - data.get("gemini_api_key", "").strip() - or os.getenv("GEMINI_API_KEY", "") - ) - return ( - data.get("api_key", "").strip() - or os.getenv("ANTHROPIC_API_KEY", "") - ) + return data.get("gemini_api_key", "").strip() + return data.get("api_key", "").strip() # ── Audit logic ─────────────────────────────────────────────────────────────── @@ -242,15 +246,76 @@ def do_OPTIONS(self): # noqa: N802 # GET ────────────────────────────────────────────────────────────────────── def do_GET(self): # noqa: N802 - path = self.path.split("?")[0] + request_url = urlparse(self.path) + path = request_url.path + analytics_page = path in { + "/analytics", + "/analytics/full-site", + "/analytics/url-list", + } + analytics_report = re.fullmatch( + r"/analytics/reports/\d{4}-\d{2}-\d{2}", path + ) + analytics_api = path == "/api/admin/analytics" + protected_job_api = re.fullmatch( + r"/api/site-audits/[A-Za-z0-9_-]+(?:/report)?", path + ) + if ( + analytics_page + or analytics_report + or analytics_api + or protected_job_api + ) and not self._admin_access_allowed(): + if analytics_api or protected_job_api: + self._send_json( + {"success": False, "error": "Administrator sign-in required"}, + 401, + ) + else: + self._serve_login_page(path) + return + if path in ("/", "/index.html"): self._serve_file(STATIC_DIR / "index.html", "text/html; charset=utf-8") + elif path == "/login": + requested_next = parse_qs(request_url.query).get("next", ["/"])[0] + self._serve_login_page(requested_next) + elif path == "/analytics": + self._serve_file( + STATIC_DIR / "analytics.html", + "text/html; charset=utf-8", + cache_control="no-store", + ) + elif path in {"/analytics/full-site", "/analytics/url-list"}: + self._serve_file( + STATIC_DIR / "index.html", + "text/html; charset=utf-8", + cache_control="no-store", + ) + elif path == "/api/admin/analytics": + self._handle_admin_analytics() + elif analytics_report: + self._handle_daily_monitor_report(path) elif path == "/styles.css": self._serve_file(STATIC_DIR / "styles.css", "text/css; charset=utf-8") + elif path == "/api/health": + self._handle_health() + elif path == "/api/site-audit-config": + self._handle_site_audit_config() + elif re.fullmatch(r"/api/site-audits/[A-Za-z0-9_-]+/report", path): + self._handle_site_audit_report(path) + elif re.fullmatch(r"/api/site-audits/[A-Za-z0-9_-]+", path): + self._handle_site_audit_status(path) else: self.send_error(404, "Not Found") - def _serve_file(self, file_path: Path, content_type: str): + def _serve_file( + self, + file_path: Path, + content_type: str, + *, + cache_control: str = "", + ): if not file_path.exists(): self.send_error(404, "Not Found") return @@ -258,6 +323,8 @@ def _serve_file(self, file_path: Path, content_type: str): self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) + if cache_control: + self.send_header("Cache-Control", cache_control) self._cors_headers() self.end_headers() self.wfile.write(body) @@ -265,7 +332,26 @@ def _serve_file(self, file_path: Path, content_type: str): # POST ───────────────────────────────────────────────────────────────────── def do_POST(self): # noqa: N802 - if self.path == "/api/audit": + if self.path == "/api/login": + self._handle_login() + elif self.path == "/api/logout": + self._handle_logout() + elif self.path.startswith("/api/internal/site-audits/"): + operation = self.path.rsplit("/", 1)[-1] + if operation not in {"discover", "page", "finalize", "daily-monitor"}: + self.send_error(404, "Not Found") + return + self._handle_internal_site_audit(operation) + elif ( + self.path == "/api/site-audits" + or self.path == "/api/audit/url/nested" + or re.fullmatch(r"/api/site-audits/[A-Za-z0-9_-]+/resend", self.path) + ) and not self._admin_access_allowed(): + self._send_json( + {"success": False, "error": "Administrator sign-in required"}, + 401, + ) + elif self.path == "/api/audit": self._handle_audit() elif self.path == "/api/audit/url": self._handle_url_audit(nested=False) @@ -273,9 +359,104 @@ def do_POST(self): # noqa: N802 self._handle_url_audit(nested=True) elif self.path == "/api/validate-key": self._handle_validate_key() + elif self.path == "/api/site-audits": + self._handle_create_site_audit() + elif re.fullmatch(r"/api/site-audits/[A-Za-z0-9_-]+/resend", self.path): + self._handle_site_audit_resend(self.path) else: self.send_error(404, "Not Found") + def _admin_cookie(self) -> str: + password = os.getenv("DAT_SITE_PASSWORD", "").strip() + if not password: + return "" + return hashlib.sha256(f"vision-aid-dat:{password}".encode("utf-8")).hexdigest() + + def _admin_access_allowed(self) -> bool: + """Return whether the administrator cookie is valid.""" + expected = self._admin_cookie() + if not expected: + return False + cookies = self.headers.get("Cookie", "") + supplied = "" + for item in cookies.split(";"): + name, _, value = item.strip().partition("=") + if name == "dat_admin": + supplied = value + break + return bool(supplied and hmac.compare_digest(expected, supplied)) + + @staticmethod + def _safe_admin_return_path(next_path: str) -> str: + allowed_paths = { + "/", + "/analytics", + "/analytics/full-site", + "/analytics/url-list", + } + return next_path if next_path in allowed_paths else "/" + + def _serve_login_page(self, next_path: str = "/"): + safe_next_path = self._safe_admin_return_path(next_path) + page = """ +Vision Aid DAT administrator sign in + +

Vision Aid Digital Accessibility Testing

Administrator sign in

+
+

Back to the public audit tool

+""" + body = page.replace("__NEXT_PATH__", json.dumps(safe_next_path)).encode( + "utf-8" + ) + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _handle_login(self): + try: + data = self._read_json_body() + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return + expected_password = os.getenv("DAT_SITE_PASSWORD", "").strip() + supplied = str(data.get("password", "")) + if not expected_password or not hmac.compare_digest(expected_password, supplied): + self._send_json({"success": False, "error": "Incorrect password"}, 401) + return + body = json.dumps({"success": True}).encode("utf-8") + secure_flag = "; Secure" if os.getenv("K_SERVICE") or self.headers.get( + "X-Forwarded-Proto", "" + ).lower() == "https" else "" + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header( + "Set-Cookie", + f"dat_admin={self._admin_cookie()}; Path=/; Max-Age=28800; HttpOnly; SameSite=Strict{secure_flag}", + ) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _handle_logout(self): + body = json.dumps({"success": True}).encode("utf-8") + secure_flag = "; Secure" if os.getenv("K_SERVICE") or self.headers.get( + "X-Forwarded-Proto", "" + ).lower() == "https" else "" + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header( + "Set-Cookie", + f"dat_admin=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict{secure_flag}", + ) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + def _start_ndjson_stream(self): """Send NDJSON response headers and return a send_event callable.""" self.send_response(200) @@ -290,6 +471,14 @@ def send_event(obj: dict) -> None: return send_event + @staticmethod + def _record_sync_usage(**kwargs) -> None: + """Record privacy-safe usage totals without making an audit depend on telemetry.""" + try: + get_coordinator().record_usage_event(**kwargs) + except Exception as exc: + print(f" Usage event recording failed ({type(exc).__name__})") + def _handle_audit(self): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) @@ -308,7 +497,13 @@ def _handle_audit(self): return model = data.get("model", "claude-haiku-4-5-20251001") - api_key = _resolve_api_key(data, model) + api_key = _resolve_api_key( + data, + model, + saved_openai_api_key=( + get_coordinator().api_key if self._admin_access_allowed() else "" + ), + ) print( f" Audit request: {len(html_content):,} chars, " @@ -322,6 +517,11 @@ def _handle_audit(self): "message": "Starting audit…", }) result = run_audit(html_content, api_key, model, progress_callback=send_event) + self._record_sync_usage( + audit_mode="html_upload", + model=model, + result=result, + ) result["type"] = "result" send_event(result) @@ -340,9 +540,20 @@ def _handle_url_audit(self, nested: bool): if not url: self._send_json({"success": False, "error": "url is required"}, 400) return + try: + url = validate_public_url(url) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return model = data.get("model", "claude-haiku-4-5-20251001") - api_key = _resolve_api_key(data, model) + api_key = _resolve_api_key( + data, + model, + saved_openai_api_key=( + get_coordinator().api_key if self._admin_access_allowed() else "" + ), + ) print( f" URL audit request ({'nested' if nested else 'single'}): {url}, " @@ -360,6 +571,12 @@ def _handle_url_audit(self, nested: bool): html_content = fetch_page(url) except Exception as exc: result = {"type": "result", "success": False, "error": f"Failed to fetch URL: {exc}"} + self._record_sync_usage( + audit_mode="single_url", + model=model, + result=result, + base_url=url, + ) send_event(result) return print(f" [url_audit] Fetched {len(html_content):,} chars from {url}") @@ -374,6 +591,12 @@ def _handle_url_audit(self, nested: bool): "message": "Page fetched — starting analysis…", }) result = run_audit(html_content, api_key, model, progress_callback=send_event) + self._record_sync_usage( + audit_mode="single_url", + model=model, + result=result, + base_url=url, + ) result["type"] = "result" send_event(result) return @@ -382,6 +605,12 @@ def _handle_url_audit(self, nested: bool): try: html_content, crawl_tree = fetch_pages_nested(url) except Exception as exc: + self._record_sync_usage( + audit_mode="legacy_crawl", + model=model, + result={"success": False}, + base_url=url, + ) self._send_json({"success": False, "error": f"Failed to fetch URL: {exc}"}, 502) return @@ -515,60 +744,263 @@ def _handle_url_audit(self, nested: bool): merged["csv_report"] = "\n".join(csv_parts) + "\n" # Final event: the full merged result + self._record_sync_usage( + audit_mode="legacy_crawl", + model=model, + result=merged, + base_url=url, + pages_total=total_pages, + pages_completed=len(merged["pages_audited"]), + pages_failed=total_pages - len(merged["pages_audited"]), + ) merged["type"] = "result" _send_event(merged) def _handle_validate_key(self): - """Validate an API key with a lightweight call to the provider.""" - import urllib.request - import urllib.error - - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length) + """Validate an entered or saved API key without returning the key.""" try: - data = json.loads(body) - except json.JSONDecodeError: - self._send_json({"valid": False, "error": "Invalid JSON"}, 400) + data = self._read_json_body() + except ValueError as exc: + self._send_json({"valid": False, "error": str(exc)}, 400) return - api_key = data.get("api_key", "").strip() provider = data.get("provider", "anthropic") + default_models = { + "openai": "gpt-5.6-sol", + "gemini": "gemini-flash-latest", + "anthropic": "claude-haiku-4-5-20251001", + } + model = str(data.get("model") or default_models.get(provider, "")).strip() + try: + valid, message = get_coordinator().verify_requested_key( + model=model, + api_key=str(data.get("api_key", "")), + use_saved=bool(data.get("use_saved")), + refresh=True, + allow_saved_key=self._admin_access_allowed(), + ) + self._send_json( + { + "valid": valid, + "message": message if valid else "", + "error": "" if valid else message, + "model": model, + } + ) + except ValueError as exc: + self._send_json({"valid": False, "error": str(exc)}, 400) + + def _read_json_body(self, *, max_bytes: int = 64 * 1024) -> dict: + """Read and parse one bounded JSON request body.""" + length = int(self.headers.get("Content-Length", 0)) + if length <= 0 or length > max_bytes: + raise ValueError("A JSON request body is required") + try: + value = json.loads(self.rfile.read(length)) + except json.JSONDecodeError as exc: + raise ValueError("Invalid JSON body") from exc + if not isinstance(value, dict): + raise ValueError("JSON body must be an object") + return value + + def _handle_health(self): + """Return non-secret service health and async feature readiness.""" + coordinator = get_coordinator() + self._send_json( + { + "status": "ok", + "service": "vision-aid-dat", + "async_site_audit_configured": coordinator.configured, + "async_model": coordinator.model, + "max_site_pages": 200, + "credential_override_encryption_configured": bool( + coordinator.credential_encryption_key + ), + } + ) - if not api_key: - self._send_json({"valid": False, "error": "No key provided"}) + def _handle_site_audit_config(self): + """Return saved-key verification state without exposing credentials.""" + query = parse_qs(urlparse(self.path).query) + model = str((query.get("model") or [""])[0]).strip() or None + refresh = str((query.get("refresh") or [""])[0]).lower() in { + "1", + "true", + "yes", + } + try: + config = get_coordinator().public_config( + model=model, + refresh=refresh, + allow_saved_key=self._admin_access_allowed(), + ) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) return + self._send_json({"success": True, "config": config}) + def _handle_create_site_audit(self): + """Create a durable crawl or uploaded URL-list audit and return immediately.""" try: - if provider == "openai": - req = urllib.request.Request( - "https://api.openai.com/v1/models", - headers={"Authorization": f"Bearer {api_key}"}, + data = self._read_json_body(max_bytes=3 * 1024 * 1024) + audit_mode = str(data.get("audit_mode") or "crawl").strip().lower() + source_file_name = "" + uploaded_urls = None + if audit_mode == "url_list": + source_file_name, uploaded_urls = decode_uploaded_urls( + data.get("url_file_name", ""), + data.get("url_file_base64", ""), ) - elif provider == "gemini": - req = urllib.request.Request( - f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}", - ) - else: - req = urllib.request.Request( - "https://api.anthropic.com/v1/models", - headers={ - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - }, + job = get_coordinator().create_job( + base_url=data.get("url", ""), + email=data.get("email", ""), + model=data.get("model", ""), + api_key=data.get("api_key", ""), + audit_mode=audit_mode, + uploaded_urls=uploaded_urls, + source_file_name=source_file_name, + allow_saved_key=self._admin_access_allowed(), + ) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return + except Exception as exc: + print(f" Site audit creation failed: {exc}") + self._send_json( + {"success": False, "error": "Could not queue the site audit"}, 503 + ) + return + self._send_json({"success": True, "job": job}, 202) + + def _handle_site_audit_status(self, path: str): + job_id = path.rstrip("/").rsplit("/", 1)[-1] + coordinator = get_coordinator() + job = coordinator.get_job(job_id) + if not job: + self._send_json({"success": False, "error": "Job not found"}, 404) + return + self._send_json({"success": True, "job": coordinator.public_job(job)}) + + def _handle_site_audit_report(self, path: str): + job_id = path.split("/")[-2] + report = get_coordinator().report_bytes(job_id) + if report is None: + self._send_json({"success": False, "error": "Report is not ready"}, 404) + return + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header( + "Content-Disposition", 'attachment; filename="DAT-whole-site-report.zip"' + ) + self.send_header("Content-Length", str(len(report))) + self._cors_headers() + self.end_headers() + self.wfile.write(report) + + def _handle_site_audit_resend(self, path: str): + job_id = path.split("/")[-2] + try: + job = get_coordinator().resend_report(job_id) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return + except Exception as exc: + print(f" Site audit email resend failed: {exc}") + self._send_json( + {"success": False, "error": "The mail server did not accept the resend"}, + 503, + ) + return + self._send_json({"success": True, "job": job}) + + def _handle_admin_analytics(self): + """Return privacy-safe administrator analytics and recent reports.""" + try: + coordinator = get_coordinator() + self._send_json( + { + "success": True, + "summary": coordinator.analytics_summary(), + "reports": coordinator.list_daily_monitor_reports(limit=30), + } + ) + except Exception as exc: + print(f" Administrator analytics failed: {exc}") + self._send_json( + {"success": False, "error": "Analytics are temporarily unavailable"}, + 503, + ) + + def _handle_daily_monitor_report(self, path: str): + """Serve one retained private daily report to an administrator.""" + report_date = path.rstrip("/").rsplit("/", 1)[-1] + try: + body = get_coordinator().daily_monitor_report_bytes(report_date) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return + except Exception as exc: + print(f" Daily monitor report retrieval failed: {exc}") + self._send_json( + {"success": False, "error": "The daily report is unavailable"}, + 503, + ) + return + if body is None: + self.send_error(404, "Report not found") + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header( + "Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", + ) + self.end_headers() + self.wfile.write(body) + + def _handle_internal_site_audit(self, operation: str): + """Handle authenticated callbacks from the dedicated Cloud Tasks queue.""" + coordinator = get_coordinator() + supplied_token = self.headers.get("X-DAT-Job-Token", "") + if not coordinator.internal_token_valid(supplied_token): + self._send_json({"success": False, "error": "Not found"}, 404) + return + try: + data = self._read_json_body() + if operation == "daily-monitor": + result = coordinator.run_daily_monitor( + schedule_time=( + self.headers.get("X-CloudScheduler-ScheduleTime", "") + or str(data.get("schedule_time") or "") + ), + send_email=bool(data.get("send_email", True)), + force=bool(data.get("force", False)), ) - with urllib.request.urlopen(req, timeout=10): - self._send_json({"valid": True}) - except urllib.error.HTTPError as exc: - if exc.code in (401, 403): - self._send_json( - {"valid": False, "error": "Invalid or unauthorized API key"} + elif operation == "discover": + job_id = data.get("job_id", "") + result = coordinator.run_discovery(job_id) + elif operation == "page": + job_id = data.get("job_id", "") + retry_count = int(self.headers.get("X-CloudTasks-TaskRetryCount", "0")) + result = coordinator.run_page( + job_id=job_id, + page_id=data.get("page_id", ""), + retry_count=retry_count, + audit_callable=run_audit, ) else: - self._send_json( - {"valid": False, "error": f"Provider returned HTTP {exc.code}"} - ) + job_id = data.get("job_id", "") + result = coordinator.finalize(job_id) + except ValueError as exc: + self._send_json({"success": False, "error": str(exc)}, 400) + return except Exception as exc: - self._send_json({"valid": False, "error": str(exc)}) + print(f" Internal site audit {operation} failed: {exc}") + self._send_json({"success": False, "error": "Task failed; retrying"}, 500) + return + self._send_json({"success": True, "result": result}) def _send_json(self, obj: dict, status: int = 200): body = json.dumps(obj, indent=2, ensure_ascii=False).encode("utf-8") diff --git a/entry_points/generate_report.py b/entry_points/generate_report.py index fb86401..ddf097d 100644 --- a/entry_points/generate_report.py +++ b/entry_points/generate_report.py @@ -871,13 +871,22 @@ def deduplicate_with_llm( if is_openai: import openai client = openai.OpenAI(api_key=api_key) - response = client.chat.completions.create( - model=model, - max_tokens=512, - temperature=0.0, - messages=[{"role": "user", "content": prompt}], - ) - response_text = response.choices[0].message.content or "[]" + if model.startswith("gpt-5"): + response = client.responses.create( + model=model, + input=prompt, + max_output_tokens=512, + reasoning={"effort": "low"}, + ) + response_text = response.output_text or "[]" + else: + response = client.chat.completions.create( + model=model, + max_tokens=512, + temperature=0.0, + messages=[{"role": "user", "content": prompt}], + ) + response_text = response.choices[0].message.content or "[]" else: import anthropic from processing_scripts.llm_client.client import supports_temperature @@ -1086,4 +1095,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/entry_points/run_pipeline.py b/entry_points/run_pipeline.py index 1334021..e74ee75 100644 --- a/entry_points/run_pipeline.py +++ b/entry_points/run_pipeline.py @@ -66,6 +66,8 @@ "gpt-4.1": (2.00, 8.00), "gpt-4.1-mini": (0.40, 1.60), "gpt-4.1-nano": (0.10, 0.40), + "gpt-5.6-sol": (4.00, 20.00), + "gpt-5.6": (4.00, 20.00), "o3": (2.00, 8.00), "o3-mini": (1.10, 4.40), "o4-mini": (1.10, 4.40), @@ -181,7 +183,26 @@ def _call_anthropic(self, prompt: str, start: float) -> dict: } def _call_openai(self, prompt: str, start: float) -> dict: - """Call the OpenAI Chat Completions API.""" + """Call OpenAI, using Responses for GPT-5 reasoning models.""" + if self.model.startswith("gpt-5"): + response = self._client.responses.create( + model=self.model, + input=prompt, + max_output_tokens=self.max_tokens, + reasoning={"effort": "low"}, + ) + return { + "success": True, + "response": response.output_text, + "model": response.model, + "usage": { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + }, + "stop_reason": response.status, + "duration_seconds": round(time.time() - start, 2), + } + response = self._client.chat.completions.create( model=self.model, max_tokens=self.max_tokens, diff --git a/index.html b/index.html index e5cdb93..4633112 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ /> - + @@ -54,6 +54,15 @@ Audit Tool + + + @@ -301,8 +310,7 @@

Accessibility Audit Tool

- Provide an HTML file, or enter a URL to fetch and audit — with - optional recursive crawling of nested pages. + Audit an HTML file or one public web page.

@@ -315,23 +323,27 @@

Accessibility Audit Tool

>

Input type

-
-
+