diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 774f495..9a19a5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ permissions: jobs: checks: - name: Dependencies, imports, pipeline + name: Dependencies, lint, imports, pipeline runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -53,6 +53,12 @@ jobs: print('all entry points import cleanly') " + - name: Evaluator lint + run: uv run ruff check vision_aid/evaluation tests + + - name: Network-free test suite + run: uv run pytest -q + # 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/.gitignore b/.gitignore index 4f3a6fb..60f54c4 100644 --- a/.gitignore +++ b/.gitignore @@ -212,5 +212,27 @@ output/ # CI smoke-test output ci-output/ +# Private model-evaluation inputs and derived artifacts +/Pristine Accessibility Defect Report.xlsx +/.model-evaluation/ +/vision_aid/evaluation/human_finding_mappings/ + +# Node.js / Next.js generated files +node_modules/ +.next/ +next-env.d.ts +*.tsbuildinfo +web/prisma/generated/ + # Stray git worktrees — never belong in the index .claude/worktrees/ + +# Local-only issue tracker and detailed evaluator tests +/.scratch/ +/.local-tests/ + +# Local architecture decision records +/docs/adr/ + +# Local agent context +CONTEXT.md diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1e26040 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.terminal.useEnvFile": true +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4d00ed3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues are tracked as local Markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The default five-role triage vocabulary is used. See `docs/agents/triage-labels.md`. + +### Domain docs + +This repository uses a single-context domain-doc layout. See `docs/agents/domain.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 220212a..5a9d342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ This project builds LLM-powered tools that analyze websites for WCAG accessibility issues and generate structured remediation reports. It is a Computing for Good course project at Georgia Tech (OMSCS) partnered with the Vision Aid Digital Accessibility Testing Team. The system has three planned stages: + 1. Automated webpage accessibility analysis → structured CSV reports 2. Code remediation suggestions based on those reports 3. (Stretch) Chrome extension for on-the-fly accessibility fixes @@ -116,3 +117,17 @@ Docker image → GHCR (`ghcr.io/c4g/va-dat`) → Coolify at `https://va-dat.c4g. - HTML test files can be enormous (500K+ tokens) — do not read them fully into context. - Some files may have non-UTF-8 encoding; always use `errors='replace'`. - Model defaults differ by entry point: `run_pipeline.py` uses `claude-sonnet-5`, `api_server.py` uses `claude-haiku-4-5-20251001`. + +## Agent skills + +### Issue tracker + +Issues are tracked as local Markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The default five-role triage vocabulary is used. See `docs/agents/triage-labels.md`. + +### Domain docs + +This repository uses a single-context domain-doc layout. See `docs/agents/domain.md`. diff --git a/README.md b/README.md index 909446b..8b4e60f 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,9 @@ HTML file (e.g. 1.9 MB) │ │ ├── forms_checklist_02.txt # 6 prompts for form accessibility │ │ └── nontext_checklist_03.txt # 8 prompts for non-text content │ │ -│ ├── llm_client/ # Standalone Claude API client (Andrew) -│ │ ├── client.py # API wrapper -│ │ ├── prompt_loader.py # Prompt loading utilities -│ │ └── runner.py # End-to-end audit runner +│ ├── llm_client/ # Provider-independent audit requests +│ │ ├── audit.py # API requests and response metadata +│ │ └── client.py # Model-provider capability helpers │ │ │ ├── llm_preprocessing/ # HTML → structured JSON extractors │ │ ├── semantic_checklist_01.py # Headings, links, landmarks, tables, iframes @@ -74,9 +73,29 @@ HTML file (e.g. 1.9 MB) │ │ │ └── docs/pipeline.md # Pipeline architecture documentation │ -├── vision_aid/ingestion/ -│ ├── file_crawler.py # fetch_page / fetch_pages_nested (used by the server) -│ └── pull_html.py # Standalone HTML download helper +├── vision_aid/ +│ ├── evaluation/ # Evaluation workflows, review, and shared schemas +│ │ ├── README.md # Detailed evaluator guide and command reference +│ │ ├── cli.py # CLI commands and operator interaction +│ │ ├── full.py # Full evaluation across all audited pages +│ │ ├── page.py # Shared page preparation and review validation +│ │ ├── review.py # Review CSV validation and detection counts +│ │ ├── schemas.py # Shared Human audit and finding types +│ │ ├── inputs/ +│ │ │ └── human_audit.py # Import Human findings and audited URLs +│ │ ├── execution/ +│ │ │ ├── llm_audit.py # LLM requests, saved responses, and cost guardrails +│ │ │ ├── normalization.py # Convert tool responses into findings +│ │ │ ├── pricing.py # Load price schedules and estimate request cost +│ │ │ └── pricing.json # Versioned public model rates +│ │ └── reporting/ +│ │ ├── html_report.py # Prepare report data and render standalone HTML +│ │ ├── templates/ # Report HTML templates +│ │ ├── css/ # Report styles +│ │ └── js/ # Report filters and printing +│ └── ingestion/ +│ ├── file_crawler.py # fetch_page / fetch_pages_nested (used by the server) +│ └── pull_html.py # Standalone HTML download helper │ ├── Dockerfile # Multi-stage uv build → runtime image ├── docker-compose.yml # Local run (Coolify deploys the image directly) @@ -100,7 +119,7 @@ HTML file (e.g. 1.9 MB) ├── docs/ # Architecture documentation │ └── modular-prompts-plan.md # Full architectural plan │ -├── reports/ # Raw JSON output from standalone llm_client runs +├── reports/ # Historical raw JSON audit output │ ├── test_results/ │ ├── chatgpt/ # Legacy ChatGPT testing results @@ -120,11 +139,11 @@ HTML file (e.g. 1.9 MB) Each extractor has an `extract(file_path)` function that parses HTML with BeautifulSoup and returns a structured dict: -| Extractor | Focus | Output tokens (visionaid.org) | -|-----------|-------|-------------------------------| -| `semantic_checklist_01.py` | Page title, headings, links, landmarks, tables, iframes | ~17,600 | -| `forms_checklist_02.py` | Form fields with label source, instructions, required flags | ~2,500 | -| `nontext_checklist_03.py` | Images (4 categories), SVGs, icon fonts, video/audio | ~19,200 | +| Extractor | Focus | Output tokens (visionaid.org) | +| -------------------------- | ----------------------------------------------------------- | ----------------------------- | +| `semantic_checklist_01.py` | Page title, headings, links, landmarks, tables, iframes | ~17,600 | +| `forms_checklist_02.py` | Form fields with label source, instructions, required flags | ~2,500 | +| `nontext_checklist_03.py` | Images (4 categories), SVGs, icon fonts, video/audio | ~19,200 | These files live in `processing_scripts/llm_preprocessing/` and were authored by ahildebrandt3 and Andrew Yin. They should not need modification unless a new checklist (CL04+) is added. @@ -276,10 +295,23 @@ everything it does is free: - `uv.lock` is in sync with `pyproject.toml`, and `requirements.txt` matches the lock - entry points import +- evaluator code and tests pass Ruff lint checks +- the tracked synthetic pytest suite (`uv run pytest -q`), with no private data or provider calls - a full pipeline dry run, asserting prompts generated, findings found, and zero tokens consumed - `index.html`'s inline JavaScript parses - the Docker image builds, becomes healthy, serves the site, and returns a valid NDJSON audit +Default pytest discovery is limited to `tests/`, where `tests/test_evaluation.py` +covers the evaluator end to end through its CLI with a fake provider. Optional +detailed evaluator cases live in the ignored `.local-tests/evaluation/` folder; +run them with `uv run pytest -q .local-tests/evaluation`. CI never needs them. + +Run the evaluator lint checks locally with the dev dependencies installed by `uv sync`: + +```bash +uv run ruff check vision_aid/evaluation tests +``` + ## Running the Pipeline ### Dry run (no API calls, no cost) @@ -309,22 +341,22 @@ uv run python entry_points/generate_report.py --output-dir ./output --report-dir ### Pipeline options -| Flag | Default | Description | -|------|---------|-------------| -| `--html` | (required) | Path to the HTML file to analyze | -| `--output-dir` | `./output` | Directory for results | -| `--model` | `claude-sonnet-5` | Anthropic model to use | -| `--dry-run` | off | Generate prompts without calling the API | -| `--include-summaries` | off | Include the 3 cross-cutting summary prompts | -| `--show-cost` | off | Print estimated dollar cost of the run based on model pricing | -| `--env-file` | `.env` | Path to environment file | +| Flag | Default | Description | +| --------------------- | ----------------- | ------------------------------------------------------------- | +| `--html` | (required) | Path to the HTML file to analyze | +| `--output-dir` | `./output` | Directory for results | +| `--model` | `claude-sonnet-5` | Anthropic model to use | +| `--dry-run` | off | Generate prompts without calling the API | +| `--include-summaries` | off | Include the 3 cross-cutting summary prompts | +| `--show-cost` | off | Print estimated dollar cost of the run based on model pricing | +| `--env-file` | `.env` | Path to environment file | ### Report generator options -| Flag | Default | Description | -|------|---------|-------------| -| `--output-dir` | `./output` | Directory containing pipeline output | -| `--report-dir` | `./test_results/claude/` | Directory to write the CSV report | +| Flag | Default | Description | +| -------------- | ------------------------ | ------------------------------------ | +| `--output-dir` | `./output` | Directory containing pipeline output | +| `--report-dir` | `./test_results/claude/` | Directory to write the CSV report | ## Output Structure @@ -349,38 +381,178 @@ output/ The report CSV has 13 columns matching the Vision Aid team's standard format: -| Column | Description | -|--------|-------------| -| `ID` | Sequential row number | -| `element_name` | HTML element (e.g. ``, ` "link text"`) | -| `browser_combination` | Always `N/A` (static HTML analysis) | -| `page_title` | Page title from the analyzed HTML | -| `issue_title` | Short issue description | -| `steps_to_reproduce` | Element snippet or inspection steps | -| `actual_result` | What was found | -| `expected_result` | What WCAG requires | -| `recommendation` | Suggested fix | -| `wcag_sc` | WCAG success criterion (e.g. `1.1.1`) | -| `category` | Issue category (e.g. `Programmatic / Non-text Content`) | -| `log_date` | Date of the pipeline run | -| `reported_by` | `Programmatic` or the LLM model string | +| Column | Description | +| --------------------- | ---------------------------------------------------------- | +| `ID` | Sequential row number | +| `element_name` | HTML element (e.g. ``, ` "link text"`) | +| `browser_combination` | Always `N/A` (static HTML analysis) | +| `page_title` | Page title from the analyzed HTML | +| `issue_title` | Short issue description | +| `steps_to_reproduce` | Element snippet or inspection steps | +| `actual_result` | What was found | +| `expected_result` | What WCAG requires | +| `recommendation` | Suggested fix | +| `wcag_sc` | WCAG success criterion (e.g. `1.1.1`) | +| `category` | Issue category (e.g. `Programmatic / Non-text Content`) | +| `log_date` | Date of the pipeline run | +| `reported_by` | `Programmatic` or the LLM model string | ## Cost Estimate For visionaid.org homepage (using Claude Sonnet): -| Approach | Input tokens | Cost | -|----------|-------------|------| -| Monolithic (entire HTML) | ~487,000 | ~$1.52 | -| Element-specific pipeline | ~18,000 | ~$0.32 | +| Approach | Input tokens | Cost | +| ------------------------- | ------------ | ------ | +| Monolithic (entire HTML) | ~487,000 | ~$1.52 | +| Element-specific pipeline | ~18,000 | ~$0.32 | The pipeline skips prompts with empty payloads (e.g., no forms on the page = no form prompts), so actual cost varies by page content. ## Attribution -| Contributor | What they own | Key files | -|---|---|---| -| ahildebrandt3 | Extractors, programmatic checkers (CL01–CL03), CL01 prompts | `processing_scripts/llm_preprocessing/`, `processing_scripts/programmatic/` | -| Andrew Yin | CL02 + CL03 extractors, CL02 + CL03 prompts, LLM client, pipeline docs | `processing_scripts/llm_preprocessing/`, `processing_scripts/llm_client/`, `processing_scripts/llm/*.txt` | -| nfulton99 | HTML ingestion, packaging | `vision_aid/ingestion/pull_html.py`, `pyproject.toml` | -| ColeANiblett | Pipeline orchestration, prompt system, report generator | `processing_scripts/llm/{registry,slicers,templates}.py`, `entry_points/`, `docs/` | +| Contributor | What they own | Key files | +| ------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| ahildebrandt3 | Extractors, programmatic checkers (CL01–CL03), CL01 prompts | `processing_scripts/llm_preprocessing/`, `processing_scripts/programmatic/` | +| Andrew Yin | CL02 + CL03 extractors, CL02 + CL03 prompts, LLM client, pipeline docs | `processing_scripts/llm_preprocessing/`, `processing_scripts/llm_client/`, `processing_scripts/llm/*.txt` | +| nfulton99 | HTML ingestion, packaging | `vision_aid/ingestion/pull_html.py`, `pyproject.toml` | +| ColeANiblett | Pipeline orchestration, prompt system, report generator | `processing_scripts/llm/{registry,slicers,templates}.py`, `entry_points/`, `docs/` | + +## Model evaluation + +The evaluator measures how many findings from an existing human accessibility +audit va-dat catches. It runs programmatic checks and one selected LLM against +saved page HTML, then uses reviewed matches to produce an offline HTML report +with LLM, Programmatic, and Overall detection rates. + +There are two reviews: approve the audit's classifications and programmatic +matches once, then review LLM matches after each model run. The evaluator +validates the saved evidence and calculates scores; the reviewer decides +whether findings describe the same defect. + +The [evaluator guide](vision_aid/evaluation/README.md) covers input formats, +matching rules, scoring, model settings, saved files, and troubleshooting. + +### Evaluator quick start + +Complete [setup](#setup) first. + +**Run all commands in this guide from the repository root**, the va-dat/ directory containing pyproject.toml and the root README. +All relative paths in the examples are relative to that directory. + +The human audit Excel file, `Pristine Accessibility Defect Report.xlsx`, +belongs directly in the repository root, alongside this README. The examples +expect it at `./Pristine Accessibility Defect Report.xlsx`. The file and +approved mappings are private local inputs, excluded from Git. If the file +is missing, obtain it from your project team and place it there, or supply your +own audit in the [supported format](vision_aid/evaluation/README.md#human-audit-excel-file-format) +and update `AUDIT` below. + +This walkthrough evaluates every URL listed in the audit. Keep the same saved +HTML when comparing models. If you already have snapshots and an approved +mapping for this audit, set their paths below and skip steps 1 and 2. + +```bash +AUDIT='./Pristine Accessibility Defect Report.xlsx' +SNAPSHOTS='.model-evaluation/pristine-snapshots/snapshots.json' +REVIEW_DIR='.model-evaluation/pristine-mapping-review' +MAPPING="$REVIEW_DIR/human-finding-mapping.json" +MODEL='claude-haiku-5-5' +MAX_COST_USD=5.00 # Choose your total allowance for all pages. +``` + +1. **Capture the pages.** The output directory must be new. Capture downloads + server HTML without rendering JavaScript; inspect the saved pages before review. + It requires no model API key. + + ```bash + uv run visionaid-evaluate capture \ + --human-audit "$AUDIT" \ + --output-dir .model-evaluation/pristine-snapshots + ``` + +2. **Prepare and approve the reusable mapping.** This makes no model requests. + The review directory must be new. + + ```bash + uv run visionaid-evaluate human-finding-mapping-prepare \ + --human-audit "$AUDIT" --snapshots "$SNAPSHOTS" \ + --output-dir "$REVIEW_DIR" + ``` + + Follow the generated README: fill `classifications.csv` and + `programmatic-review.csv`, using `programmatic-findings.csv` as the checker + catalog. The [mapping review guide](vision_aid/evaluation/README.md#approve-a-human-finding-mapping) + explains the classification values and complete-match requirements. Then approve: + + ```bash + uv run visionaid-evaluate human-finding-mapping-approve \ + --human-audit "$AUDIT" --snapshots "$SNAPSHOTS" \ + --review-dir "$REVIEW_DIR" --reviewer "Your name and review method" + ``` + +3. **Preview the evaluation.** Inspect the pages, prompt count, model settings, + and prices without an API key or paid requests. A preview cannot produce a + final report. + + ```bash + uv run visionaid-evaluate run-all \ + --human-audit "$AUDIT" --snapshots "$SNAPSHOTS" \ + --human-finding-mapping "$MAPPING" \ + --model "$MODEL" --max-cost-usd "$MAX_COST_USD" + ``` + +4. **Run the model.** Put `ANTHROPIC_API_KEY` for Claude or `OPENAI_API_KEY` for + GPT in your environment or the repository-root `.env`. Only the selected + provider's key is required. Repeat the command with `--live`: + + ```bash + uv run visionaid-evaluate run-all \ + --human-audit "$AUDIT" --snapshots "$SNAPSHOTS" \ + --human-finding-mapping "$MAPPING" \ + --model "$MODEL" --max-cost-usd "$MAX_COST_USD" --live + ``` + + Type `yes` when prompted. The allowance is checked between requests; a request + underway can exceed it. This creates a fresh run instead of executing the + preview directory. Requests run sequentially without retries. Restarting after + failure repeats all requests, including previously completed pages. + +5. **Review the LLM matches.** Use the new directory printed by the live command: + + ```bash + RUN_DIR='.model-evaluation/full-runs/' + ``` + + Confirm `"complete": true` in `full.json`, and check each child's `run.json` + for `format_failures`. Follow each page's README under `$RUN_DIR/pages/`. + Compare `normalized-llm-findings.json`, `review.csv`, and `snapshot.html`. + Edit only `llm_finding_id`, `match_reason`, and `review_notes`. Leave an LLM ID + blank for a miss; record IDs only when they fully cover the human finding. + Keep approved classification and programmatic fields unchanged. + See the [matching rules](vision_aid/evaluation/README.md#review-llm-matches). + +6. **Generate the reports.** After saving every page's review CSV: + + ```bash + uv run visionaid-evaluate report-all \ + --run-dir "$RUN_DIR" --reviewer "Your name and review method" + ``` + + Open `$RUN_DIR/report.html` for combined results. Each page also gets a + `report.html`. Reports work offline and can regenerate without model calls. + +Choose `claude-haiku-5-5`, `claude-opus-5-5`, `claude-sonnet-5-5`, +`gpt-6-luna`, or `gpt-6.1-sol` as `MODEL`. Haiku is the default when `--model` +is omitted. Each run uses one model; compare models with separate runs using +the same audit, snapshots, and mapping. + +To evaluate just one page, use `run` with `--html` and `--source-url`, then +`report`. See the [single-page commands](vision_aid/evaluation/README.md#evaluate-one-page). +For command options, use `uv run visionaid-evaluate --help` or add `--help` +after a command name. + +The rates measure coverage of the supplied human audit. Findings that cannot +be tested from saved HTML remain in Overall, so perfect coverage of testable +findings can still produce an Overall rate below 100%. Read the +[scoring guide](vision_aid/evaluation/README.md#how-scoring-works) before interpreting +or comparing results. diff --git a/entry_points/api_server.py b/entry_points/api_server.py old mode 100644 new mode 100755 index 0f61b4f..7ec0d9a --- a/entry_points/api_server.py +++ b/entry_points/api_server.py @@ -19,6 +19,7 @@ """ import json +import logging import os import re import shutil @@ -33,11 +34,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from entry_points.run_pipeline import run_pipeline # noqa: E402 -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 entry_points.generate_report import generate_report +from entry_points.run_pipeline import run_pipeline +from processing_scripts.llm_client.client import is_gemini_model, is_openai_model +from vision_aid.ingestion.file_crawler import fetch_page, fetch_pages_nested +logger = logging.getLogger(__name__) # ── Multi-page splitting ───────────────────────────────────────────────────── @@ -150,9 +152,14 @@ def run_audit(html_content: str, api_key: str, model: str, progress_callback=Non "wcag_criteria": data.get("wcag_criteria", []), "status": status, "error": (api_result or {}).get("error"), + "failure": (api_result or {}).get("failure"), "parsed": parsed, + "provider": (api_result or {}).get("provider"), + "request": (api_result or {}).get("request"), + "usage": usage, "input_tokens": usage.get("input_tokens"), "output_tokens": usage.get("output_tokens"), + "stop_reason": (api_result or {}).get("stop_reason"), "duration_seconds": (api_result or {}).get("duration_seconds"), } @@ -177,8 +184,8 @@ def run_audit(html_content: str, api_key: str, model: str, progress_callback=Non "stage": "report_complete", "message": "Report ready", }) - except Exception as csv_err: - print(f" Warning: CSV generation failed: {csv_err}") + except Exception: + logger.exception("CSV generation failed") return { "success": True, @@ -202,6 +209,7 @@ def run_audit(html_content: str, api_key: str, model: str, progress_callback=Non } except Exception as exc: + logger.exception("Audit failed") return {"success": False, "error": str(exc)} finally: @@ -223,7 +231,7 @@ def _try_parse_json(text: str): class AuditHandler(BaseHTTPRequestHandler): """Handle static file serving and the /api/audit endpoint.""" - def log_message(self, fmt, *args): # noqa: N802 + def log_message(self, fmt, *args): sys.stderr.write(f"[{self.address_string()}] {fmt % args}\n") # CORS helpers ───────────────────────────────────────────────────────────── @@ -233,7 +241,7 @@ def _cors_headers(self): self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") - def do_OPTIONS(self): # noqa: N802 + def do_OPTIONS(self): """Handle CORS preflight requests.""" self.send_response(200) self._cors_headers() @@ -241,7 +249,7 @@ def do_OPTIONS(self): # noqa: N802 # GET ────────────────────────────────────────────────────────────────────── - def do_GET(self): # noqa: N802 + def do_GET(self): path = self.path.split("?")[0] if path in ("/", "/index.html"): self._serve_file(STATIC_DIR / "index.html", "text/html; charset=utf-8") @@ -264,7 +272,7 @@ def _serve_file(self, file_path: Path, content_type: str): # POST ───────────────────────────────────────────────────────────────────── - def do_POST(self): # noqa: N802 + def do_POST(self): if self.path == "/api/audit": self._handle_audit() elif self.path == "/api/audit/url": @@ -520,8 +528,8 @@ def _handle_url_audit(self, nested: bool): def _handle_validate_key(self): """Validate an API key with a lightweight call to the provider.""" - import urllib.request import urllib.error + import urllib.request length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) @@ -599,7 +607,7 @@ def main(): parser.add_argument( "--port", type=int, - default=int(os.getenv("PORT", 8000)), + default=int(os.getenv("PORT", "8000")), help="Port to listen on (default: $PORT or 8000)", ) parser.add_argument( diff --git a/entry_points/generate_report.py b/entry_points/generate_report.py index fb86401..d24deb8 100644 --- a/entry_points/generate_report.py +++ b/entry_points/generate_report.py @@ -17,11 +17,15 @@ import argparse import csv import json +import logging import re from dataclasses import dataclass, fields from pathlib import Path + from bs4 import BeautifulSoup +logger = logging.getLogger(__name__) + # ── Deduplication prompt ───────────────────────────────────────────────────── _DEDUP_PROMPT = """\ @@ -280,10 +284,7 @@ def _is_link_fp(row: ReportRow, nav_link_texts: set[str]) -> bool: return True # Links inside