diff --git a/.claude/worktrees/objective-spence b/.claude/worktrees/objective-spence deleted file mode 160000 index d3194de..0000000 --- a/.claude/worktrees/objective-spence +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d3194de0b852752ccd70eb41bd743b2d52319a51 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14ddc3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Secrets — never copy into an image layer. Keys are supplied at runtime via +# Coolify's environment variables, or per-request from the web form. +.env +.env.* + +# Local virtualenvs and Python build noise. Patterns are matched from the +# build-context root, so nested matches need an explicit **/ prefix. +venv/ +.venv/ +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +build/ +dist/ + +# Version control and editor/tooling state +.git/ +.gitignore +.github/ +.claude/ +.idea/ +.vscode/ + +# Runtime output — regenerated per audit, never part of the image +output/ +reports/ +test_results/ + +# Sample inputs and reference material. Only needed for the CLI pipeline +# (entry_points/run_pipeline.py), not for serving the app. Drop these lines +# if you want to run the CLI inside the container. +test_files/ +semantic_checklist/ +docs/ +*.ipynb + +# Deployment docs — not needed inside the image +DEPLOY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..774f495 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,206 @@ +name: CI + +on: + pull_request: + branches: [main] + +# A new push to the same PR cancels the previous run. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + checks: + name: Dependencies, imports, pipeline + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.11.21" + enable-cache: true + + # Fails if pyproject.toml and uv.lock disagree — i.e. someone changed a + # dependency without re-running `uv lock`. + - name: uv.lock is up to date + run: uv lock --check + + - name: Install locked dependencies + run: uv sync --frozen + + # requirements.txt is a generated export, not a hand-maintained list. + # Regenerate it and fail if the committed copy differs. + - name: requirements.txt matches uv.lock + run: | + uv export --format requirements.txt --no-dev --no-emit-project \ + --no-hashes -o requirements.txt + if ! git diff --exit-code --stat -- requirements.txt; then + echo "::error::requirements.txt is out of date. Regenerate it with the command in its header and commit the result." + exit 1 + fi + + - name: Entry points import + run: | + uv run python -c " + import entry_points.api_server, entry_points.run_pipeline, entry_points.generate_report + from vision_aid.ingestion.file_crawler import fetch_page, fetch_pages_nested + print('all entry points import cleanly') + " + + # 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. + - name: Pipeline dry run + run: | + uv run python entry_points/run_pipeline.py \ + --html test_files/dat_visionaid_home.html \ + --dry-run --output-dir ./ci-output + + - name: Pipeline produced expected output + run: | + uv run python - <<'PY' + import json, pathlib, sys + + manifest = json.loads(pathlib.Path("ci-output/manifest.json").read_text()) + prompts = list(pathlib.Path("ci-output/prompts").glob("*.json")) + findings = json.loads( + pathlib.Path("ci-output/programmatic_findings.json").read_text() + ) + + problems = [] + if not manifest["dry_run"]: + problems.append("manifest says this was not a dry run") + if manifest["total_input_tokens"] or manifest["total_output_tokens"]: + problems.append("a dry run consumed tokens — an API call escaped") + if not manifest["prompts_dry_run"]: + problems.append("no prompts were generated") + if not findings: + problems.append("programmatic checkers found nothing") + if len(prompts) != len(manifest["prompts_dry_run"]): + problems.append( + f"{len(prompts)} prompt files vs " + f"{len(manifest['prompts_dry_run'])} in manifest" + ) + + if problems: + for p in problems: + print(f"::error::{p}") + sys.exit(1) + + print( + f"OK — {len(manifest['prompts_dry_run'])} prompts generated, " + f"{len(manifest['prompts_skipped'])} skipped, " + f"{len(findings)} programmatic findings" + ) + PY + + # index.html carries all of the front-end logic inline, so a syntax error + # there ships a broken page with nothing else to catch it. + - name: index.html JavaScript parses + run: | + python3 - <<'PY' + import pathlib, re + html = pathlib.Path("index.html").read_text(encoding="utf-8", errors="replace") + blocks = re.findall(r"]*>(.*?)", html, re.DOTALL) + assert blocks, "no