From 10c76201bc62d501b0abfd6613a1d6ea6d489ec9 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 13:57:42 -0700 Subject: [PATCH 01/29] Keep Observatory changes aligned with evidence and operations Codify project language, methodology constraints, privacy boundaries, reliability expectations, verification, and concise external writing before implementation begins. Constraint: External documentation must begin with a plain-language explanation for non-technical project readers. Confidence: high Scope-risk: narrow Tested: Reviewed the full file and confirmed it contains no personal or application context. Not-tested: Documentation-only change; no runtime checks apply. --- AGENTS.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..577f785 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# Engineering guide + +## Purpose + +This repository implements a compact Loss of Control Observatory based on CLTR's published pilot methodology. It collects public reports of concerning AI behaviour, classifies the evidence, stores the results with provenance, and exposes useful summaries for review. + +Build a small system that works end to end before adding breadth. A complete, honest vertical slice is more valuable than several unfinished components. Do not claim production readiness that has not been demonstrated. + +## Domain language + +Use these terms consistently: + +- **Scheming:** covert pursuit of goals that conflict with the intentions of a system's user, developer, or deployer. +- **Scheming-related behaviour:** misalignment, covertness, or a possible precursor that may inform the study of scheming without meeting the full definition. +- **Incident report:** a collected post that may describe scheming-related behaviour. It is an unverified report, not an incident. +- **Credible incident report:** a report scoring at least 5 on the 0–9 evidence rubric. +- **Incident:** a distinct real-world event represented by one or more credible reports after deduplication. + +Scores are prioritisation signals, not ground truth or calibrated probabilities. Public reports can support monitoring and hypothesis generation, but they cannot establish population prevalence, a model's propensity to scheme, or the mechanism behind a behaviour. + +## Product priorities + +Deliver work in this order: + +1. One Reddit post flows through collection, classification, storage, and a visible report. +2. The pipeline handles realistic batches idempotently and records provenance. +3. Failures are isolated, observable, and recoverable. +4. Privacy controls, retention, and erasure work and are documented. +5. Classification quality is measured against a small human-labelled set. +6. Add deduplication, a review dashboard, more sources, deployment, and an API only after the earlier layers are sound. + +When time is limited, protect the working pipeline, tests, privacy controls, recovery path, evaluation, and clear documentation. Cut interface polish and optional breadth first. + +## Methodology constraints + +- Collect public reports that combine an AI-related signal with a scheming or reaction signal. Prefer reports with direct evidence such as transcripts, screenshots, or chatbot share links. +- Keep collection separate from classification. Preserve a seam for a cheap, high-recall pre-screen before the slower, conservative scoring stage, even if the first vertical slice uses one classifier. +- Version every classification prompt. Store the model identifier, prompt version or content hash, timestamp, token usage, cost, score, and reasoning with each result. +- Validate model output against a strict schema before it reaches storage. Reject out-of-range scores and malformed fields. +- In final scoring, prefer evidence over dramatic language. Treat mundane errors, unsupported claims, jokes, promotion, deliberate jailbreaks, and user-driven misuse as common false positives. When evidence is ambiguous, choose the lower score. +- Keep experimental or red-team results distinguishable from events reported in normal deployment. Do not mix them into real-world trend counts. +- Count unique incidents, not posts. Deduplication must be inspectable and reversible. Guard against transitive chaining that merges distinct events across an excessive date span. +- Choose a representative report using an explicit rule, while preserving links to every report in the group. +- Evaluate ordinal scores with an appropriate agreement metric such as quadratic weighted Cohen's kappa. Keep the labelled sample, rubric, model outputs, and evaluation code reproducible. +- Report collection volume and credible-incident volume separately. Normalise trend claims against relevant collection or discussion volume where possible. + +## Data protection + +Privacy controls apply before data reaches persistent storage or logs. + +- Never persist a raw username, display name, user ID, API token, session value, or other unnecessary identifier. +- Replace author identifiers at collection time with `HMAC(secret_salt, stable_platform_identifier)`. Keep the salt outside the repository and database. A plain hash is not sufficient. +- Treat `posts_raw` as immutable source evidence after mandatory redaction and minimisation. Corrections and derived data belong in separate records. +- Store only fields needed for analysis, provenance, deduplication, retention, or erasure. +- Apply documented retention periods to raw posts, derived data, and archived artifacts. Deletions must be auditable without retaining the deleted content. +- Support erasure by pseudonymous author identifier and remove dependent records safely. +- Do not send content to an LLM provider unless the configured data-handling terms are documented. Never assume an API has zero retention. +- Do not expose collected text, artifacts, or exports publicly by default. The dashboard and export paths require explicit access controls before deployment. +- Never commit `.env`, credentials, databases containing collected data, report artifacts containing personal data, or the ignored `background/` directory. + +## Architecture + +Keep boundaries explicit: + +- `collector/`: external API access, query construction, redaction, retries, and collection metadata. +- `classifier/`: prompt loading, provider calls, response validation, scoring, and cost accounting. +- `warehouse/`: schema, migrations, transactions, repositories, retention, erasure, and audit records. +- `dashboard/`: read-only presentation and authenticated review actions. +- `docs/`: decisions, operations, data protection, evaluation, and known limitations. +- `tests/`: behaviour-focused tests and inert provider fixtures. + +Domain logic must not depend directly on an API SDK, web framework, or database driver. Put those dependencies behind narrow adapters so core behaviour can be tested offline. + +Use SQLite for the local demonstration. Keep queries and transactions disciplined enough that a later move to PostgreSQL has clear boundaries; do not build a speculative compatibility layer. + +## Python standards + +- Target one documented Python version and declare it in project metadata. +- Use a `pyproject.toml` and a reproducible lock file. Add only dependencies that earn their maintenance cost. +- Type public functions and domain models. Prefer small, explicit data structures over untyped dictionaries passed between layers. +- Use UTC-aware timestamps at every boundary and store them in an unambiguous format. +- Pass configuration into components. Do not read environment variables throughout business logic. +- Make repeated collection and classification safe. Use stable external IDs, database uniqueness constraints, and upserts or conflict handling deliberately. +- Use transactions for multi-record state changes. Enable SQLite foreign keys and define deletion behaviour explicitly. +- Set timeouts on network calls. Retry only transient failures, with bounded exponential backoff and jitter. Respect rate-limit responses and provider retry hints. +- Distinguish permanent validation failures from transient provider failures. Preserve enough structured error context for replay without leaking secrets or personal data. +- Keep modules focused. Reuse existing code before adding helpers or abstractions. Delete dead code rather than preserving speculative paths. + +## Reliability and operations + +- Give every pipeline run a `run_id`. Emit structured JSON logs with the component, operation, outcome, duration, item counts, failure counts, and cost where relevant. +- Never log raw credentials, author identifiers, full post bodies, full prompts containing user data, or unredacted provider responses. +- A bad item must not terminate a batch. Record classification failures in a dead-letter queue and continue. +- Replay must be idempotent. Record retry count, last error, and last-attempt time, and prevent concurrent workers from processing the same item. +- Detect silent collection failures. Compare run volume with a documented baseline and make abnormal drops visible without treating them as proof of an upstream outage. +- Provide health checks that distinguish process health from dependency readiness when a service is deployed. +- Scheduled jobs must prevent overlapping runs or make overlap safe. +- Document recovery steps in `RUNBOOK.md` and verify them through failure injection before presenting them as supported. + +## Testing and verification + +Tests must be deterministic and run without live API credentials. + +- Keep saved, redacted API responses as fixtures. Do not make network calls in unit tests. +- Test public behaviour and database invariants, not implementation details. +- At minimum, cover redaction before persistence, idempotent upserts, prompt-version idempotency, schema validation, score bounds, dead-letter handling and replay, cost calculation, retention, erasure, and audit logging. +- Use integration tests for schema creation, constraints, transactions, and command-line workflows. +- Add a regression test before fixing a defect when practical. +- Run focused tests after each change, then the full test suite, formatting, linting, type checking, and build checks before completion. +- Inspect the final diff for secrets, personal data, generated files, accidental API changes, and unrelated edits. +- Do not describe a path as tested unless the relevant command was run and its result was inspected. + +## Git and change discipline + +- Keep each commit to one coherent outcome. Include its tests and necessary documentation in the same commit. +- Do not mix formatting, refactoring, dependency changes, and behaviour changes unless they are inseparable. +- Write the subject as the reason for the change. Add a short body when constraints or trade-offs are not obvious. +- Record meaningful verification with a `Tested:` trailer and any known gap with `Not-tested:`. +- Commit only a reviewed, passing state. Do not rewrite shared history or discard work that you did not create. +- Use architecture decision records for consequential choices with credible alternatives, not for routine implementation details. + +## Documentation and communication + +Write external documentation for a non-technical reader who understands the project and has limited time. Lead with a plain-language explanation of the purpose, outcome, and limits. Add a clearly separated technical section only when it helps someone build, operate, or review the system. Internal engineering notes may assume more technical knowledge. + +- Use short sentences, concrete claims, and ordinary words. Remove filler, hype, generic praise, and repeated conclusions. +- Use technical terms only when they make the statement more precise. Define project-specific terms once and use them consistently. +- Explain why a design exists, its operational trade-offs, and how it was verified. Do not narrate obvious code. +- Keep setup commands executable from a fresh clone. +- Maintain a candid distinction between what was validated, what is assumed, and what would change at higher scale. +- State methodology limitations beside results. Never present classifier output, public reporting volume, or deduplicated counts with more certainty than the evidence supports. From d9ede563e55c6d5f2e4aab71ebd6c3160fc275c4 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 14:12:37 -0700 Subject: [PATCH 02/29] Make every pipeline change start from a reproducible base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the Python 3.12 package boundaries, locked uv environment, local quality gates, plain-language project guide, and honest initial operating documents required by the rest of the roadmap. Constraint: The complete demonstration must fit a 7–8 hour implementation window. Rejected: Separate services at the outset | deployment and coordination cost would delay the first end-to-end path. Confidence: high Scope-risk: narrow Directive: Keep collection, classification, storage, reporting, and interface code behind the package boundaries introduced here. Tested: make install; make check; uv build; git diff --cached --check. Not-tested: No live API, database, or service behaviour exists in this foundation. Related: #1 --- .gitignore | 34 ++++ .python-version | 1 + Makefile | 21 +++ README.md | 81 +++++++++- RUNBOOK.md | 52 ++++++ docs/DATA_PROTECTION.md | 32 ++++ docs/adr/0001-use-a-modular-monolith.md | 36 +++++ pyproject.toml | 40 +++++ src/loc_observatory/__init__.py | 7 + src/loc_observatory/classifier/__init__.py | 1 + src/loc_observatory/collector/__init__.py | 1 + src/loc_observatory/dashboard/__init__.py | 1 + src/loc_observatory/reporting/__init__.py | 1 + src/loc_observatory/warehouse/__init__.py | 1 + tests/test_package.py | 15 ++ uv.lock | 180 +++++++++++++++++++++ 16 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Makefile create mode 100644 RUNBOOK.md create mode 100644 docs/DATA_PROTECTION.md create mode 100644 docs/adr/0001-use-a-modular-monolith.md create mode 100644 pyproject.toml create mode 100644 src/loc_observatory/__init__.py create mode 100644 src/loc_observatory/classifier/__init__.py create mode 100644 src/loc_observatory/collector/__init__.py create mode 100644 src/loc_observatory/dashboard/__init__.py create mode 100644 src/loc_observatory/reporting/__init__.py create mode 100644 src/loc_observatory/warehouse/__init__.py create mode 100644 tests/test_package.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d1155d --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Private project context +background/ + +# Secrets and local configuration +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +build/ +dist/ +*.egg-info/ + +# Local data and generated output +data/ +artifacts/ +reports/ +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Local tools and operating systems +.omx/ +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..70120d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: check format format-check install lint test typecheck + +install: + uv sync --dev --locked + +format: + uv run ruff format . + +format-check: + uv run ruff format --check . + +lint: + uv run ruff check . + +typecheck: + uv run mypy src tests + +test: + uv run pytest + +check: format-check lint typecheck test diff --git a/README.md b/README.md index 898f6b4..5406a26 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,79 @@ -# loc-observatory -A miniature, production-grade Loss of Control Observatory based on https://www.longtermresilience.org/wp-content/uploads/2026/03/v5-Scheming-in-the-wild_-detecting-real-world-AI-scheming-incidents-through-open-source-intelligence.pdf +# Loss of Control Observatory Mini + +AI systems sometimes act against a user's instructions or hide what they have done. Reports of +these events are scattered across public forums, which makes patterns difficult to see. This project +is building a small observatory to collect relevant Reddit posts, remove direct author identifiers, +score the strength of the evidence, and present the results for review. + +The project adapts the public-transcript method described in CLTR's +[Loss of Control Observatory pilot report](https://www.longtermresilience.org/wp-content/uploads/2026/03/v5-Scheming-in-the-wild_-detecting-real-world-AI-scheming-incidents-through-open-source-intelligence.pdf). +It is an independent reference implementation, not an official CLTR system. + +## What this project tests + +The pilot report describes several limits. This implementation focuses on three: + +1. **Coverage:** the pilot collected posts from X. This project adds Reddit as a separate source. +2. **Evidence authenticity:** the pipeline records where evidence came from and will preserve + supported chatbot share links with a content hash. This reduces evidence loss but cannot prove + that every public report is genuine. +3. **Ordinary errors:** unexpected AI behaviour is not automatically scheming. The classifier uses a + conservative evidence rubric, records its reasoning, and will be checked against human labels. + +The wider goal is a complete data path that can be operated safely: collection, classification, +storage, recovery, evaluation, and a useful review output. The project does not estimate how often +AI systems scheme across all uses, and classifier scores are not ground truth. + +## Current status + +The repository foundation is in place. Collection and analysis are tracked in the +[project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline is +implemented yet. + +## Technical guide + +### Project shape + +```text +src/loc_observatory/ +├── collector/ Reddit access, filtering, and author redaction +├── classifier/ Evidence prompts, provider calls, and result validation +├── warehouse/ SQLite schema, retention, erasure, and audit records +├── reporting/ Static reports and exports +└── dashboard/ Authenticated review interface +``` + +Detailed decisions live in [`docs/adr/`](docs/adr/). Data handling is described in +[`docs/DATA_PROTECTION.md`](docs/DATA_PROTECTION.md), and operating instructions are kept in +[`RUNBOOK.md`](RUNBOOK.md). + +### Set up the project + +Requirements: + +- Python 3.12 +- [uv](https://docs.astral.sh/uv/) +- GNU Make, or run the corresponding `uv` commands from `Makefile` + +Install the development environment: + +```bash +uv sync --dev --locked +``` + +Run every local quality check: + +```bash +make check +``` + +The individual commands are: + +```bash +make format-check +make lint +make typecheck +make test +``` + +Tests must run without live API credentials or network access. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..d178010 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,52 @@ +# Runbook + +## Current state + +The repository currently contains the Python package and local quality checks. It does not yet run a +collector, classifier, database, or web service. Operational recovery steps will be added only after +the related behaviour has been exercised. + +## Set up a local environment + +Requirements: Python 3.12, `uv`, and GNU Make. + +```bash +uv sync --dev --locked +make check +``` + +Expected result: formatting, linting, type checking, and tests all pass. + +## Common setup failures + +### `uv` cannot find Python 3.12 + +Confirm that Python 3.12 is installed and available: + +```bash +python3.12 --version +uv python find 3.12 +``` + +### The environment is out of date + +Reconcile it with the committed lock file: + +```bash +uv sync --dev --locked +``` + +Do not delete local databases or collected artifacts as a generic recovery step. + +## Planned operating procedures + +The following procedures are not implemented yet: + +- Reddit rate-limit or access failure +- LLM provider outage and dead-letter replay +- Abnormal collection volume +- Retention and erasure +- Service rollback + +Their implementation is tracked in the +[project roadmap](https://github.com/code259/loc-observatory/issues/27). diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md new file mode 100644 index 0000000..5dbf9cc --- /dev/null +++ b/docs/DATA_PROTECTION.md @@ -0,0 +1,32 @@ +# Data protection + +## Plain-language summary + +This project will collect public Reddit posts about concerning AI behaviour. Public availability does +not remove the need to protect the people who wrote them. The pipeline will remove direct author +identifiers before data is stored, keep only fields needed for analysis, limit how long data is kept, +and support deletion requests. + +The repository currently contains the project foundation only. No collection or deletion control is +implemented yet. This document describes the required safeguards; it will link to tested commands +as each safeguard is added. + +## Planned controls + +- Replace the Reddit author ID with a keyed HMAC before persistence or logging. +- Keep the HMAC secret outside the repository and database. +- Store only the post fields needed for evidence review, provenance, and deletion. +- Keep collected data and archived evidence out of Git. +- Define separate retention periods for source posts and archived artifacts. +- Delete records by pseudonymous author ID and record the action without retaining deleted content. +- Restrict access to reports, exports, and archived evidence. +- Document the retention and training terms of any LLM provider before sending post content. + +## Open decisions + +The legal basis, final retention periods, deployed access controls, and processor terms must be +reviewed before live deployment. This project documentation is not legal advice. + +Implementation and verification are tracked in +[the retention and erasure issue](https://github.com/code259/loc-observatory/issues/15) and +[the data protection guide issue](https://github.com/code259/loc-observatory/issues/16). diff --git a/docs/adr/0001-use-a-modular-monolith.md b/docs/adr/0001-use-a-modular-monolith.md new file mode 100644 index 0000000..da3e71e --- /dev/null +++ b/docs/adr/0001-use-a-modular-monolith.md @@ -0,0 +1,36 @@ +# ADR 0001: Use a modular monolith + +- **Status:** Accepted +- **Date:** 2026-08-04 + +## Context + +The first release must demonstrate one complete path from collection to review within a short build +window. It still needs clear boundaries so that API access, classification, storage, and presentation +can be tested independently. + +## Decision + +Build one installable Python package with separate modules for collection, classification, storage, +reporting, and the dashboard. Run these modules in one local environment and use SQLite for the +first complete path. + +External services and framework code will sit behind small adapters. Core rules will not import API +SDKs, the database driver, or a web framework directly. + +## Consequences + +- One environment and one deployment unit keep setup and debugging simple. +- Module boundaries preserve a clear path to split workers or replace SQLite if real volume requires + it. +- A long-running task can still affect other work in the same process until separate workers are + introduced. +- SQLite is suitable for the demonstration but not evidence that the same design meets a large, + concurrent production workload. + +## Alternatives considered + +- **Separate services now:** rejected because coordination, deployment, and failure modes would + consume time before the end-to-end method is validated. +- **One undivided script:** rejected because it would couple external APIs, domain rules, storage, + and reporting, making offline tests and later changes harder. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6899b15 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "loc-observatory" +version = "0.1.0" +description = "A small observatory for public reports of concerning AI behaviour" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [] + +[dependency-groups] +dev = [ + "mypy>=1.17,<2", + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/loc_observatory"] + +[tool.mypy] +python_version = "3.12" +strict = true +files = ["src", "tests"] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "UP"] + +[tool.ruff.format] +docstring-code-format = true diff --git a/src/loc_observatory/__init__.py b/src/loc_observatory/__init__.py new file mode 100644 index 0000000..1a502c1 --- /dev/null +++ b/src/loc_observatory/__init__.py @@ -0,0 +1,7 @@ +"""Loss of Control Observatory Mini.""" + +from importlib.metadata import version + +__all__ = ["__version__"] + +__version__ = version("loc-observatory") diff --git a/src/loc_observatory/classifier/__init__.py b/src/loc_observatory/classifier/__init__.py new file mode 100644 index 0000000..5e99be4 --- /dev/null +++ b/src/loc_observatory/classifier/__init__.py @@ -0,0 +1 @@ +"""Classify the evidence in collected incident reports.""" diff --git a/src/loc_observatory/collector/__init__.py b/src/loc_observatory/collector/__init__.py new file mode 100644 index 0000000..a32e5f9 --- /dev/null +++ b/src/loc_observatory/collector/__init__.py @@ -0,0 +1 @@ +"""Collect and redact public incident reports.""" diff --git a/src/loc_observatory/dashboard/__init__.py b/src/loc_observatory/dashboard/__init__.py new file mode 100644 index 0000000..f7950e9 --- /dev/null +++ b/src/loc_observatory/dashboard/__init__.py @@ -0,0 +1 @@ +"""Present authenticated review workflows.""" diff --git a/src/loc_observatory/reporting/__init__.py b/src/loc_observatory/reporting/__init__.py new file mode 100644 index 0000000..0278242 --- /dev/null +++ b/src/loc_observatory/reporting/__init__.py @@ -0,0 +1 @@ +"""Build reports and exports from stored results.""" diff --git a/src/loc_observatory/warehouse/__init__.py b/src/loc_observatory/warehouse/__init__.py new file mode 100644 index 0000000..54a5137 --- /dev/null +++ b/src/loc_observatory/warehouse/__init__.py @@ -0,0 +1 @@ +"""Store source evidence, derived results, and audit records.""" diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..62562ae --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,15 @@ +"""Tests for the installable package boundary.""" + +from importlib.metadata import version + +import loc_observatory +import loc_observatory.classifier +import loc_observatory.collector +import loc_observatory.dashboard +import loc_observatory.reporting +import loc_observatory.warehouse + + +def test_distribution_version_matches_package_version() -> None: + """Keep project metadata and the public package version aligned.""" + assert loc_observatory.__version__ == version("loc-observatory") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ea0e14f --- /dev/null +++ b/uv.lock @@ -0,0 +1,180 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, +] + +[[package]] +name = "loc-observatory" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.17,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 00c360796a6685b568243d385fc7eb2f40b80451 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 14:24:25 -0700 Subject: [PATCH 03/29] Keep secrets out of code while making runs repeatable Load versioned non-secret YAML and ignored environment credentials through one validated settings object. Process variables override local files, missing names are actionable, and secret values remain redacted. Constraint: Different commands need different credential subsets during incremental delivery. Rejected: Read environment variables throughout the codebase | configuration errors would be late, inconsistent, and harder to test. Confidence: high Scope-risk: narrow Directive: Load settings once at each command boundary and pass the validated object inward. Tested: uv run pytest tests/test_config.py; make check; git diff --cached --check; .env ignore verification. Not-tested: No deployment secret store or live provider credential was used. Related: #2 --- .env.example | 9 ++ README.md | 3 +- config.yaml | 46 +++++++++ docs/CONFIGURATION.md | 41 ++++++++ pyproject.toml | 7 +- src/loc_observatory/config.py | 173 ++++++++++++++++++++++++++++++++++ tests/test_config.py | 120 +++++++++++++++++++++++ uv.lock | 114 ++++++++++++++++++++++ 8 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 config.yaml create mode 100644 docs/CONFIGURATION.md create mode 100644 src/loc_observatory/config.py create mode 100644 tests/test_config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10de7b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Reddit API credentials from a Reddit script application +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= + +# Generate with: openssl rand -hex 32 +AUTHOR_HMAC_KEY= + +# LLM classification credential +ANTHROPIC_API_KEY= diff --git a/README.md b/README.md index 5406a26..4f849be 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ src/loc_observatory/ Detailed decisions live in [`docs/adr/`](docs/adr/). Data handling is described in [`docs/DATA_PROTECTION.md`](docs/DATA_PROTECTION.md), and operating instructions are kept in -[`RUNBOOK.md`](RUNBOOK.md). +[`RUNBOOK.md`](RUNBOOK.md). Non-secret settings and required environment variables are documented +in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). ### Set up the project diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..5847a8b --- /dev/null +++ b/config.yaml @@ -0,0 +1,46 @@ +config_version: 1 + +reddit: + subreddits: + - ClaudeAI + - ChatGPT + - LocalLLaMA + - OpenAI + sample_subreddit: ClaudeAI + request_limit: 10 + user_agent: loc-observatory/0.1 (https://github.com/code259/loc-observatory) + +classifier: + provider: anthropic + model: claude-opus-4-6 + max_output_tokens: 2048 + +retention: + raw_posts_days: 90 + artifacts_days: 180 + +# This small starting set will be expanded and tested with the collector. +search: + ai_terms: + - AI + - agent + - ChatGPT + - Claude + - Gemini + - Grok + - LLM + scheming_terms: + - circumvented + - deceptive + - hid + - ignored instructions + - lied + - misaligned + - scheming + - without permission + reaction_terms: + - alarming + - can't believe + - concerning + - unexpected + - worrying diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..219205c --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,41 @@ +# Configuration + +## Plain-language summary + +The repository keeps ordinary settings in `config.yaml` so every run uses visible, reviewed +defaults. Credentials stay in environment variables or a local `.env` file that Git ignores. Process +environment variables take priority over `.env`, which lets deployment platforms provide secrets +without changing files. + +The application reports missing variable names, never their values. Loaded credentials use a +redacted secret type so they are not revealed by normal object logging or error output. + +## Non-secret settings + +`config.yaml` currently controls: + +- Reddit communities, the access-check community, request size, and user agent +- classifier provider, model, and output limit +- source and artifact retention periods +- the first AI, scheming, and reaction search terms + +The file has `config_version: 1`. Unknown fields and unsupported versions fail validation rather than +being ignored. + +## Required environment variables + +Copy `.env.example` to `.env` for local development and fill in only the values you need: + +- `REDDIT_CLIENT_ID`: identifier for a Reddit script application +- `REDDIT_CLIENT_SECRET`: secret for that Reddit application +- `AUTHOR_HMAC_KEY`: at least 32 characters; used to pseudonymise author IDs before storage +- `ANTHROPIC_API_KEY`: credential for the classifier + +Generate the HMAC key with: + +```bash +openssl rand -hex 32 +``` + +Never commit `.env`, paste credentials into issues, or pass them as command-line arguments. Tests +use inert values and do not read the developer's environment. diff --git a/pyproject.toml b/pyproject.toml index 6899b15..d590ddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,18 @@ version = "0.1.0" description = "A small observatory for public reports of concerning AI behaviour" readme = "README.md" requires-python = ">=3.12,<3.13" -dependencies = [] +dependencies = [ + "pydantic>=2.11,<3", + "python-dotenv>=1.1,<2", + "pyyaml>=6,<7", +] [dependency-groups] dev = [ "mypy>=1.17,<2", "pytest>=8.4,<9", "ruff>=0.12,<1", + "types-pyyaml>=6,<7", ] [tool.hatch.build.targets.wheel] diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py new file mode 100644 index 0000000..e4d15f3 --- /dev/null +++ b/src/loc_observatory/config.py @@ -0,0 +1,173 @@ +"""Load and validate application configuration without exposing secrets.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Set +from pathlib import Path +from typing import Literal, Self, cast + +import yaml +from dotenv import dotenv_values +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator +from pydantic.functional_validators import model_validator + +SECRET_FIELDS = { + "ANTHROPIC_API_KEY": "anthropic_api_key", + "AUTHOR_HMAC_KEY": "author_hmac_key", + "REDDIT_CLIENT_ID": "reddit_client_id", + "REDDIT_CLIENT_SECRET": "reddit_client_secret", +} + +ALL_SECRET_NAMES = frozenset(SECRET_FIELDS) +REDDIT_SECRET_NAMES = frozenset({"REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"}) + + +class ConfigurationError(RuntimeError): + """Raised when application configuration is absent or invalid.""" + + +class FrozenSettings(BaseModel): + """Base model for immutable configuration with no silent extra fields.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class RedditSettings(FrozenSettings): + """Non-secret Reddit collection settings.""" + + subreddits: tuple[str, ...] = Field(min_length=1) + sample_subreddit: str = Field(min_length=1) + request_limit: int = Field(ge=1, le=100) + user_agent: str = Field(min_length=10) + + @field_validator("subreddits") + @classmethod + def validate_subreddit_names(cls, value: tuple[str, ...]) -> tuple[str, ...]: + """Keep canonical subreddit names without `r/` prefixes or duplicates.""" + cleaned = tuple(name.strip() for name in value) + if any(not name or name.startswith("r/") for name in cleaned): + raise ValueError("use non-empty subreddit names without an r/ prefix") + if len(set(cleaned)) != len(cleaned): + raise ValueError("subreddit names must be unique") + return cleaned + + @model_validator(mode="after") + def validate_sample_subreddit(self) -> Self: + """Require the access check to use a configured collection source.""" + if self.sample_subreddit not in self.subreddits: + raise ValueError("sample_subreddit must appear in reddit.subreddits") + return self + + +class ClassifierSettings(FrozenSettings): + """Non-secret LLM classification settings.""" + + provider: Literal["anthropic"] + model: str = Field(min_length=1) + max_output_tokens: int = Field(ge=128, le=8192) + + +class RetentionSettings(FrozenSettings): + """Retention periods for collected source data and archived evidence.""" + + raw_posts_days: int = Field(ge=1) + artifacts_days: int = Field(ge=1) + + +class SearchSettings(FrozenSettings): + """Starting search vocabulary grouped by its role in the query.""" + + ai_terms: tuple[str, ...] = Field(min_length=1) + scheming_terms: tuple[str, ...] = Field(min_length=1) + reaction_terms: tuple[str, ...] = Field(min_length=1) + + +class SecretSettings(FrozenSettings): + """Credentials loaded from the process environment or an ignored `.env` file.""" + + anthropic_api_key: SecretStr | None = None + author_hmac_key: SecretStr | None = None + reddit_client_id: SecretStr | None = None + reddit_client_secret: SecretStr | None = None + + @field_validator("author_hmac_key") + @classmethod + def validate_hmac_key(cls, value: SecretStr | None) -> SecretStr | None: + """Require enough key material for author pseudonymisation.""" + if value is not None and len(value.get_secret_value()) < 32: + raise ValueError("AUTHOR_HMAC_KEY must contain at least 32 characters") + return value + + +class AppSettings(FrozenSettings): + """Complete validated application configuration.""" + + config_version: Literal[1] + reddit: RedditSettings + classifier: ClassifierSettings + retention: RetentionSettings + search: SearchSettings + secrets: SecretSettings + + +def load_settings( + config_path: Path = Path("config.yaml"), + *, + env_path: Path | None = Path(".env"), + environ: Mapping[str, str] | None = None, + required_secrets: Set[str] = ALL_SECRET_NAMES, +) -> AppSettings: + """Load one validated settings object, with process values overriding `.env`.""" + unknown_names = required_secrets - ALL_SECRET_NAMES + if unknown_names: + names = ", ".join(sorted(unknown_names)) + raise ConfigurationError(f"Unknown required environment variables: {names}") + + file_values = dotenv_values(env_path) if env_path is not None and env_path.exists() else {} + process_values = os.environ if environ is None else environ + secret_values: dict[str, str] = {} + + for environment_name, field_name in SECRET_FIELDS.items(): + value = process_values.get(environment_name) + if value is None: + value = file_values.get(environment_name) + if value: + secret_values[field_name] = value + + missing_names = sorted( + name for name in required_secrets if SECRET_FIELDS[name] not in secret_values + ) + if missing_names: + names = ", ".join(missing_names) + raise ConfigurationError(f"Missing required environment variables: {names}") + + raw_config = _read_yaml(config_path) + raw_config["secrets"] = secret_values + + try: + return AppSettings.model_validate(raw_config) + except ValidationError as error: + problems = [] + for item in error.errors(include_input=False, include_url=False): + location = ".".join(str(part) for part in item["loc"]) + problems.append(f"{location}: {item['msg']}") + raise ConfigurationError("Invalid configuration: " + "; ".join(problems)) from None + + +def _read_yaml(config_path: Path) -> dict[str, object]: + """Read a YAML mapping and turn parser or file errors into one public error type.""" + try: + with config_path.open(encoding="utf-8") as config_file: + raw_config = cast(object, yaml.safe_load(config_file)) + except OSError as error: + raise ConfigurationError(f"Cannot read configuration file {config_path}: {error}") from None + except yaml.YAMLError as error: + raise ConfigurationError( + f"Cannot parse configuration file {config_path}: {error}" + ) from None + + if not isinstance(raw_config, dict) or not all(isinstance(key, str) for key in raw_config): + raise ConfigurationError(f"Configuration file {config_path} must contain a YAML mapping") + + return {str(key): value for key, value in raw_config.items()} diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cb437db --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,120 @@ +"""Tests for configuration loading and secret handling.""" + +from pathlib import Path + +import pytest + +from loc_observatory.config import ( + ALL_SECRET_NAMES, + REDDIT_SECRET_NAMES, + ConfigurationError, + load_settings, +) + +VALID_CONFIG = """\ +config_version: 1 +reddit: + subreddits: + - ClaudeAI + - ChatGPT + sample_subreddit: ClaudeAI + request_limit: 10 + user_agent: loc-observatory/0.1 access-check +classifier: + provider: anthropic + model: claude-opus-4-6 + max_output_tokens: 2048 +retention: + raw_posts_days: 90 + artifacts_days: 180 +search: + ai_terms: + - AI + - Claude + scheming_terms: + - deceptive + - ignored instructions + reaction_terms: + - alarming + - unexpected +""" + +VALID_SECRETS = { + "ANTHROPIC_API_KEY": "anthropic-secret", + "AUTHOR_HMAC_KEY": "hmac-secret-with-at-least-32-bytes", + "REDDIT_CLIENT_ID": "reddit-client-id", + "REDDIT_CLIENT_SECRET": "reddit-client-secret", +} + + +def write_config(tmp_path: Path, contents: str = VALID_CONFIG) -> Path: + """Write a configuration fixture and return its path.""" + config_path = tmp_path / "config.yaml" + config_path.write_text(contents, encoding="utf-8") + return config_path + + +def test_loads_versioned_config_and_redacts_secrets(tmp_path: Path) -> None: + """Return validated settings without exposing secret values in representations.""" + settings = load_settings(write_config(tmp_path), environ=VALID_SECRETS) + + assert settings.reddit.subreddits == ("ClaudeAI", "ChatGPT") + assert settings.reddit.request_limit == 10 + assert settings.retention.raw_posts_days == 90 + assert settings.secrets.reddit_client_id is not None + assert settings.secrets.reddit_client_id.get_secret_value() == "reddit-client-id" + assert "reddit-client-id" not in repr(settings) + assert "anthropic-secret" not in repr(settings) + + +def test_process_environment_overrides_dotenv_file(tmp_path: Path) -> None: + """Prefer deployment environment values to local development values.""" + env_path = tmp_path / ".env" + env_path.write_text( + "REDDIT_CLIENT_ID=file-client-id\nREDDIT_CLIENT_SECRET=file-secret\n", + encoding="utf-8", + ) + + settings = load_settings( + write_config(tmp_path), + env_path=env_path, + environ={ + "REDDIT_CLIENT_ID": "process-client-id", + "REDDIT_CLIENT_SECRET": "process-secret", + }, + required_secrets=REDDIT_SECRET_NAMES, + ) + + assert settings.secrets.reddit_client_id is not None + assert settings.secrets.reddit_client_id.get_secret_value() == "process-client-id" + + +def test_missing_secrets_are_named_without_exposing_values(tmp_path: Path) -> None: + """Make missing configuration actionable without printing nearby secrets.""" + with pytest.raises(ConfigurationError) as exc_info: + load_settings( + write_config(tmp_path), + environ={"REDDIT_CLIENT_ID": "must-not-appear"}, + required_secrets=REDDIT_SECRET_NAMES, + ) + + message = str(exc_info.value) + assert "REDDIT_CLIENT_SECRET" in message + assert "must-not-appear" not in message + + +def test_default_validation_requires_every_documented_secret(tmp_path: Path) -> None: + """Treat the complete application configuration as the default startup contract.""" + with pytest.raises(ConfigurationError) as exc_info: + load_settings(write_config(tmp_path), environ={}) + + for name in ALL_SECRET_NAMES: + assert name in str(exc_info.value) + + +def test_rejects_sample_subreddit_outside_collection_scope(tmp_path: Path) -> None: + """Catch a sample check that cannot use the configured collection scope.""" + invalid_config = VALID_CONFIG.replace("sample_subreddit: ClaudeAI", "sample_subreddit: OpenAI") + + with pytest.raises(ConfigurationError, match="sample_subreddit"): + load_settings(write_config(tmp_path, invalid_config), environ=VALID_SECRETS) diff --git a/uv.lock b/uv.lock index ea0e14f..1a34d42 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -45,21 +54,33 @@ wheels = [ name = "loc-observatory" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, +] [package.dev-dependencies] dev = [ { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "python-dotenv", specifier = ">=1.1,<2" }, + { name = "pyyaml", specifier = ">=6,<7" }, +] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.17,<2" }, { name = "pytest", specifier = ">=8.4,<9" }, { name = "ruff", specifier = ">=0.12,<1" }, + { name = "types-pyyaml", specifier = ">=6,<7" }, ] [[package]] @@ -120,6 +141,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -145,6 +211,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + [[package]] name = "ruff" version = "0.16.1" @@ -170,6 +263,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -178,3 +280,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] From 6ff0ef00454e0a5ebd6e75fffd003cb732063330 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 14:31:55 -0700 Subject: [PATCH 04/29] Remove Reddit access uncertainty before collector work Add a read-only, non-persistent access check with a narrow provider boundary and safe failure modes. Document local script-app setup and the rate-limit assumptions that later collection work must preserve. Constraint: Reddit credentials remain local and are not available in this workspace Rejected: Store the sample for inspection | access proof does not require retaining user content Confidence: high Scope-risk: narrow Directive: Keep the access check read-only and non-persistent Tested: make check (12 tests); uv build; missing-credential CLI path Not-tested: Live authenticated Reddit request --- README.md | 10 +- RUNBOOK.md | 15 ++- docs/REDDIT_ACCESS.md | 50 ++++++++ pyproject.toml | 4 + src/loc_observatory/cli.py | 97 ++++++++++++++ src/loc_observatory/collector/reddit.py | 94 ++++++++++++++ tests/test_reddit_access.py | 164 ++++++++++++++++++++++++ uv.lock | 120 +++++++++++++++++ 8 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 docs/REDDIT_ACCESS.md create mode 100644 src/loc_observatory/cli.py create mode 100644 src/loc_observatory/collector/reddit.py create mode 100644 tests/test_reddit_access.py diff --git a/README.md b/README.md index 4f849be..88448e5 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,10 @@ AI systems scheme across all uses, and classifier scores are not ground truth. ## Current status -The repository foundation is in place. Collection and analysis are tracked in the -[project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline is -implemented yet. +The repository foundation, validated configuration, and a read-only Reddit access check are in +place. Collection and analysis are tracked in the +[project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline or +stored dataset is implemented yet. ## Technical guide @@ -48,6 +49,9 @@ Detailed decisions live in [`docs/adr/`](docs/adr/). Data handling is described [`RUNBOOK.md`](RUNBOOK.md). Non-secret settings and required environment variables are documented in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). +Reddit application setup and the access check are documented in +[`docs/REDDIT_ACCESS.md`](docs/REDDIT_ACCESS.md). + ### Set up the project Requirements: diff --git a/RUNBOOK.md b/RUNBOOK.md index d178010..f1219fb 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -17,6 +17,20 @@ make check Expected result: formatting, linting, type checking, and tests all pass. +## Check Reddit access + +After adding Reddit credentials to `.env`, run: + +```bash +uv run observatory reddit check-access +``` + +Expected result: one JSON object reporting `"status": "ok"` and `"posts_fetched": 10`. The +command does not store or print the posts. + +If credentials are absent, it exits with status 2 and names the missing variables. If Reddit rejects +the credentials or the request fails, it exits with status 1 and reports the provider error type. + ## Common setup failures ### `uv` cannot find Python 3.12 @@ -42,7 +56,6 @@ Do not delete local databases or collected artifacts as a generic recovery step. The following procedures are not implemented yet: -- Reddit rate-limit or access failure - LLM provider outage and dead-letter replay - Abnormal collection volume - Retention and erasure diff --git a/docs/REDDIT_ACCESS.md b/docs/REDDIT_ACCESS.md new file mode 100644 index 0000000..d2bfd47 --- /dev/null +++ b/docs/REDDIT_ACCESS.md @@ -0,0 +1,50 @@ +# Reddit access + +## Plain-language summary + +The setup check confirms that the project can read ten recent posts from one configured Reddit +community. It uses read-only application credentials. It does not use a Reddit username or +password, and it does not print or store post content, post IDs, or author details. + +This check proves basic access only. Filtering, redaction, retries, and database writes belong to the +collector task. + +## Create a Reddit application + +1. Sign in to Reddit and open [App Preferences](https://www.reddit.com/prefs/apps). +2. Choose **create another app** and select the **script** application type. +3. Add a clear name and description. Reddit requires a redirect URI even though this read-only + check does not use it; `http://localhost:8080` is sufficient for local setup. +4. Copy the short client ID shown beneath the application name and the client secret. +5. Copy `.env.example` to `.env`, then set `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET`. + +Do not add a Reddit username or password. Do not paste credentials into commands, logs, commits, +or issues. + +## Run the check + +```bash +uv run observatory reddit check-access +``` + +Success produces one JSON object like: + +```json +{"posts_fetched": 10, "read_only": true, "status": "ok", "subreddit": "ClaudeAI"} +``` + +The command exits with status 2 for missing or invalid local configuration and status 1 for a Reddit +request failure. + +## Access and rate-limit assumptions + +The command uses PRAW's application-only client-credentials flow and explicitly enables read-only +mode. It sets a ten-second request timeout. PRAW follows Reddit's `X-Ratelimit-*` headers and waits +between requests; its default five-second handling threshold is kept explicit here. This setup check +makes one small listing request and adds no retry loop. Bounded retries and collection metrics are +part of the collector task. + +References: + +- [PRAW authentication and read-only mode](https://praw.readthedocs.io/en/stable/getting_started/authentication.html) +- [PRAW rate-limit handling](https://praw.readthedocs.io/en/stable/getting_started/ratelimits.html) diff --git a/pyproject.toml b/pyproject.toml index d590ddc..5b6a64b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,11 +9,15 @@ description = "A small observatory for public reports of concerning AI behaviour readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ + "praw>=8,<9", "pydantic>=2.11,<3", "python-dotenv>=1.1,<2", "pyyaml>=6,<7", ] +[project.scripts] +observatory = "loc_observatory.cli:main" + [dependency-groups] dev = [ "mypy>=1.17,<2", diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py new file mode 100644 index 0000000..e1e3764 --- /dev/null +++ b/src/loc_observatory/cli.py @@ -0,0 +1,97 @@ +"""Command-line entry point for Observatory operations.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +from loc_observatory.collector.reddit import ( + PrawRedditGateway, + RedditAccessError, + RedditGateway, + run_access_check, +) +from loc_observatory.config import ( + REDDIT_SECRET_NAMES, + AppSettings, + ConfigurationError, + load_settings, +) + +type GatewayFactory = Callable[[AppSettings], RedditGateway] + + +def build_parser() -> argparse.ArgumentParser: + """Build the public command tree.""" + parser = argparse.ArgumentParser(prog="observatory") + parser.add_argument("--config", type=Path, default=Path("config.yaml")) + parser.add_argument("--env-file", type=Path, default=Path(".env")) + + commands = parser.add_subparsers(dest="command", required=True) + reddit_parser = commands.add_parser("reddit", help="Reddit setup and collection commands") + reddit_commands = reddit_parser.add_subparsers(dest="reddit_command", required=True) + reddit_commands.add_parser( + "check-access", + help="Fetch ten recent post IDs without printing or storing them", + ) + return parser + + +def main( + argv: Sequence[str] | None = None, + *, + gateway_factory: GatewayFactory = PrawRedditGateway.from_settings, +) -> int: + """Run a command and return a process exit status.""" + arguments = build_parser().parse_args(argv) + + if arguments.command == "reddit" and arguments.reddit_command == "check-access": + return _check_reddit_access(arguments, gateway_factory) + + build_parser().error("unsupported command") + return 2 + + +def _check_reddit_access( + arguments: argparse.Namespace, + gateway_factory: GatewayFactory, +) -> int: + """Load the Reddit credential subset and run the non-persistent access check.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=REDDIT_SECRET_NAMES, + ) + gateway = gateway_factory(settings) + result = run_access_check( + gateway, + subreddit=settings.reddit.sample_subreddit, + limit=settings.reddit.request_limit, + ) + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except RedditAccessError as error: + print(f"Reddit access check failed: {error}", file=sys.stderr) + return 1 + + print( + json.dumps( + { + "posts_fetched": result.posts_fetched, + "read_only": result.read_only, + "status": "ok", + "subreddit": result.subreddit, + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/loc_observatory/collector/reddit.py b/src/loc_observatory/collector/reddit.py new file mode 100644 index 0000000..1782bb2 --- /dev/null +++ b/src/loc_observatory/collector/reddit.py @@ -0,0 +1,94 @@ +"""Read-only Reddit boundary and access check.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import praw +from praw.exceptions import PRAWException +from prawcore.exceptions import PrawcoreException +from pydantic import SecretStr + +from loc_observatory.config import AppSettings, ConfigurationError + + +class RedditAccessError(RuntimeError): + """Raised when Reddit access cannot produce a complete, trustworthy sample.""" + + +class RedditGateway(Protocol): + """Small read-only boundary needed by the access check.""" + + def recent_post_ids(self, subreddit: str, limit: int) -> tuple[str, ...]: + """Return IDs for recent posts without retaining their content.""" + ... + + +@dataclass(frozen=True, slots=True) +class RedditAccessResult: + """Non-sensitive proof that a read-only listing request succeeded.""" + + subreddit: str + posts_fetched: int + read_only: bool = True + + +class PrawRedditGateway: + """PRAW adapter for application-only, read-only Reddit access.""" + + def __init__(self, reddit: praw.Reddit) -> None: + self._reddit = reddit + + @classmethod + def from_settings(cls, settings: AppSettings) -> PrawRedditGateway: + """Create a read-only PRAW client from validated settings.""" + client_id = _required_secret(settings.secrets.reddit_client_id, "REDDIT_CLIENT_ID") + client_secret = _required_secret( + settings.secrets.reddit_client_secret, + "REDDIT_CLIENT_SECRET", + ) + reddit = praw.Reddit( + client_id=client_id, + client_secret=client_secret, + user_agent=settings.reddit.user_agent, + ratelimit_seconds=5, + requestor_kwargs={"timeout": 10}, + ) + reddit.read_only = True + return cls(reddit) + + def recent_post_ids(self, subreddit: str, limit: int) -> tuple[str, ...]: + """Fetch only IDs from one recent-post listing and translate provider failures.""" + try: + submissions = self._reddit.subreddit(subreddit).new(limit=limit) + return tuple(submission.id for submission in submissions) + except (PRAWException, PrawcoreException) as error: + error_name = type(error).__name__ + raise RedditAccessError(f"Reddit API request failed ({error_name})") from None + + +def run_access_check( + gateway: RedditGateway, + *, + subreddit: str, + limit: int, +) -> RedditAccessResult: + """Require a complete set of unique IDs before reporting access success.""" + if limit < 1: + raise ValueError("limit must be positive") + + post_ids = gateway.recent_post_ids(subreddit, limit) + if len(post_ids) != limit or len(set(post_ids)) != limit: + raise RedditAccessError( + f"Reddit access check expected {limit} unique posts but received {len(set(post_ids))}" + ) + + return RedditAccessResult(subreddit=subreddit, posts_fetched=len(post_ids)) + + +def _required_secret(value: SecretStr | None, environment_name: str) -> str: + """Narrow an already-required secret without relying on an assertion.""" + if value is None: + raise ConfigurationError(f"Missing required environment variable: {environment_name}") + return value.get_secret_value() diff --git a/tests/test_reddit_access.py b/tests/test_reddit_access.py new file mode 100644 index 0000000..6ba59e2 --- /dev/null +++ b/tests/test_reddit_access.py @@ -0,0 +1,164 @@ +"""Tests for the read-only Reddit access check.""" + +import json +from collections.abc import Sequence +from pathlib import Path + +import pytest + +from loc_observatory.cli import main +from loc_observatory.collector.reddit import RedditAccessError, RedditGateway, run_access_check +from loc_observatory.config import AppSettings + + +class FakeRedditGateway: + """Inert Reddit boundary used by access-check tests.""" + + def __init__(self, post_ids: Sequence[str] = ()) -> None: + self.post_ids = tuple(post_ids) + + def recent_post_ids(self, subreddit: str, limit: int) -> tuple[str, ...]: + """Return the configured IDs without making a network request.""" + return self.post_ids[:limit] + + +class FailingRedditGateway: + """Inert Reddit boundary that behaves like a provider failure.""" + + def recent_post_ids(self, subreddit: str, limit: int) -> tuple[str, ...]: + """Raise the public provider error used by the CLI.""" + raise RedditAccessError("Reddit API request failed (OAuthException)") + + +def write_reddit_env(tmp_path: Path) -> Path: + """Write inert Reddit credentials for CLI tests.""" + env_path = tmp_path / ".env" + env_path.write_text( + "REDDIT_CLIENT_ID=test-client-id\nREDDIT_CLIENT_SECRET=test-client-secret\n", + encoding="utf-8", + ) + return env_path + + +def test_access_check_requires_requested_number_of_unique_posts() -> None: + """Accept only a complete sample that proves the listing could be read.""" + result = run_access_check( + FakeRedditGateway([f"post-{index}" for index in range(10)]), + subreddit="ClaudeAI", + limit=10, + ) + + assert result.subreddit == "ClaudeAI" + assert result.posts_fetched == 10 + assert result.read_only is True + + +@pytest.mark.parametrize( + "post_ids", + [ + ["post-1", "post-2"], + ["post-1"] * 10, + ], +) +def test_access_check_rejects_incomplete_or_duplicate_samples(post_ids: list[str]) -> None: + """Do not report success for a short or internally inconsistent response.""" + with pytest.raises(RedditAccessError, match="10 unique posts"): + run_access_check(FakeRedditGateway(post_ids), subreddit="ClaudeAI", limit=10) + + +def test_cli_prints_counts_without_returning_post_data( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep the access proof useful without printing or storing sampled content.""" + monkeypatch.delenv("REDDIT_CLIENT_ID", raising=False) + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + + def gateway_factory(_settings: AppSettings) -> RedditGateway: + return FakeRedditGateway([f"private-post-id-{index}" for index in range(10)]) + + exit_code = main( + [ + "--config", + "config.yaml", + "--env-file", + str(write_reddit_env(tmp_path)), + "reddit", + "check-access", + ], + gateway_factory=gateway_factory, + ) + + output = capsys.readouterr() + payload = json.loads(output.out) + assert exit_code == 0 + assert payload == { + "posts_fetched": 10, + "read_only": True, + "status": "ok", + "subreddit": "ClaudeAI", + } + assert "private-post-id" not in output.out + assert output.err == "" + + +def test_cli_reports_missing_credentials_without_calling_reddit( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail before network access when the local setup is incomplete.""" + monkeypatch.delenv("REDDIT_CLIENT_ID", raising=False) + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + called = False + + def gateway_factory(_settings: AppSettings) -> RedditGateway: + nonlocal called + called = True + return FakeRedditGateway() + + exit_code = main( + [ + "--config", + "config.yaml", + "--env-file", + str(tmp_path / "missing.env"), + "reddit", + "check-access", + ], + gateway_factory=gateway_factory, + ) + + output = capsys.readouterr() + assert exit_code == 2 + assert called is False + assert "REDDIT_CLIENT_ID" in output.err + assert "REDDIT_CLIENT_SECRET" in output.err + + +def test_cli_returns_safe_provider_failure( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Return a clear error without including credentials or sampled content.""" + monkeypatch.delenv("REDDIT_CLIENT_ID", raising=False) + monkeypatch.delenv("REDDIT_CLIENT_SECRET", raising=False) + + exit_code = main( + [ + "--config", + "config.yaml", + "--env-file", + str(write_reddit_env(tmp_path)), + "reddit", + "check-access", + ], + gateway_factory=lambda _settings: FailingRedditGateway(), + ) + + output = capsys.readouterr() + assert exit_code == 1 + assert "OAuthException" in output.err + assert "test-client-secret" not in output.err diff --git a/uv.lock b/uv.lock index 1a34d42..89ad6d6 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -20,6 +51,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -55,6 +104,7 @@ name = "loc-observatory" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "praw" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -70,6 +120,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "praw", specifier = ">=8,<9" }, { name = "pydantic", specifier = ">=2.11,<3" }, { name = "python-dotenv", specifier = ">=1.1,<2" }, { name = "pyyaml", specifier = ">=6,<7" }, @@ -141,6 +192,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "praw" +version = "8.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "prawcore" }, + { name = "update-checker" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/07/bee3bab8634965354402d28aaabae7932097dfa5d9895afbeebeac24de69/praw-8.0.2.tar.gz", hash = "sha256:29bfe995f7f24017dd8eee36153085ad27de1740f13c7dcdb08df42a24a0dae2", size = 23798066, upload-time = "2026-06-24T00:06:31.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/7e/0831abb5ef9db1db8981f2c230c5a71ecc81732a7924ec79b076f5eea693/praw-8.0.2-py3-none-any.whl", hash = "sha256:22a988bef07e2d840ab062ae25f760c5bfc6367b9ecaab69f71e7ff511d75564", size = 199203, upload-time = "2026-06-24T00:06:29.73Z" }, +] + +[[package]] +name = "prawcore" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/06/d6eaaaa5ec11ebc56b176e16233c4dbd7ae5a51a9d82649cad8aa2ba3b43/prawcore-4.0.0.tar.gz", hash = "sha256:c7e4d6e1b71e2b9c88680aee467e3e07d22df4c91bb01488ce2a3ab43d76b076", size = 1208273, upload-time = "2026-06-13T02:00:32.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/97/e52a0a62a14b17ce382d68d00a838887deab52eddac8d9e87f4e6274efcd/prawcore-4.0.0-py3-none-any.whl", hash = "sha256:53f91bcd4cb25a26ca58f3ba7c5bf83d9d93a74025aae2c926bc6b0d5fd11df7", size = 19294, upload-time = "2026-06-13T02:00:30.519Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -238,6 +316,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.16.1" @@ -292,3 +385,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "update-checker" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/0d/cdf2a0bc53f2b0b85a9cc7a81d0c5fa666dc140d26a1797ddf8ce4a24d0d/update_checker-1.0.0.tar.gz", hash = "sha256:bfcac66414572a82a98ea8c8633bf1ce5d102750e3b93469b89b30aa845cd1d7", size = 9600, upload-time = "2026-06-08T07:08:20.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/f5/a264987b052d61c172d23eec92935480535fd32e45357bd6557fa2c687d5/update_checker-1.0.0-py3-none-any.whl", hash = "sha256:5837640948ff21820ba3ea881fb4bed5abefb39037895c6447f5e712236d60c6", size = 10618, upload-time = "2026-06-08T07:08:19.782Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] From 509f49609f1bd0484b54d0aa17960abe559d4acc Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 14:55:45 -0700 Subject: [PATCH 05/29] Keep live collection on a sanctioned source Reddit approval cannot fit the demonstration window, so use Bluesky's documented public AppView for the working path while preserving Reddit as an approval-gated adapter. Record the rejected bypasses and validate the new response boundary before exposing a successful access result. Constraint: Reddit Data API approval can take weeks Rejected: Reddit .json endpoints | not an approved collection contract Rejected: Browser scraping with residential proxies | bypasses access controls and is operationally fragile Confidence: high Scope-risk: moderate Directive: Keep source-specific access behind adapters and do not enable Reddit without approval Tested: make check (20 tests); uv build; live observatory bluesky check-access returned 10 posts Not-tested: Bluesky rate-limit and outage behavior under sustained collection --- .env.example | 2 +- AGENTS.md | 2 +- README.md | 16 +- RUNBOOK.md | 11 +- config.yaml | 9 +- docs/BLUESKY_ACCESS.md | 42 +++++ docs/CONFIGURATION.md | 16 +- docs/DATA_PROTECTION.md | 6 +- docs/REDDIT_ACCESS.md | 52 ++---- .../0002-use-bluesky-for-live-collection.md | 49 ++++++ pyproject.toml | 1 + src/loc_observatory/cli.py | 74 ++++++++- src/loc_observatory/collector/bluesky.py | 130 +++++++++++++++ src/loc_observatory/config.py | 28 +++- tests/test_bluesky_access.py | 156 ++++++++++++++++++ tests/test_config.py | 17 +- tests/test_reddit_access.py | 6 +- uv.lock | 52 ++++++ 18 files changed, 596 insertions(+), 73 deletions(-) create mode 100644 docs/BLUESKY_ACCESS.md create mode 100644 docs/adr/0002-use-bluesky-for-live-collection.md create mode 100644 src/loc_observatory/collector/bluesky.py create mode 100644 tests/test_bluesky_access.py diff --git a/.env.example b/.env.example index 10de7b2..98056d2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Reddit API credentials from a Reddit script application +# Optional: use only after Reddit approves this project's API access REDDIT_CLIENT_ID= REDDIT_CLIENT_SECRET= diff --git a/AGENTS.md b/AGENTS.md index 577f785..a40d7be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Scores are prioritisation signals, not ground truth or calibrated probabilities. Deliver work in this order: -1. One Reddit post flows through collection, classification, storage, and a visible report. +1. One Bluesky post flows through collection, classification, storage, and a visible report. 2. The pipeline handles realistic batches idempotently and records provenance. 3. Failures are isolated, observable, and recoverable. 4. Privacy controls, retention, and erasure work and are documented. diff --git a/README.md b/README.md index 88448e5..b562511 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ AI systems sometimes act against a user's instructions or hide what they have done. Reports of these events are scattered across public forums, which makes patterns difficult to see. This project -is building a small observatory to collect relevant Reddit posts, remove direct author identifiers, +is building a small observatory to collect relevant public posts, remove direct author identifiers, score the strength of the evidence, and present the results for review. The project adapts the public-transcript method described in CLTR's @@ -13,7 +13,8 @@ It is an independent reference implementation, not an official CLTR system. The pilot report describes several limits. This implementation focuses on three: -1. **Coverage:** the pilot collected posts from X. This project adds Reddit as a separate source. +1. **Coverage:** the pilot collected posts from X. This project adds Bluesky as a separate source + through its documented public API. 2. **Evidence authenticity:** the pipeline records where evidence came from and will preserve supported chatbot share links with a content hash. This reduces evidence loss but cannot prove that every public report is genuine. @@ -26,8 +27,8 @@ AI systems scheme across all uses, and classifier scores are not ground truth. ## Current status -The repository foundation, validated configuration, and a read-only Reddit access check are in -place. Collection and analysis are tracked in the +The repository foundation, validated configuration, and a live read-only Bluesky access check are +in place. Collection and analysis are tracked in the [project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline or stored dataset is implemented yet. @@ -37,7 +38,7 @@ stored dataset is implemented yet. ```text src/loc_observatory/ -├── collector/ Reddit access, filtering, and author redaction +├── collector/ Source adapters, filtering, and author redaction ├── classifier/ Evidence prompts, provider calls, and result validation ├── warehouse/ SQLite schema, retention, erasure, and audit records ├── reporting/ Static reports and exports @@ -49,8 +50,9 @@ Detailed decisions live in [`docs/adr/`](docs/adr/). Data handling is described [`RUNBOOK.md`](RUNBOOK.md). Non-secret settings and required environment variables are documented in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). -Reddit application setup and the access check are documented in -[`docs/REDDIT_ACCESS.md`](docs/REDDIT_ACCESS.md). +Bluesky access is documented in [`docs/BLUESKY_ACCESS.md`](docs/BLUESKY_ACCESS.md). The reason +Reddit is not the live source is recorded in +[`docs/adr/0002-use-bluesky-for-live-collection.md`](docs/adr/0002-use-bluesky-for-live-collection.md). ### Set up the project diff --git a/RUNBOOK.md b/RUNBOOK.md index f1219fb..3868f32 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -17,19 +17,20 @@ make check Expected result: formatting, linting, type checking, and tests all pass. -## Check Reddit access +## Check Bluesky access -After adding Reddit credentials to `.env`, run: +Run: ```bash -uv run observatory reddit check-access +uv run observatory bluesky check-access ``` Expected result: one JSON object reporting `"status": "ok"` and `"posts_fetched": 10`. The command does not store or print the posts. -If credentials are absent, it exits with status 2 and names the missing variables. If Reddit rejects -the credentials or the request fails, it exits with status 1 and reports the provider error type. +The command exits with status 2 for invalid local configuration. It exits with status 1 when the +Bluesky request fails or its response does not match the expected public contract. See +[`docs/BLUESKY_ACCESS.md`](docs/BLUESKY_ACCESS.md) for the current endpoint assumptions. ## Common setup failures diff --git a/config.yaml b/config.yaml index 5847a8b..dbbdb1b 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,11 @@ -config_version: 1 +config_version: 2 + +bluesky: + api_base_url: https://api.bsky.app + sample_query: Claude AI + request_limit: 10 + timeout_seconds: 10 + user_agent: loc-observatory/0.1 (https://github.com/code259/loc-observatory) reddit: subreddits: diff --git a/docs/BLUESKY_ACCESS.md b/docs/BLUESKY_ACCESS.md new file mode 100644 index 0000000..cb2bbf4 --- /dev/null +++ b/docs/BLUESKY_ACCESS.md @@ -0,0 +1,42 @@ +# Bluesky access + +## Plain-language summary + +Bluesky provides a documented public interface for reading public posts without an account or API +key. The setup check retrieves ten recent search results and reports only the count. It does not +print or store post content, post identifiers, or author details. + +This proves basic access only. Filtering, redaction, retries, and database writes belong to the +collector task. + +## Run the check + +```bash +uv run observatory bluesky check-access +``` + +Success produces one JSON object like: + +```json +{"posts_fetched": 10, "query": "Claude AI", "read_only": true, "source": "bluesky", "status": "ok"} +``` + +The command exits with status 2 for invalid configuration and status 1 for a network, HTTP, or +response-validation failure. + +## Access assumptions + +The check calls the documented `app.bsky.feed.searchPosts` endpoint with a descriptive user agent and +a ten-second timeout. The API base URL is versioned configuration rather than hard-coded business +logic. The general AppView host, `https://api.bsky.app`, is the default because it returned search +results during the live setup check. The cached `https://public.api.bsky.app` host returned HTTP 403 +for the same search from the development environment on 4 August 2026. + +Bluesky describes public AppView reads as unauthenticated and asks public-web clients to use its +cached endpoint where supported. Limits may change. The collector must handle HTTP 429 responses, +provider retry hints, timeouts, and partial batches without silently losing data. + +References: + +- [Bluesky API hosts and authentication](https://docs.bsky.app/docs/advanced-guides/api-directory) +- [Bluesky rate limits](https://docs.bsky.app/docs/advanced-guides/rate-limits) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 219205c..7b7b172 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -14,23 +14,29 @@ redacted secret type so they are not revealed by normal object logging or error `config.yaml` currently controls: -- Reddit communities, the access-check community, request size, and user agent +- Bluesky API location, access-check query, request size, timeout, and user agent +- settings for the optional, approval-gated Reddit adapter - classifier provider, model, and output limit - source and artifact retention periods - the first AI, scheming, and reaction search terms -The file has `config_version: 1`. Unknown fields and unsupported versions fail validation rather than +The file has `config_version: 2`. Unknown fields and unsupported versions fail validation rather than being ignored. ## Required environment variables -Copy `.env.example` to `.env` for local development and fill in only the values you need: +Copy `.env.example` to `.env` for local development and fill in only the values you need. The +default Bluesky access path does not require credentials. -- `REDDIT_CLIENT_ID`: identifier for a Reddit script application -- `REDDIT_CLIENT_SECRET`: secret for that Reddit application - `AUTHOR_HMAC_KEY`: at least 32 characters; used to pseudonymise author IDs before storage - `ANTHROPIC_API_KEY`: credential for the classifier +The following variables are optional and must be used only if Reddit approves access for this +project: + +- `REDDIT_CLIENT_ID`: identifier for the approved Reddit application +- `REDDIT_CLIENT_SECRET`: secret for that Reddit application + Generate the HMAC key with: ```bash diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md index 5dbf9cc..8ede27e 100644 --- a/docs/DATA_PROTECTION.md +++ b/docs/DATA_PROTECTION.md @@ -2,8 +2,8 @@ ## Plain-language summary -This project will collect public Reddit posts about concerning AI behaviour. Public availability does -not remove the need to protect the people who wrote them. The pipeline will remove direct author +This project will collect public Bluesky posts about concerning AI behaviour. Public availability +does not remove the need to protect the people who wrote them. The pipeline will remove direct author identifiers before data is stored, keep only fields needed for analysis, limit how long data is kept, and support deletion requests. @@ -13,7 +13,7 @@ as each safeguard is added. ## Planned controls -- Replace the Reddit author ID with a keyed HMAC before persistence or logging. +- Replace the Bluesky author DID with a keyed HMAC before persistence or logging. - Keep the HMAC secret outside the repository and database. - Store only the post fields needed for evidence review, provenance, and deletion. - Keep collected data and archived evidence out of Git. diff --git a/docs/REDDIT_ACCESS.md b/docs/REDDIT_ACCESS.md index d2bfd47..aed2c81 100644 --- a/docs/REDDIT_ACCESS.md +++ b/docs/REDDIT_ACCESS.md @@ -1,50 +1,34 @@ -# Reddit access +# Reddit access status ## Plain-language summary -The setup check confirms that the project can read ten recent posts from one configured Reddit -community. It uses read-only application credentials. It does not use a Reddit username or -password, and it does not print or store post content, post IDs, or author details. +Reddit requires explicit approval before a new project uses its Data API. Approval can take longer +than this demonstration's build window, so Reddit is not the live source. The codebase keeps a +read-only adapter for future approved use, but the working pipeline uses Bluesky. -This check proves basic access only. Filtering, redaction, retries, and database writes belong to the -collector task. +Appending `.json` to public Reddit pages is not treated as an approved collection interface. +Browser automation and residential proxies are also excluded because they would bypass Reddit's +access controls and produce a fragile collector. -## Create a Reddit application +## Optional approved setup -1. Sign in to Reddit and open [App Preferences](https://www.reddit.com/prefs/apps). -2. Choose **create another app** and select the **script** application type. -3. Add a clear name and description. Reddit requires a redirect URI even though this read-only - check does not use it; `http://localhost:8080` is sufficient for local setup. -4. Copy the short client ID shown beneath the application name and the client secret. -5. Copy `.env.example` to `.env`, then set `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET`. +If Reddit approves this project later, add the issued application values to the ignored `.env` file: -Do not add a Reddit username or password. Do not paste credentials into commands, logs, commits, -or issues. +```dotenv +REDDIT_CLIENT_ID=... +REDDIT_CLIENT_SECRET=... +``` -## Run the check +Then run: ```bash uv run observatory reddit check-access ``` -Success produces one JSON object like: - -```json -{"posts_fetched": 10, "read_only": true, "status": "ok", "subreddit": "ClaudeAI"} -``` - -The command exits with status 2 for missing or invalid local configuration and status 1 for a Reddit -request failure. - -## Access and rate-limit assumptions - -The command uses PRAW's application-only client-credentials flow and explicitly enables read-only -mode. It sets a ten-second request timeout. PRAW follows Reddit's `X-Ratelimit-*` headers and waits -between requests; its default five-second handling threshold is kept explicit here. This setup check -makes one small listing request and adds no retry loop. Bounded retries and collection metrics are -part of the collector task. +The command is read-only and does not print or store post content, post IDs, or author details. It is +not part of the default demo path and has not been validated with approved credentials. References: -- [PRAW authentication and read-only mode](https://praw.readthedocs.io/en/stable/getting_started/authentication.html) -- [PRAW rate-limit handling](https://praw.readthedocs.io/en/stable/getting_started/ratelimits.html) +- [Reddit Data API guidance](https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki) +- [Reddit Responsible Builder Policy](https://support.reddithelp.com/hc/en-us/articles/16471395473812-Responsible-Builder-Policy) diff --git a/docs/adr/0002-use-bluesky-for-live-collection.md b/docs/adr/0002-use-bluesky-for-live-collection.md new file mode 100644 index 0000000..c9421f7 --- /dev/null +++ b/docs/adr/0002-use-bluesky-for-live-collection.md @@ -0,0 +1,49 @@ +# ADR 0002: Use Bluesky for live collection + +- **Status:** Accepted +- **Date:** 2026-08-04 + +## Context + +The first plan used Reddit to extend CLTR's pilot beyond X. Reddit now requires explicit approval for +Data API access, and approval can take weeks. The demonstration needs one real, supportable source +within a 7–8 hour implementation window. + +Reddit pages can sometimes return JSON without OAuth, and browser automation can imitate a user. +Neither is a stable or approved collection contract. Residential proxies would also hide the origin +of automated traffic and bypass access controls. Those choices conflict with the operational +judgment this project is intended to demonstrate. + +Bluesky documents unauthenticated public AppView APIs and provides public posts relevant to the same +monitoring question. + +## Decision + +Use Bluesky as the live source for the first complete pipeline. Keep source-specific network code +behind a narrow adapter so collection, redaction, classification, storage, and reporting remain +source-neutral. + +Keep the existing Reddit adapter as optional, approval-gated code. Do not use Reddit's `.json` +responses, browser scraping, residential proxies, or unapproved credentials. Tests use inert +fixtures and never depend on either live service. + +## Consequences + +- The project can demonstrate a real social-data request without credentials or an approval delay. +- A provider change does not require a rewrite of domain or storage logic. +- Bluesky has no subreddit boundary. Search terms and provenance replace community selection. +- Results describe Bluesky's public population and search index. They cannot be compared directly + with the pilot's X sample or treated as representative of Reddit. +- Public search availability and limits remain external dependencies. Failures need explicit + metrics, retries, and runbook guidance. +- Reddit can be enabled later only after approval and a fresh policy review. + +## Alternatives considered + +- **Wait for Reddit approval:** rejected because it blocks the time-limited demonstration. +- **Append `.json` to Reddit URLs:** rejected because it is not an approved, stable collection + contract. +- **Use Playwright with residential proxies:** rejected because it bypasses access controls and is + operationally fragile. +- **Use fixtures only:** rejected as the sole path because one sanctioned live boundary provides + useful integration evidence. Fixtures remain the deterministic test path. diff --git a/pyproject.toml b/pyproject.toml index 5b6a64b..c6a119a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "A small observatory for public reports of concerning AI behaviour readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ + "httpx>=0.28,<1", "praw>=8,<9", "pydantic>=2.11,<3", "python-dotenv>=1.1,<2", diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py index e1e3764..f834143 100644 --- a/src/loc_observatory/cli.py +++ b/src/loc_observatory/cli.py @@ -8,11 +8,21 @@ from collections.abc import Callable, Sequence from pathlib import Path +from loc_observatory.collector.bluesky import ( + BlueskyAccessError, + BlueskyGateway, + BlueskyPostGateway, +) +from loc_observatory.collector.bluesky import ( + run_access_check as run_bluesky_access_check, +) from loc_observatory.collector.reddit import ( PrawRedditGateway, RedditAccessError, RedditGateway, - run_access_check, +) +from loc_observatory.collector.reddit import ( + run_access_check as run_reddit_access_check, ) from loc_observatory.config import ( REDDIT_SECRET_NAMES, @@ -21,7 +31,8 @@ load_settings, ) -type GatewayFactory = Callable[[AppSettings], RedditGateway] +type BlueskyGatewayFactory = Callable[[AppSettings], BlueskyPostGateway] +type RedditGatewayFactory = Callable[[AppSettings], RedditGateway] def build_parser() -> argparse.ArgumentParser: @@ -31,6 +42,14 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--env-file", type=Path, default=Path(".env")) commands = parser.add_subparsers(dest="command", required=True) + + bluesky_parser = commands.add_parser("bluesky", help="Bluesky setup and collection commands") + bluesky_commands = bluesky_parser.add_subparsers(dest="bluesky_command", required=True) + bluesky_commands.add_parser( + "check-access", + help="Fetch ten recent post IDs without printing or storing them", + ) + reddit_parser = commands.add_parser("reddit", help="Reddit setup and collection commands") reddit_commands = reddit_parser.add_subparsers(dest="reddit_command", required=True) reddit_commands.add_parser( @@ -43,21 +62,64 @@ def build_parser() -> argparse.ArgumentParser: def main( argv: Sequence[str] | None = None, *, - gateway_factory: GatewayFactory = PrawRedditGateway.from_settings, + bluesky_gateway_factory: BlueskyGatewayFactory = BlueskyGateway.from_settings, + reddit_gateway_factory: RedditGatewayFactory = PrawRedditGateway.from_settings, ) -> int: """Run a command and return a process exit status.""" arguments = build_parser().parse_args(argv) + if arguments.command == "bluesky" and arguments.bluesky_command == "check-access": + return _check_bluesky_access(arguments, bluesky_gateway_factory) + if arguments.command == "reddit" and arguments.reddit_command == "check-access": - return _check_reddit_access(arguments, gateway_factory) + return _check_reddit_access(arguments, reddit_gateway_factory) build_parser().error("unsupported command") return 2 +def _check_bluesky_access( + arguments: argparse.Namespace, + gateway_factory: BlueskyGatewayFactory, +) -> int: + """Run the non-persistent Bluesky access check without requiring credentials.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=frozenset(), + ) + gateway = gateway_factory(settings) + result = run_bluesky_access_check( + gateway, + query=settings.bluesky.sample_query, + limit=settings.bluesky.request_limit, + ) + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except BlueskyAccessError as error: + print(f"Bluesky access check failed: {error}", file=sys.stderr) + return 1 + + print( + json.dumps( + { + "posts_fetched": result.posts_fetched, + "query": result.query, + "read_only": result.read_only, + "source": "bluesky", + "status": "ok", + }, + sort_keys=True, + ) + ) + return 0 + + def _check_reddit_access( arguments: argparse.Namespace, - gateway_factory: GatewayFactory, + gateway_factory: RedditGatewayFactory, ) -> int: """Load the Reddit credential subset and run the non-persistent access check.""" try: @@ -67,7 +129,7 @@ def _check_reddit_access( required_secrets=REDDIT_SECRET_NAMES, ) gateway = gateway_factory(settings) - result = run_access_check( + result = run_reddit_access_check( gateway, subreddit=settings.reddit.sample_subreddit, limit=settings.reddit.request_limit, diff --git a/src/loc_observatory/collector/bluesky.py b/src/loc_observatory/collector/bluesky.py new file mode 100644 index 0000000..6986638 --- /dev/null +++ b/src/loc_observatory/collector/bluesky.py @@ -0,0 +1,130 @@ +"""Read-only Bluesky boundary and access check.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from loc_observatory.config import AppSettings + + +class BlueskyAccessError(RuntimeError): + """Raised when Bluesky access cannot produce a complete, trustworthy sample.""" + + +class BlueskyPostGateway(Protocol): + """Small read-only boundary needed by the access check.""" + + def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: + """Return AT URIs for recent posts without retaining their content.""" + ... + + +class _SearchPost(BaseModel): + """Minimum post shape required from the external search response.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + uri: str + + @field_validator("uri") + @classmethod + def validate_post_uri(cls, value: str) -> str: + """Reject identifiers that are not Bluesky post AT URIs.""" + if not value.startswith("at://") or "/app.bsky.feed.post/" not in value: + raise ValueError("expected a Bluesky post AT URI") + return value + + +class _SearchResponse(BaseModel): + """Minimum forward-compatible response used by the access check.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + posts: tuple[_SearchPost, ...] = Field(min_length=1) + + +@dataclass(frozen=True, slots=True) +class BlueskyAccessResult: + """Non-sensitive proof that a read-only search request succeeded.""" + + query: str + posts_fetched: int + read_only: bool = True + + +class BlueskyGateway: + """HTTP adapter for the public, read-only Bluesky AppView.""" + + def __init__( + self, + *, + api_base_url: str, + user_agent: str, + timeout_seconds: float, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._api_base_url = api_base_url.rstrip("/") + self._user_agent = user_agent + self._timeout_seconds = timeout_seconds + self._transport = transport + + @classmethod + def from_settings(cls, settings: AppSettings) -> BlueskyGateway: + """Create the public client from validated, non-secret settings.""" + return cls( + api_base_url=str(settings.bluesky.api_base_url), + user_agent=settings.bluesky.user_agent, + timeout_seconds=settings.bluesky.timeout_seconds, + ) + + def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: + """Fetch only post identifiers and translate unsafe provider failures.""" + try: + with httpx.Client( + base_url=self._api_base_url, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout_seconds, + transport=self._transport, + ) as client: + response = client.get( + "/xrpc/app.bsky.feed.searchPosts", + params={"q": query, "limit": limit, "sort": "latest"}, + ) + response.raise_for_status() + payload = _SearchResponse.model_validate(response.json()) + except httpx.HTTPStatusError as error: + raise BlueskyAccessError( + f"Bluesky API request failed (HTTP {error.response.status_code})" + ) from None + except httpx.RequestError as error: + raise BlueskyAccessError( + f"Bluesky API request failed ({type(error).__name__})" + ) from None + except (ValidationError, ValueError): + raise BlueskyAccessError("Bluesky API returned an invalid response") from None + + return tuple(post.uri for post in payload.posts) + + +def run_access_check( + gateway: BlueskyPostGateway, + *, + query: str, + limit: int, +) -> BlueskyAccessResult: + """Require a complete set of unique IDs before reporting access success.""" + if limit < 1: + raise ValueError("limit must be positive") + + post_ids = gateway.recent_post_ids(query, limit) + unique_count = len(set(post_ids)) + if len(post_ids) != limit or unique_count != limit: + raise BlueskyAccessError( + f"Bluesky access check expected {limit} unique posts but received {unique_count}" + ) + + return BlueskyAccessResult(query=query, posts_fetched=len(post_ids)) diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py index e4d15f3..a5ae968 100644 --- a/src/loc_observatory/config.py +++ b/src/loc_observatory/config.py @@ -9,7 +9,15 @@ import yaml from dotenv import dotenv_values -from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + HttpUrl, + SecretStr, + ValidationError, + field_validator, +) from pydantic.functional_validators import model_validator SECRET_FIELDS = { @@ -20,6 +28,7 @@ } ALL_SECRET_NAMES = frozenset(SECRET_FIELDS) +CORE_SECRET_NAMES = frozenset({"ANTHROPIC_API_KEY", "AUTHOR_HMAC_KEY"}) REDDIT_SECRET_NAMES = frozenset({"REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"}) @@ -33,8 +42,18 @@ class FrozenSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) +class BlueskySettings(FrozenSettings): + """Non-secret settings for the public Bluesky AppView.""" + + api_base_url: HttpUrl + sample_query: str = Field(min_length=1) + request_limit: int = Field(ge=1, le=100) + timeout_seconds: float = Field(gt=0, le=60) + user_agent: str = Field(min_length=10) + + class RedditSettings(FrozenSettings): - """Non-secret Reddit collection settings.""" + """Non-secret settings for the optional, approval-gated Reddit adapter.""" subreddits: tuple[str, ...] = Field(min_length=1) sample_subreddit: str = Field(min_length=1) @@ -103,7 +122,8 @@ def validate_hmac_key(cls, value: SecretStr | None) -> SecretStr | None: class AppSettings(FrozenSettings): """Complete validated application configuration.""" - config_version: Literal[1] + config_version: Literal[2] + bluesky: BlueskySettings reddit: RedditSettings classifier: ClassifierSettings retention: RetentionSettings @@ -116,7 +136,7 @@ def load_settings( *, env_path: Path | None = Path(".env"), environ: Mapping[str, str] | None = None, - required_secrets: Set[str] = ALL_SECRET_NAMES, + required_secrets: Set[str] = CORE_SECRET_NAMES, ) -> AppSettings: """Load one validated settings object, with process values overriding `.env`.""" unknown_names = required_secrets - ALL_SECRET_NAMES diff --git a/tests/test_bluesky_access.py b/tests/test_bluesky_access.py new file mode 100644 index 0000000..62aeb0e --- /dev/null +++ b/tests/test_bluesky_access.py @@ -0,0 +1,156 @@ +"""Tests for the read-only Bluesky access check.""" + +import json +from collections.abc import Sequence + +import httpx +import pytest + +from loc_observatory.cli import main +from loc_observatory.collector.bluesky import ( + BlueskyAccessError, + BlueskyGateway, + BlueskyPostGateway, + run_access_check, +) +from loc_observatory.config import AppSettings + + +class FakeBlueskyGateway: + """Inert Bluesky boundary used by access-check tests.""" + + def __init__(self, post_ids: Sequence[str] = ()) -> None: + self.post_ids = tuple(post_ids) + + def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: + """Return the configured IDs without making a network request.""" + return self.post_ids[:limit] + + +class FailingBlueskyGateway: + """Inert Bluesky boundary that behaves like a provider failure.""" + + def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: + """Raise the public provider error used by the CLI.""" + raise BlueskyAccessError("Bluesky API request failed (HTTP 503)") + + +def test_gateway_calls_public_search_and_validates_post_uris() -> None: + """Use the documented search contract and accept only typed post identifiers.""" + + def handle_request(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/xrpc/app.bsky.feed.searchPosts" + assert request.url.params["q"] == "Claude AI" + assert request.url.params["limit"] == "10" + assert request.url.params["sort"] == "latest" + assert request.headers["user-agent"] == "loc-observatory/0.1 test" + return httpx.Response( + 200, + json={ + "posts": [ + {"uri": (f"at://did:plc:test/app.bsky.feed.post/post-{index}")} + for index in range(10) + ] + }, + ) + + gateway = BlueskyGateway( + api_base_url="https://api.bsky.app", + user_agent="loc-observatory/0.1 test", + timeout_seconds=10, + transport=httpx.MockTransport(handle_request), + ) + + assert gateway.recent_post_ids("Claude AI", 10) == tuple( + f"at://did:plc:test/app.bsky.feed.post/post-{index}" for index in range(10) + ) + + +@pytest.mark.parametrize( + ("status_code", "payload", "expected_message"), + [ + (503, {"error": "Unavailable"}, "HTTP 503"), + (200, {"posts": [{"not_uri": "bad"}]}, "invalid response"), + ], +) +def test_gateway_translates_provider_and_schema_failures( + status_code: int, + payload: object, + expected_message: str, +) -> None: + """Expose actionable failure classes without returning response bodies.""" + gateway = BlueskyGateway( + api_base_url="https://api.bsky.app", + user_agent="loc-observatory/0.1 test", + timeout_seconds=10, + transport=httpx.MockTransport(lambda _request: httpx.Response(status_code, json=payload)), + ) + + with pytest.raises(BlueskyAccessError, match=expected_message): + gateway.recent_post_ids("Claude AI", 10) + + +def test_access_check_requires_requested_number_of_unique_posts() -> None: + """Accept only a complete sample that proves the search endpoint could be read.""" + result = run_access_check( + FakeBlueskyGateway([f"post-{index}" for index in range(10)]), + query="Claude AI", + limit=10, + ) + + assert result.query == "Claude AI" + assert result.posts_fetched == 10 + assert result.read_only is True + + +@pytest.mark.parametrize( + "post_ids", + [ + ["post-1", "post-2"], + ["post-1"] * 10, + ], +) +def test_access_check_rejects_incomplete_or_duplicate_samples(post_ids: list[str]) -> None: + """Do not report success for a short or internally inconsistent response.""" + with pytest.raises(BlueskyAccessError, match="10 unique posts"): + run_access_check(FakeBlueskyGateway(post_ids), query="Claude AI", limit=10) + + +def test_cli_prints_counts_without_returning_post_or_author_data( + capsys: pytest.CaptureFixture[str], +) -> None: + """Keep the live proof useful without printing or storing sampled data.""" + + def gateway_factory(_settings: AppSettings) -> BlueskyPostGateway: + return FakeBlueskyGateway([f"private-post-id-{index}" for index in range(10)]) + + exit_code = main( + ["--config", "config.yaml", "bluesky", "check-access"], + bluesky_gateway_factory=gateway_factory, + ) + + output = capsys.readouterr() + payload = json.loads(output.out) + assert exit_code == 0 + assert payload == { + "posts_fetched": 10, + "query": "Claude AI", + "read_only": True, + "source": "bluesky", + "status": "ok", + } + assert "private-post-id" not in output.out + assert output.err == "" + + +def test_cli_returns_safe_provider_failure(capsys: pytest.CaptureFixture[str]) -> None: + """Return a clear provider error without a traceback or response content.""" + exit_code = main( + ["--config", "config.yaml", "bluesky", "check-access"], + bluesky_gateway_factory=lambda _settings: FailingBlueskyGateway(), + ) + + output = capsys.readouterr() + assert exit_code == 1 + assert "HTTP 503" in output.err + assert output.out == "" diff --git a/tests/test_config.py b/tests/test_config.py index cb437db..d654760 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,13 +6,20 @@ from loc_observatory.config import ( ALL_SECRET_NAMES, + CORE_SECRET_NAMES, REDDIT_SECRET_NAMES, ConfigurationError, load_settings, ) VALID_CONFIG = """\ -config_version: 1 +config_version: 2 +bluesky: + api_base_url: https://api.bsky.app + sample_query: Claude AI + request_limit: 10 + timeout_seconds: 10 + user_agent: loc-observatory/0.1 access-check reddit: subreddits: - ClaudeAI @@ -58,6 +65,8 @@ def test_loads_versioned_config_and_redacts_secrets(tmp_path: Path) -> None: """Return validated settings without exposing secret values in representations.""" settings = load_settings(write_config(tmp_path), environ=VALID_SECRETS) + assert str(settings.bluesky.api_base_url) == "https://api.bsky.app/" + assert settings.bluesky.sample_query == "Claude AI" assert settings.reddit.subreddits == ("ClaudeAI", "ChatGPT") assert settings.reddit.request_limit == 10 assert settings.retention.raw_posts_days == 90 @@ -104,12 +113,14 @@ def test_missing_secrets_are_named_without_exposing_values(tmp_path: Path) -> No def test_default_validation_requires_every_documented_secret(tmp_path: Path) -> None: - """Treat the complete application configuration as the default startup contract.""" + """Require only secrets used by the default Bluesky-backed pipeline.""" with pytest.raises(ConfigurationError) as exc_info: load_settings(write_config(tmp_path), environ={}) - for name in ALL_SECRET_NAMES: + for name in CORE_SECRET_NAMES: assert name in str(exc_info.value) + for name in ALL_SECRET_NAMES - CORE_SECRET_NAMES: + assert name not in str(exc_info.value) def test_rejects_sample_subreddit_outside_collection_scope(tmp_path: Path) -> None: diff --git a/tests/test_reddit_access.py b/tests/test_reddit_access.py index 6ba59e2..b0111be 100644 --- a/tests/test_reddit_access.py +++ b/tests/test_reddit_access.py @@ -87,7 +87,7 @@ def gateway_factory(_settings: AppSettings) -> RedditGateway: "reddit", "check-access", ], - gateway_factory=gateway_factory, + reddit_gateway_factory=gateway_factory, ) output = capsys.readouterr() @@ -127,7 +127,7 @@ def gateway_factory(_settings: AppSettings) -> RedditGateway: "reddit", "check-access", ], - gateway_factory=gateway_factory, + reddit_gateway_factory=gateway_factory, ) output = capsys.readouterr() @@ -155,7 +155,7 @@ def test_cli_returns_safe_provider_failure( "reddit", "check-access", ], - gateway_factory=lambda _settings: FailingRedditGateway(), + reddit_gateway_factory=lambda _settings: FailingRedditGateway(), ) output = capsys.readouterr() diff --git a/uv.lock b/uv.lock index 89ad6d6..b4f6235 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -60,6 +73,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -104,6 +154,7 @@ name = "loc-observatory" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "praw" }, { name = "pydantic" }, { name = "python-dotenv" }, @@ -120,6 +171,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.28,<1" }, { name = "praw", specifier = ">=8,<9" }, { name = "pydantic", specifier = ">=2.11,<3" }, { name = "python-dotenv", specifier = ">=1.1,<2" }, From baa7ca15698c9504c2e03029106611155aaeb36d Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 15:01:39 -0700 Subject: [PATCH 06/29] Make evidence storage enforce its safety rules Add a forward-only SQLite migration and repeatable CLI entry point before the live collector writes data. The schema keeps redacted source evidence immutable, constrains versioned scores, cascades derived records during erasure, and prevents audit-history rewrites. Constraint: The local demo uses SQLite and must remain reproducible from an empty database Rejected: Create tables ad hoc in repository code | hides schema history and weakens startup repeatability Confidence: high Scope-risk: moderate Directive: Add schema changes through a new migration; do not edit an applied migration Tested: make check (25 tests); migration CLI first and repeated run; uv build; wheel contains SQL migration Not-tested: Concurrent migration attempts from multiple processes --- README.md | 10 +- RUNBOOK.md | 18 +- config.yaml | 3 + docs/CONFIGURATION.md | 1 + docs/DATA_PROTECTION.md | 5 +- src/loc_observatory/cli.py | 47 ++++ src/loc_observatory/config.py | 7 + src/loc_observatory/warehouse/database.py | 60 ++++++ .../warehouse/migrations/0001_initial.sql | 87 ++++++++ .../warehouse/migrations/__init__.py | 1 + tests/test_config.py | 3 + tests/test_warehouse.py | 203 ++++++++++++++++++ 12 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 src/loc_observatory/warehouse/database.py create mode 100644 src/loc_observatory/warehouse/migrations/0001_initial.sql create mode 100644 src/loc_observatory/warehouse/migrations/__init__.py create mode 100644 tests/test_warehouse.py diff --git a/README.md b/README.md index b562511..df64b04 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ AI systems scheme across all uses, and classifier scores are not ground truth. ## Current status -The repository foundation, validated configuration, and a live read-only Bluesky access check are -in place. Collection and analysis are tracked in the +The repository foundation, validated configuration, live read-only Bluesky access check, and +migration-backed SQLite schema are in place. Collection and analysis are tracked in the [project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline or stored dataset is implemented yet. @@ -74,6 +74,12 @@ Run every local quality check: make check ``` +Create or upgrade the ignored local warehouse: + +```bash +uv run observatory warehouse migrate +``` + The individual commands are: ```bash diff --git a/RUNBOOK.md b/RUNBOOK.md index 3868f32..2276eea 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -2,9 +2,9 @@ ## Current state -The repository currently contains the Python package and local quality checks. It does not yet run a -collector, classifier, database, or web service. Operational recovery steps will be added only after -the related behaviour has been exercised. +The repository currently contains the Python package, local quality checks, a live source-access +check, and the SQLite schema. It does not yet run a collector, classifier, or web service. +Operational recovery steps will be added only after the related behaviour has been exercised. ## Set up a local environment @@ -17,6 +17,18 @@ make check Expected result: formatting, linting, type checking, and tests all pass. +## Create or upgrade the warehouse + +Run: + +```bash +uv run observatory warehouse migrate +``` + +Expected result: one JSON object listing migrations applied in that invocation. A repeated command +returns an empty list and leaves the schema unchanged. The default database is +`data/observatory.db`, which Git ignores. + ## Check Bluesky access Run: diff --git a/config.yaml b/config.yaml index dbbdb1b..ddeafbe 100644 --- a/config.yaml +++ b/config.yaml @@ -17,6 +17,9 @@ reddit: request_limit: 10 user_agent: loc-observatory/0.1 (https://github.com/code259/loc-observatory) +warehouse: + path: data/observatory.db + classifier: provider: anthropic model: claude-opus-4-6 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7b7b172..bcff801 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -16,6 +16,7 @@ redacted secret type so they are not revealed by normal object logging or error - Bluesky API location, access-check query, request size, timeout, and user agent - settings for the optional, approval-gated Reddit adapter +- ignored local SQLite database path - classifier provider, model, and output limit - source and artifact retention periods - the first AI, scheming, and reaction search terms diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md index 8ede27e..b1c10d5 100644 --- a/docs/DATA_PROTECTION.md +++ b/docs/DATA_PROTECTION.md @@ -7,8 +7,9 @@ does not remove the need to protect the people who wrote them. The pipeline will identifiers before data is stored, keep only fields needed for analysis, limit how long data is kept, and support deletion requests. -The repository currently contains the project foundation only. No collection or deletion control is -implemented yet. This document describes the required safeguards; it will link to tested commands +The current warehouse accepts only a pseudonymous author value and makes source rows immutable. +Its foreign keys define how later erasure removes derived rows, and its audit table is append-only. +Collection and erasure commands are not implemented yet. This document will link to tested commands as each safeguard is added. ## Planned controls diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py index f834143..3522320 100644 --- a/src/loc_observatory/cli.py +++ b/src/loc_observatory/cli.py @@ -4,6 +4,7 @@ import argparse import json +import sqlite3 import sys from collections.abc import Callable, Sequence from pathlib import Path @@ -30,6 +31,7 @@ ConfigurationError, load_settings, ) +from loc_observatory.warehouse.database import connect_database, migrate_database type BlueskyGatewayFactory = Callable[[AppSettings], BlueskyPostGateway] type RedditGatewayFactory = Callable[[AppSettings], RedditGateway] @@ -56,6 +58,14 @@ def build_parser() -> argparse.ArgumentParser: "check-access", help="Fetch ten recent post IDs without printing or storing them", ) + + warehouse_parser = commands.add_parser("warehouse", help="Warehouse setup commands") + warehouse_commands = warehouse_parser.add_subparsers(dest="warehouse_command", required=True) + migrate_parser = warehouse_commands.add_parser( + "migrate", + help="Apply pending SQLite migrations", + ) + migrate_parser.add_argument("--database", type=Path) return parser @@ -74,10 +84,47 @@ def main( if arguments.command == "reddit" and arguments.reddit_command == "check-access": return _check_reddit_access(arguments, reddit_gateway_factory) + if arguments.command == "warehouse" and arguments.warehouse_command == "migrate": + return _migrate_warehouse(arguments) + build_parser().error("unsupported command") return 2 +def _migrate_warehouse(arguments: argparse.Namespace) -> int: + """Build or upgrade the configured SQLite warehouse.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=frozenset(), + ) + database_path = arguments.database or settings.warehouse.path + connection = connect_database(database_path) + try: + applied = migrate_database(connection) + finally: + connection.close() + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except (OSError, sqlite3.Error) as error: + print(f"Warehouse migration failed ({type(error).__name__})", file=sys.stderr) + return 1 + + print( + json.dumps( + { + "applied_migrations": list(applied), + "database": str(database_path), + "status": "ok", + }, + sort_keys=True, + ) + ) + return 0 + + def _check_bluesky_access( arguments: argparse.Namespace, gateway_factory: BlueskyGatewayFactory, diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py index a5ae968..da0405b 100644 --- a/src/loc_observatory/config.py +++ b/src/loc_observatory/config.py @@ -79,6 +79,12 @@ def validate_sample_subreddit(self) -> Self: return self +class WarehouseSettings(FrozenSettings): + """Local warehouse settings.""" + + path: Path + + class ClassifierSettings(FrozenSettings): """Non-secret LLM classification settings.""" @@ -125,6 +131,7 @@ class AppSettings(FrozenSettings): config_version: Literal[2] bluesky: BlueskySettings reddit: RedditSettings + warehouse: WarehouseSettings classifier: ClassifierSettings retention: RetentionSettings search: SearchSettings diff --git a/src/loc_observatory/warehouse/database.py b/src/loc_observatory/warehouse/database.py new file mode 100644 index 0000000..1a43578 --- /dev/null +++ b/src/loc_observatory/warehouse/database.py @@ -0,0 +1,60 @@ +"""SQLite connection and forward-only migration entry points.""" + +from __future__ import annotations + +import sqlite3 +from importlib import resources +from pathlib import Path + +_MIGRATION_NAMES = ("0001_initial.sql",) +_MIGRATION_PACKAGE = "loc_observatory.warehouse.migrations" + + +def connect_database(path: Path) -> sqlite3.Connection: + """Open a local database with referential integrity enabled.""" + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 5000") + return connection + + +def migrate_database(connection: sqlite3.Connection) -> tuple[str, ...]: + """Apply each packaged migration exactly once and return those applied now.""" + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) STRICT + """ + ) + connection.commit() + + applied = {str(row[0]) for row in connection.execute("SELECT name FROM schema_migrations")} + newly_applied: list[str] = [] + + for migration_name in _MIGRATION_NAMES: + if migration_name in applied: + continue + + migration = ( + resources.files(_MIGRATION_PACKAGE).joinpath(migration_name).read_text(encoding="utf-8") + ) + escaped_name = migration_name.replace("'", "''") + script = ( + "BEGIN IMMEDIATE;\n" + f"{migration}\n" + "INSERT INTO schema_migrations (name) " + f"VALUES ('{escaped_name}');\n" + "COMMIT;" + ) + try: + connection.executescript(script) + except sqlite3.Error: + if connection.in_transaction: + connection.rollback() + raise + newly_applied.append(migration_name) + + return tuple(newly_applied) diff --git a/src/loc_observatory/warehouse/migrations/0001_initial.sql b/src/loc_observatory/warehouse/migrations/0001_initial.sql new file mode 100644 index 0000000..cda736f --- /dev/null +++ b/src/loc_observatory/warehouse/migrations/0001_initial.sql @@ -0,0 +1,87 @@ +-- Immutable, redacted source evidence. Direct author identifiers must never enter this table. +CREATE TABLE posts_raw ( + source TEXT NOT NULL CHECK (length(source) > 0), + external_id TEXT NOT NULL CHECK (length(external_id) > 0), + source_url TEXT NOT NULL CHECK (length(source_url) > 0), + author_hmac TEXT NOT NULL CHECK (length(author_hmac) = 64), + created_at TEXT NOT NULL CHECK (length(created_at) > 0), + text TEXT NOT NULL, + like_count INTEGER NOT NULL CHECK (like_count >= 0), + reply_count INTEGER NOT NULL CHECK (reply_count >= 0), + repost_count INTEGER NOT NULL CHECK (repost_count >= 0), + quote_count INTEGER NOT NULL CHECK (quote_count >= 0), + query TEXT NOT NULL CHECK (length(query) > 0), + collected_at TEXT NOT NULL CHECK (length(collected_at) > 0), + collector_version TEXT NOT NULL CHECK (length(collector_version) > 0), + PRIMARY KEY (source, external_id) +) STRICT; + +CREATE TRIGGER posts_raw_prevent_update +BEFORE UPDATE ON posts_raw +BEGIN + SELECT RAISE(ABORT, 'posts_raw rows are immutable'); +END; + +-- Versioned classifier outputs. Multiple prompt or model versions may score one source record. +CREATE TABLE scores ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + score INTEGER NOT NULL CHECK (score BETWEEN 0 AND 9), + reasoning TEXT NOT NULL CHECK (length(reasoning) > 0), + model_id TEXT NOT NULL CHECK (length(model_id) > 0), + prompt_hash TEXT NOT NULL CHECK (length(prompt_hash) = 64), + input_tokens INTEGER NOT NULL CHECK (input_tokens >= 0), + output_tokens INTEGER NOT NULL CHECK (output_tokens >= 0), + cost_usd REAL NOT NULL CHECK (cost_usd >= 0), + scored_at TEXT NOT NULL CHECK (length(scored_at) > 0), + FOREIGN KEY (source, external_id) + REFERENCES posts_raw (source, external_id) + ON UPDATE RESTRICT + ON DELETE CASCADE, + UNIQUE (source, external_id, model_id, prompt_hash) +) STRICT; + +-- Recoverable item failures. Error fields must not contain credentials or raw author identifiers. +CREATE TABLE dlq ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + stage TEXT NOT NULL CHECK (length(stage) > 0), + error_code TEXT NOT NULL CHECK (length(error_code) > 0), + error_message TEXT NOT NULL CHECK (length(error_message) > 0), + retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0), + last_attempt_at TEXT NOT NULL CHECK (length(last_attempt_at) > 0), + FOREIGN KEY (source, external_id) + REFERENCES posts_raw (source, external_id) + ON UPDATE RESTRICT + ON DELETE CASCADE, + UNIQUE (source, external_id, stage) +) STRICT; + +-- Append-only evidence of deletion and administrative actions. It stores no source content. +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + action TEXT NOT NULL CHECK (length(action) > 0), + subject_hmac TEXT CHECK (subject_hmac IS NULL OR length(subject_hmac) = 64), + affected_records INTEGER NOT NULL CHECK (affected_records >= 0), + occurred_at TEXT NOT NULL CHECK (length(occurred_at) > 0), + details_json TEXT NOT NULL DEFAULT '{}' +) STRICT; + +CREATE TRIGGER audit_log_prevent_update +BEFORE UPDATE ON audit_log +BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only'); +END; + +CREATE TRIGGER audit_log_prevent_delete +BEFORE DELETE ON audit_log +BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only'); +END; + +CREATE INDEX scores_source_post_idx ON scores (source, external_id); +CREATE INDEX dlq_stage_retry_idx ON dlq (stage, retry_count, last_attempt_at); +CREATE INDEX posts_raw_author_hmac_idx ON posts_raw (author_hmac); +CREATE INDEX posts_raw_created_at_idx ON posts_raw (created_at); diff --git a/src/loc_observatory/warehouse/migrations/__init__.py b/src/loc_observatory/warehouse/migrations/__init__.py new file mode 100644 index 0000000..aba3ac0 --- /dev/null +++ b/src/loc_observatory/warehouse/migrations/__init__.py @@ -0,0 +1 @@ +"""Packaged, forward-only SQLite migrations.""" diff --git a/tests/test_config.py b/tests/test_config.py index d654760..4683de6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,6 +27,8 @@ sample_subreddit: ClaudeAI request_limit: 10 user_agent: loc-observatory/0.1 access-check +warehouse: + path: data/observatory.db classifier: provider: anthropic model: claude-opus-4-6 @@ -69,6 +71,7 @@ def test_loads_versioned_config_and_redacts_secrets(tmp_path: Path) -> None: assert settings.bluesky.sample_query == "Claude AI" assert settings.reddit.subreddits == ("ClaudeAI", "ChatGPT") assert settings.reddit.request_limit == 10 + assert settings.warehouse.path == Path("data/observatory.db") assert settings.retention.raw_posts_days == 90 assert settings.secrets.reddit_client_id is not None assert settings.secrets.reddit_client_id.get_secret_value() == "reddit-client-id" diff --git a/tests/test_warehouse.py b/tests/test_warehouse.py new file mode 100644 index 0000000..7692db9 --- /dev/null +++ b/tests/test_warehouse.py @@ -0,0 +1,203 @@ +"""Integration tests for the SQLite warehouse schema and migrations.""" + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from loc_observatory.cli import main +from loc_observatory.warehouse.database import connect_database, migrate_database + + +def insert_source_post(connection: sqlite3.Connection) -> None: + """Insert one valid, already-redacted source record.""" + connection.execute( + """ + INSERT INTO posts_raw ( + source, + external_id, + source_url, + author_hmac, + created_at, + text, + like_count, + reply_count, + repost_count, + quote_count, + query, + collected_at, + collector_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "bluesky", + "at://did:plc:test/app.bsky.feed.post/example", + "https://bsky.app/profile/example.test/post/example", + "a" * 64, + "2026-08-04T20:00:00Z", + "Redacted source text", + 3, + 2, + 1, + 0, + "Claude AI", + "2026-08-04T20:01:00Z", + "bluesky-v1", + ), + ) + + +def test_migrations_build_an_empty_database_and_are_idempotent(tmp_path: Path) -> None: + """Apply every migration once and safely accept a repeated startup.""" + connection = connect_database(tmp_path / "observatory.db") + try: + first = migrate_database(connection) + second = migrate_database(connection) + + tables = { + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + } + assert first == ("0001_initial.sql",) + assert second == () + assert { + "schema_migrations", + "posts_raw", + "scores", + "dlq", + "audit_log", + } <= tables + assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + finally: + connection.close() + + +def test_source_rows_are_immutable_and_scores_are_constrained(tmp_path: Path) -> None: + """Reject evidence mutation and classification values outside the rubric.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + insert_source_post(connection) + + with pytest.raises(sqlite3.IntegrityError, match="posts_raw rows are immutable"): + connection.execute( + "UPDATE posts_raw SET text = ? WHERE source = ? AND external_id = ?", + ( + "changed", + "bluesky", + "at://did:plc:test/app.bsky.feed.post/example", + ), + ) + + with pytest.raises(sqlite3.IntegrityError, match="CHECK constraint failed"): + connection.execute( + """ + INSERT INTO scores ( + source, external_id, score, reasoning, model_id, prompt_hash, + input_tokens, output_tokens, cost_usd, scored_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "bluesky", + "at://did:plc:test/app.bsky.feed.post/example", + 10, + "Out of range", + "test-model", + "b" * 64, + 10, + 5, + 0.01, + "2026-08-04T20:02:00Z", + ), + ) + finally: + connection.close() + + +def test_deleting_source_evidence_cascades_to_derived_rows(tmp_path: Path) -> None: + """Make later retention and erasure behavior explicit at the schema boundary.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + insert_source_post(connection) + connection.execute( + """ + INSERT INTO dlq ( + source, external_id, stage, error_code, error_message, + retry_count, last_attempt_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "bluesky", + "at://did:plc:test/app.bsky.feed.post/example", + "classification", + "provider_timeout", + "Provider timed out", + 1, + "2026-08-04T20:02:00Z", + ), + ) + + connection.execute( + "DELETE FROM posts_raw WHERE source = ? AND external_id = ?", + ("bluesky", "at://did:plc:test/app.bsky.feed.post/example"), + ) + + assert connection.execute("SELECT COUNT(*) FROM dlq").fetchone()[0] == 0 + finally: + connection.close() + + +def test_audit_log_is_append_only(tmp_path: Path) -> None: + """Prevent later code from rewriting or removing audit evidence.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + cursor = connection.execute( + """ + INSERT INTO audit_log ( + action, subject_hmac, affected_records, occurred_at, details_json + ) VALUES (?, ?, ?, ?, ?) + """, + ("erasure", "a" * 64, 1, "2026-08-04T20:03:00Z", "{}"), + ) + audit_id = cursor.lastrowid + + with pytest.raises(sqlite3.IntegrityError, match="audit_log is append-only"): + connection.execute( + "UPDATE audit_log SET affected_records = 2 WHERE id = ?", + (audit_id,), + ) + with pytest.raises(sqlite3.IntegrityError, match="audit_log is append-only"): + connection.execute("DELETE FROM audit_log WHERE id = ?", (audit_id,)) + finally: + connection.close() + + +def test_cli_migrates_configured_database( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Expose one repeatable migration entry point for local and deployed startup.""" + database_path = tmp_path / "observatory.db" + + exit_code = main( + [ + "--config", + "config.yaml", + "warehouse", + "migrate", + "--database", + str(database_path), + ] + ) + + output = capsys.readouterr() + assert exit_code == 0 + assert json.loads(output.out) == { + "applied_migrations": ["0001_initial.sql"], + "database": str(database_path), + "status": "ok", + } + assert output.err == "" From 7f829626c52a0351a06435b1f3c4ba3e7c9e37df Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 15:19:03 -0700 Subject: [PATCH 07/29] Prevent source identifiers from reaching evidence storage Bluesky post URIs carry author identifiers, so collection now pseudonymises author and post identity before the repository boundary. Bounded pagination, retry policy, query isolation, immutable inserts, and counts-only CLI output make live runs safe to repeat and diagnose. Constraint: Bluesky AT URIs and public post URLs contain author identifiers Rejected: Store raw AT URIs for convenient retrieval | this defeats ingest-time pseudonymisation Confidence: high Scope-risk: moderate Directive: Keep direct identifiers out of repository inputs, storage, logs, and command output Tested: make check (33 tests); uv build; live collection repeated with 185 then 0 inserts Not-tested: Live multi-page pagination because the demo configuration intentionally caps each query at one page --- README.md | 18 +- RUNBOOK.md | 17 + config.yaml | 6 + docs/BLUESKY_ACCESS.md | 6 +- docs/CONFIGURATION.md | 3 +- docs/DATA_PROTECTION.md | 17 +- ...pseudonymise-bluesky-record-identifiers.md | 42 +++ src/loc_observatory/cli.py | 87 +++++ src/loc_observatory/collector/bluesky.py | 221 ++++++++++-- src/loc_observatory/collector/service.py | 203 +++++++++++ src/loc_observatory/config.py | 7 + src/loc_observatory/models.py | 46 +++ src/loc_observatory/warehouse/database.py | 5 +- .../0002_add_bluesky_provenance.sql | 9 + src/loc_observatory/warehouse/posts.py | 63 ++++ tests/test_bluesky_access.py | 63 ++++ tests/test_bluesky_collection.py | 319 ++++++++++++++++++ tests/test_config.py | 8 + tests/test_warehouse.py | 13 +- 19 files changed, 1116 insertions(+), 37 deletions(-) create mode 100644 docs/adr/0003-pseudonymise-bluesky-record-identifiers.md create mode 100644 src/loc_observatory/collector/service.py create mode 100644 src/loc_observatory/models.py create mode 100644 src/loc_observatory/warehouse/migrations/0002_add_bluesky_provenance.sql create mode 100644 src/loc_observatory/warehouse/posts.py create mode 100644 tests/test_bluesky_collection.py diff --git a/README.md b/README.md index df64b04..e56b097 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,14 @@ AI systems scheme across all uses, and classifier scores are not ground truth. ## Current status -The repository foundation, validated configuration, live read-only Bluesky access check, and -migration-backed SQLite schema are in place. Collection and analysis are tracked in the -[project roadmap](https://github.com/code259/loc-observatory/issues/27). No live data pipeline or -stored dataset is implemented yet. +The repository foundation, validated configuration, live read-only Bluesky access check, +migration-backed SQLite schema, and privacy-first collector are in place. Collection and analysis +are tracked in the [project roadmap](https://github.com/code259/loc-observatory/issues/27). The +classification-to-report path is not complete, and no collected dataset is committed. + +A bounded live collector check saw 188 posts across eight queries. The first run inserted 185 unique +rows and ignored three cross-query duplicates. An immediate repeat inserted no rows and reported all +188 as duplicates. The temporary database remained outside the repository. ## Technical guide @@ -80,6 +84,12 @@ Create or upgrade the ignored local warehouse: uv run observatory warehouse migrate ``` +After setting `AUTHOR_HMAC_KEY` in the ignored `.env` file, collect one bounded Bluesky batch: + +```bash +uv run observatory bluesky collect +``` + The individual commands are: ```bash diff --git a/RUNBOOK.md b/RUNBOOK.md index 2276eea..4792849 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -44,6 +44,23 @@ The command exits with status 2 for invalid local configuration. It exits with s Bluesky request fails or its response does not match the expected public contract. See [`docs/BLUESKY_ACCESS.md`](docs/BLUESKY_ACCESS.md) for the current endpoint assumptions. +## Collect a bounded Bluesky batch + +Set `AUTHOR_HMAC_KEY` in the ignored `.env` file, then run: + +```bash +uv run observatory bluesky collect +``` + +The command applies pending migrations, builds a bounded set of report-derived queries, follows at +most the configured number of pages, and writes only minimised records. Its JSON result contains +counts and the ignored database path, not post text or author identifiers. + +A repeated run may report duplicates. Existing immutable rows are left unchanged. HTTP 408, 429, +and common 5xx failures are retried within the configured attempt and delay bounds. A failed query +is counted and isolated so later queries can continue. The command reports `"status": "partial"` +when this happens. + ## Common setup failures ### `uv` cannot find Python 3.12 diff --git a/config.yaml b/config.yaml index ddeafbe..5fcc149 100644 --- a/config.yaml +++ b/config.yaml @@ -4,6 +4,12 @@ bluesky: api_base_url: https://api.bsky.app sample_query: Claude AI request_limit: 10 + page_size: 25 + max_pages_per_query: 1 + max_queries: 8 + max_attempts: 3 + retry_base_seconds: 0.5 + max_retry_delay_seconds: 5 timeout_seconds: 10 user_agent: loc-observatory/0.1 (https://github.com/code259/loc-observatory) diff --git a/docs/BLUESKY_ACCESS.md b/docs/BLUESKY_ACCESS.md index cb2bbf4..ada01c0 100644 --- a/docs/BLUESKY_ACCESS.md +++ b/docs/BLUESKY_ACCESS.md @@ -6,8 +6,10 @@ Bluesky provides a documented public interface for reading public posts without key. The setup check retrieves ten recent search results and reports only the count. It does not print or store post content, post identifiers, or author details. -This proves basic access only. Filtering, redaction, retries, and database writes belong to the -collector task. +This command proves basic access only. The separate `observatory bluesky collect` command applies +bounded queries, retries transient failures, redacts identifiers, and writes minimised records to +the ignored SQLite warehouse. It reports query-level failures without printing the failed query or +provider response. ## Run the check diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bcff801..ffa07b6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -14,7 +14,8 @@ redacted secret type so they are not revealed by normal object logging or error `config.yaml` currently controls: -- Bluesky API location, access-check query, request size, timeout, and user agent +- Bluesky API location, access-check query, request and pagination bounds, retry policy, timeout, + and user agent - settings for the optional, approval-gated Reddit adapter - ignored local SQLite database path - classifier provider, model, and output limit diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md index b1c10d5..b4f719a 100644 --- a/docs/DATA_PROTECTION.md +++ b/docs/DATA_PROTECTION.md @@ -7,14 +7,16 @@ does not remove the need to protect the people who wrote them. The pipeline will identifiers before data is stored, keep only fields needed for analysis, limit how long data is kept, and support deletion requests. -The current warehouse accepts only a pseudonymous author value and makes source rows immutable. -Its foreign keys define how later erasure removes derived rows, and its audit table is append-only. -Collection and erasure commands are not implemented yet. This document will link to tested commands -as each safeguard is added. +The collector replaces the author DID and the author-bearing AT URI with separate, domain-separated +HMAC values before persistence. It also removes the author's handle and display name, plus visible +`@mentions`, from stored text. The warehouse makes source rows immutable. Its foreign keys define +how later erasure removes derived rows, and its audit table is append-only. The erasure command is +not implemented yet. ## Planned controls -- Replace the Bluesky author DID with a keyed HMAC before persistence or logging. +- Replace the Bluesky author DID and AT URI with domain-separated keyed HMAC values before + persistence or logging. - Keep the HMAC secret outside the repository and database. - Store only the post fields needed for evidence review, provenance, and deletion. - Keep collected data and archived evidence out of Git. @@ -28,6 +30,11 @@ as each safeguard is added. The legal basis, final retention periods, deployed access controls, and processor terms must be reviewed before live deployment. This project documentation is not legal advice. +The pseudonymous source reference is deliberately not a public Bluesky link because both Bluesky +AT URIs and profile URLs contain an author identifier. The warehouse keeps the content CID and +record key for provenance. A deployed review system that needs direct source retrieval would require +a separately encrypted locator with narrower access, key rotation, and audited reads. + Implementation and verification are tracked in [the retention and erasure issue](https://github.com/code259/loc-observatory/issues/15) and [the data protection guide issue](https://github.com/code259/loc-observatory/issues/16). diff --git a/docs/adr/0003-pseudonymise-bluesky-record-identifiers.md b/docs/adr/0003-pseudonymise-bluesky-record-identifiers.md new file mode 100644 index 0000000..8bfff2f --- /dev/null +++ b/docs/adr/0003-pseudonymise-bluesky-record-identifiers.md @@ -0,0 +1,42 @@ +# ADR 0003: Pseudonymise Bluesky record identifiers + +- **Status:** Accepted +- **Date:** 2026-08-04 + +## Context + +A Bluesky AT URI contains the author's decentralised identifier. A normal `bsky.app` post URL also +contains the author's DID or handle. Storing either value unchanged would preserve a direct author +identifier even if the dedicated author column were pseudonymised. + +The collector still needs stable post identity for idempotency, author matching for later erasure, +and enough provenance to reason about the source record. + +## Decision + +Before persistence, compute separate HMAC-SHA256 values for the author DID and full post AT URI. Use +domain-separated inputs (`author:v1` and `post:v1`) so equal source values cannot be correlated +across purposes. Keep the secret outside the repository and database. + +Store the content CID and record key, which do not directly identify the author. Store a +pseudonymous `bluesky://post/` reference instead of the public source URL. Remove the author's +DID, handle, display name, and visible mentions from text before passing a record to the repository. + +## Consequences + +- Reruns can detect the same source post without storing its author-bearing URI. +- Erasure can locate an author's records when the original DID is supplied and HMACed with the same + key. +- A database leak does not reveal the direct author or post URI through those fields. +- Stored rows do not contain a clickable source link. The content CID and record key preserve partial + provenance but cannot reconstruct the source independently. +- Text redaction is deliberately conservative but cannot prove that free-form prose contains no + indirect identifying details. Reports and exports remain private by default. + +## Alternatives considered + +- **Store the AT URI as the post ID:** rejected because it contains the raw author DID. +- **Use an unkeyed hash:** rejected because known public identifiers can be tested offline. +- **Encrypt the source URL in the same database:** deferred because it requires separate key + management, access control, rotation, and audited reads. Add it only if direct retrieval becomes a + reviewed product requirement. diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py index 3522320..012587e 100644 --- a/src/loc_observatory/cli.py +++ b/src/loc_observatory/cli.py @@ -13,6 +13,7 @@ BlueskyAccessError, BlueskyGateway, BlueskyPostGateway, + BlueskySearchGateway, ) from loc_observatory.collector.bluesky import ( run_access_check as run_bluesky_access_check, @@ -25,15 +26,23 @@ from loc_observatory.collector.reddit import ( run_access_check as run_reddit_access_check, ) +from loc_observatory.collector.service import ( + Pseudonymizer, + build_search_queries, + collect_bluesky_posts, +) from loc_observatory.config import ( + AUTHOR_SECRET_NAMES, REDDIT_SECRET_NAMES, AppSettings, ConfigurationError, load_settings, ) from loc_observatory.warehouse.database import connect_database, migrate_database +from loc_observatory.warehouse.posts import SQLitePostRepository type BlueskyGatewayFactory = Callable[[AppSettings], BlueskyPostGateway] +type BlueskyCollectionGatewayFactory = Callable[[AppSettings], BlueskySearchGateway] type RedditGatewayFactory = Callable[[AppSettings], RedditGateway] @@ -51,6 +60,11 @@ def build_parser() -> argparse.ArgumentParser: "check-access", help="Fetch ten recent post IDs without printing or storing them", ) + collect_parser = bluesky_commands.add_parser( + "collect", + help="Collect a bounded, redacted batch into SQLite", + ) + collect_parser.add_argument("--database", type=Path) reddit_parser = commands.add_parser("reddit", help="Reddit setup and collection commands") reddit_commands = reddit_parser.add_subparsers(dest="reddit_command", required=True) @@ -73,6 +87,9 @@ def main( argv: Sequence[str] | None = None, *, bluesky_gateway_factory: BlueskyGatewayFactory = BlueskyGateway.from_settings, + bluesky_collection_gateway_factory: BlueskyCollectionGatewayFactory = ( + BlueskyGateway.from_settings + ), reddit_gateway_factory: RedditGatewayFactory = PrawRedditGateway.from_settings, ) -> int: """Run a command and return a process exit status.""" @@ -81,6 +98,9 @@ def main( if arguments.command == "bluesky" and arguments.bluesky_command == "check-access": return _check_bluesky_access(arguments, bluesky_gateway_factory) + if arguments.command == "bluesky" and arguments.bluesky_command == "collect": + return _collect_bluesky(arguments, bluesky_collection_gateway_factory) + if arguments.command == "reddit" and arguments.reddit_command == "check-access": return _check_reddit_access(arguments, reddit_gateway_factory) @@ -164,6 +184,73 @@ def _check_bluesky_access( return 0 +def _collect_bluesky( + arguments: argparse.Namespace, + gateway_factory: BlueskyCollectionGatewayFactory, +) -> int: + """Collect one configured, privacy-minimised Bluesky batch.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=AUTHOR_SECRET_NAMES, + ) + author_key = settings.secrets.author_hmac_key + if author_key is None: + raise ConfigurationError("Missing required environment variable: AUTHOR_HMAC_KEY") + + database_path = arguments.database or settings.warehouse.path + connection = connect_database(database_path) + try: + migrate_database(connection) + queries = build_search_queries( + ai_terms=settings.search.ai_terms, + scheming_terms=settings.search.scheming_terms, + reaction_terms=settings.search.reaction_terms, + limit=settings.bluesky.max_queries, + ) + result = collect_bluesky_posts( + gateway_factory(settings), + SQLitePostRepository(connection), + Pseudonymizer(author_key.get_secret_value().encode()), + queries=queries, + page_size=settings.bluesky.page_size, + max_pages_per_query=settings.bluesky.max_pages_per_query, + collector_version="bluesky-v1", + ) + finally: + connection.close() + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except BlueskyAccessError as error: + print(f"Bluesky collection failed: {error}", file=sys.stderr) + return 1 + except (OSError, sqlite3.Error) as error: + print(f"Warehouse write failed ({type(error).__name__})", file=sys.stderr) + return 1 + + all_queries_failed = result.query_failures == result.queries_run + status = "failed" if all_queries_failed else "partial" if result.query_failures else "ok" + print( + json.dumps( + { + "database": str(database_path), + "duplicates": result.duplicates, + "pages_fetched": result.pages_fetched, + "posts_inserted": result.posts_inserted, + "posts_seen": result.posts_seen, + "query_failures": result.query_failures, + "queries_run": result.queries_run, + "source": "bluesky", + "status": status, + }, + sort_keys=True, + ) + ) + return 1 if all_queries_failed else 0 + + def _check_reddit_access( arguments: argparse.Namespace, gateway_factory: RedditGatewayFactory, diff --git a/src/loc_observatory/collector/bluesky.py b/src/loc_observatory/collector/bluesky.py index 6986638..428748f 100644 --- a/src/loc_observatory/collector/bluesky.py +++ b/src/loc_observatory/collector/bluesky.py @@ -2,7 +2,11 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from random import uniform +from time import sleep from typing import Protocol import httpx @@ -10,6 +14,8 @@ from loc_observatory.config import AppSettings +_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) + class BlueskyAccessError(RuntimeError): """Raised when Bluesky access cannot produce a complete, trustworthy sample.""" @@ -23,6 +29,19 @@ def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: ... +class BlueskySearchGateway(Protocol): + """Read-only boundary used by bounded collection.""" + + def search_posts( + self, + query: str, + limit: int, + cursor: str | None = None, + ) -> BlueskySearchPage: + """Return one validated page of recent search results.""" + ... + + class _SearchPost(BaseModel): """Minimum post shape required from the external search response.""" @@ -47,6 +66,54 @@ class _SearchResponse(BaseModel): posts: tuple[_SearchPost, ...] = Field(min_length=1) +class _Author(BaseModel): + """Author fields that must remain inside the collection boundary.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + did: str = Field(pattern=r"^did:") + handle: str = Field(min_length=1) + display_name: str | None = Field(alias="displayName", default=None) + + +class _PostRecord(BaseModel): + """Post record fields needed for evidence scoring.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + text: str + created_at: datetime = Field(alias="createdAt") + + @field_validator("created_at") + @classmethod + def validate_created_at(cls, value: datetime) -> datetime: + """Reject ambiguous timestamps before they reach the domain model.""" + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("expected a timezone-aware timestamp") + return value + + +class _CollectionPost(_SearchPost): + """Full provider post shape required by collection.""" + + cid: str = Field(min_length=1) + author: _Author + record: _PostRecord + like_count: int = Field(alias="likeCount", default=0, ge=0) + reply_count: int = Field(alias="replyCount", default=0, ge=0) + repost_count: int = Field(alias="repostCount", default=0, ge=0) + quote_count: int = Field(alias="quoteCount", default=0, ge=0) + + +class _CollectionResponse(BaseModel): + """Forward-compatible search page contract.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + posts: tuple[_CollectionPost, ...] + cursor: str | None = None + + @dataclass(frozen=True, slots=True) class BlueskyAccessResult: """Non-sensitive proof that a read-only search request succeeded.""" @@ -56,6 +123,32 @@ class BlueskyAccessResult: read_only: bool = True +@dataclass(frozen=True, slots=True) +class BlueskySourcePost: + """Validated raw post held only inside the collection boundary.""" + + uri: str + cid: str + record_key: str + author_did: str + author_handle: str + created_at: datetime + text: str + like_count: int + reply_count: int + repost_count: int + quote_count: int + author_display_name: str | None = None + + +@dataclass(frozen=True, slots=True) +class BlueskySearchPage: + """One validated provider page and its continuation cursor.""" + + posts: tuple[BlueskySourcePost, ...] + cursor: str | None + + class BlueskyGateway: """HTTP adapter for the public, read-only Bluesky AppView.""" @@ -65,11 +158,21 @@ def __init__( api_base_url: str, user_agent: str, timeout_seconds: float, + max_attempts: int = 1, + retry_base_seconds: float = 0.5, + max_retry_delay_seconds: float = 5, + sleeper: Callable[[float], None] = sleep, + jitter: Callable[[float, float], float] = uniform, transport: httpx.BaseTransport | None = None, ) -> None: self._api_base_url = api_base_url.rstrip("/") self._user_agent = user_agent self._timeout_seconds = timeout_seconds + self._max_attempts = max_attempts + self._retry_base_seconds: float = retry_base_seconds + self._max_retry_delay_seconds: float = max_retry_delay_seconds + self._sleeper = sleeper + self._jitter = jitter self._transport = transport @classmethod @@ -79,36 +182,112 @@ def from_settings(cls, settings: AppSettings) -> BlueskyGateway: api_base_url=str(settings.bluesky.api_base_url), user_agent=settings.bluesky.user_agent, timeout_seconds=settings.bluesky.timeout_seconds, + max_attempts=settings.bluesky.max_attempts, + retry_base_seconds=settings.bluesky.retry_base_seconds, + max_retry_delay_seconds=settings.bluesky.max_retry_delay_seconds, ) def recent_post_ids(self, query: str, limit: int) -> tuple[str, ...]: """Fetch only post identifiers and translate unsafe provider failures.""" + raw_payload = self._search_payload(query=query, limit=limit) try: - with httpx.Client( - base_url=self._api_base_url, - headers={"User-Agent": self._user_agent}, - timeout=self._timeout_seconds, - transport=self._transport, - ) as client: - response = client.get( - "/xrpc/app.bsky.feed.searchPosts", - params={"q": query, "limit": limit, "sort": "latest"}, - ) - response.raise_for_status() - payload = _SearchResponse.model_validate(response.json()) - except httpx.HTTPStatusError as error: - raise BlueskyAccessError( - f"Bluesky API request failed (HTTP {error.response.status_code})" - ) from None - except httpx.RequestError as error: - raise BlueskyAccessError( - f"Bluesky API request failed ({type(error).__name__})" - ) from None - except (ValidationError, ValueError): + payload = _SearchResponse.model_validate(raw_payload) + except ValidationError: raise BlueskyAccessError("Bluesky API returned an invalid response") from None return tuple(post.uri for post in payload.posts) + def search_posts( + self, + query: str, + limit: int, + cursor: str | None = None, + ) -> BlueskySearchPage: + """Fetch and validate one collection page.""" + raw_payload = self._search_payload(query=query, limit=limit, cursor=cursor) + try: + payload = _CollectionResponse.model_validate(raw_payload) + except ValidationError: + raise BlueskyAccessError("Bluesky API returned an invalid response") from None + + posts = tuple( + BlueskySourcePost( + uri=post.uri, + cid=post.cid, + record_key=post.uri.rsplit("/", maxsplit=1)[-1], + author_did=post.author.did, + author_handle=post.author.handle, + created_at=post.record.created_at, + text=post.record.text, + like_count=post.like_count, + reply_count=post.reply_count, + repost_count=post.repost_count, + quote_count=post.quote_count, + author_display_name=post.author.display_name, + ) + for post in payload.posts + ) + return BlueskySearchPage(posts=posts, cursor=payload.cursor) + + def _search_payload( + self, + *, + query: str, + limit: int, + cursor: str | None = None, + ) -> object: + """Make one search request and return an untrusted JSON-compatible value.""" + parameters: dict[str, str | int] = {"q": query, "limit": limit, "sort": "latest"} + if cursor is not None: + parameters["cursor"] = cursor + + with httpx.Client( + base_url=self._api_base_url, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout_seconds, + transport=self._transport, + ) as client: + for attempt in range(1, self._max_attempts + 1): + try: + response = client.get( + "/xrpc/app.bsky.feed.searchPosts", + params=parameters, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as error: + status_code = error.response.status_code + if status_code in _TRANSIENT_STATUS_CODES and attempt < self._max_attempts: + self._sleeper(self._retry_delay(error.response, attempt)) + continue + raise BlueskyAccessError( + f"Bluesky API request failed (HTTP {status_code})" + ) from None + except httpx.RequestError as error: + if attempt < self._max_attempts: + self._sleeper(self._retry_delay(None, attempt)) + continue + raise BlueskyAccessError( + f"Bluesky API request failed ({type(error).__name__})" + ) from None + except ValueError: + raise BlueskyAccessError("Bluesky API returned an invalid response") from None + + raise BlueskyAccessError("Bluesky API request failed after bounded retries") + + def _retry_delay(self, response: httpx.Response | None, attempt: int) -> float: + """Prefer a numeric provider hint, otherwise use bounded exponential backoff.""" + if response is not None: + retry_after = response.headers.get("Retry-After") + if retry_after is not None: + try: + return min(float(retry_after), self._max_retry_delay_seconds) + except ValueError: + pass + base_delay: float = self._retry_base_seconds * (2 ** (attempt - 1)) + delay = base_delay + self._jitter(0, base_delay) + return min(delay, self._max_retry_delay_seconds) + def run_access_check( gateway: BlueskyPostGateway, diff --git a/src/loc_observatory/collector/service.py b/src/loc_observatory/collector/service.py new file mode 100644 index 0000000..ef0e653 --- /dev/null +++ b/src/loc_observatory/collector/service.py @@ -0,0 +1,203 @@ +"""Source-neutral collection rules for privacy and bounded execution.""" + +from __future__ import annotations + +import hashlib +import hmac +import re +from collections.abc import Callable, Sequence +from datetime import UTC, datetime +from itertools import product +from typing import Protocol + +from loc_observatory.collector.bluesky import ( + BlueskyAccessError, + BlueskySearchGateway, + BlueskySourcePost, +) +from loc_observatory.models import CollectedPost, CollectionResult, PostWriteResult + +_MENTION_PATTERN = re.compile(r"(? PostWriteResult: + """Insert posts and report new and duplicate counts.""" + ... + + +class Pseudonymizer: + """Create stable, domain-separated references without retaining source identifiers.""" + + def __init__(self, key: bytes) -> None: + if len(key) < 32: + raise ValueError("pseudonymisation key must contain at least 32 bytes") + self._key = key + + def author(self, author_id: str) -> str: + """Pseudonymise a source author identifier.""" + return self._digest("author:v1", author_id) + + def post(self, post_id: str) -> str: + """Pseudonymise an author-bearing source post identifier.""" + return self._digest("post:v1", post_id) + + def _digest(self, domain: str, value: str) -> str: + message = f"{domain}:{value}".encode() + return hmac.new(self._key, message, hashlib.sha256).hexdigest() + + +def build_search_queries( + *, + ai_terms: Sequence[str], + scheming_terms: Sequence[str], + reaction_terms: Sequence[str], + limit: int, +) -> tuple[str, ...]: + """Pair AI and report-derived signal terms within an explicit request bound.""" + if limit < 1: + raise ValueError("query limit must be positive") + + signals = _unique_terms((*scheming_terms, *reaction_terms)) + queries = ( + f"{_quote_term(ai_term)} {_quote_term(signal_term)}" + for ai_term, signal_term in product(_unique_terms(ai_terms), signals) + ) + return tuple(query for _, query in zip(range(limit), queries, strict=False)) + + +def collect_bluesky_posts( + gateway: BlueskySearchGateway, + repository: PostRepository, + pseudonymizer: Pseudonymizer, + *, + queries: Sequence[str], + page_size: int, + max_pages_per_query: int, + collector_version: str, + now: Callable[[], datetime] = lambda: datetime.now(UTC), +) -> CollectionResult: + """Collect bounded pages and redact every post before persistence.""" + if page_size < 1 or max_pages_per_query < 1: + raise ValueError("page and pagination bounds must be positive") + + pages_fetched = 0 + posts_seen = 0 + posts_inserted = 0 + duplicates = 0 + query_failures = 0 + + for query in queries: + cursor: str | None = None + for _ in range(max_pages_per_query): + try: + page = gateway.search_posts(query, page_size, cursor) + except BlueskyAccessError: + query_failures += 1 + break + pages_fetched += 1 + posts_seen += len(page.posts) + collected_at = _utc_string(now()) + safe_posts = tuple( + _minimise_post( + post, + query=query, + collected_at=collected_at, + collector_version=collector_version, + pseudonymizer=pseudonymizer, + ) + for post in page.posts + ) + write_result = repository.add_posts(safe_posts) + posts_inserted += write_result.inserted + duplicates += write_result.duplicates + + cursor = page.cursor + if cursor is None: + break + + return CollectionResult( + queries_run=len(queries), + pages_fetched=pages_fetched, + posts_seen=posts_seen, + posts_inserted=posts_inserted, + duplicates=duplicates, + query_failures=query_failures, + ) + + +def _minimise_post( + post: BlueskySourcePost, + *, + query: str, + collected_at: str, + collector_version: str, + pseudonymizer: Pseudonymizer, +) -> CollectedPost: + external_id = pseudonymizer.post(post.uri) + return CollectedPost( + source="bluesky", + external_id=external_id, + source_url=f"bluesky://post/{external_id}", + content_cid=post.cid, + record_key=post.record_key, + author_hmac=pseudonymizer.author(post.author_did), + created_at=_utc_string(post.created_at), + text=_redact_text( + post.text, + post.author_did, + post.author_handle, + post.author_display_name, + ), + like_count=post.like_count, + reply_count=post.reply_count, + repost_count=post.repost_count, + quote_count=post.quote_count, + query=query, + collected_at=collected_at, + collector_version=collector_version, + ) + + +def _redact_text( + text: str, + author_did: str, + author_handle: str, + author_display_name: str | None, +) -> str: + redacted = text.replace(author_did, "[author]") + redacted = re.sub(re.escape(f"@{author_handle}"), "[author]", redacted, flags=re.IGNORECASE) + redacted = re.sub(re.escape(author_handle), "[author]", redacted, flags=re.IGNORECASE) + if author_display_name: + redacted = re.sub( + re.escape(author_display_name), + "[author]", + redacted, + flags=re.IGNORECASE, + ) + return _MENTION_PATTERN.sub("[user]", redacted) + + +def _utc_string(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must be timezone-aware") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _unique_terms(terms: Sequence[str]) -> tuple[str, ...]: + seen: set[str] = set() + unique: list[str] = [] + for term in terms: + cleaned = term.strip() + key = cleaned.casefold() + if cleaned and key not in seen: + seen.add(key) + unique.append(cleaned) + return tuple(unique) + + +def _quote_term(term: str) -> str: + escaped = term.replace('"', '\\"') + return f'"{escaped}"' if " " in escaped else escaped diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py index da0405b..336a812 100644 --- a/src/loc_observatory/config.py +++ b/src/loc_observatory/config.py @@ -28,6 +28,7 @@ } ALL_SECRET_NAMES = frozenset(SECRET_FIELDS) +AUTHOR_SECRET_NAMES = frozenset({"AUTHOR_HMAC_KEY"}) CORE_SECRET_NAMES = frozenset({"ANTHROPIC_API_KEY", "AUTHOR_HMAC_KEY"}) REDDIT_SECRET_NAMES = frozenset({"REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"}) @@ -48,6 +49,12 @@ class BlueskySettings(FrozenSettings): api_base_url: HttpUrl sample_query: str = Field(min_length=1) request_limit: int = Field(ge=1, le=100) + page_size: int = Field(ge=1, le=100) + max_pages_per_query: int = Field(ge=1, le=20) + max_queries: int = Field(ge=1, le=100) + max_attempts: int = Field(ge=1, le=5) + retry_base_seconds: float = Field(ge=0, le=30) + max_retry_delay_seconds: float = Field(ge=0, le=120) timeout_seconds: float = Field(gt=0, le=60) user_agent: str = Field(min_length=10) diff --git a/src/loc_observatory/models.py b/src/loc_observatory/models.py new file mode 100644 index 0000000..8b6ccb5 --- /dev/null +++ b/src/loc_observatory/models.py @@ -0,0 +1,46 @@ +"""Source-neutral records passed between pipeline boundaries.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CollectedPost: + """A minimised source record that is safe to pass to persistence.""" + + source: str + external_id: str + source_url: str + content_cid: str + record_key: str + author_hmac: str + created_at: str + text: str + like_count: int + reply_count: int + repost_count: int + quote_count: int + query: str + collected_at: str + collector_version: str + + +@dataclass(frozen=True, slots=True) +class PostWriteResult: + """Counts returned by one repository write.""" + + inserted: int + duplicates: int + + +@dataclass(frozen=True, slots=True) +class CollectionResult: + """Non-sensitive outcome of one bounded collection run.""" + + queries_run: int + pages_fetched: int + posts_seen: int + posts_inserted: int + duplicates: int + query_failures: int diff --git a/src/loc_observatory/warehouse/database.py b/src/loc_observatory/warehouse/database.py index 1a43578..d6f40db 100644 --- a/src/loc_observatory/warehouse/database.py +++ b/src/loc_observatory/warehouse/database.py @@ -6,7 +6,10 @@ from importlib import resources from pathlib import Path -_MIGRATION_NAMES = ("0001_initial.sql",) +_MIGRATION_NAMES = ( + "0001_initial.sql", + "0002_add_bluesky_provenance.sql", +) _MIGRATION_PACKAGE = "loc_observatory.warehouse.migrations" diff --git a/src/loc_observatory/warehouse/migrations/0002_add_bluesky_provenance.sql b/src/loc_observatory/warehouse/migrations/0002_add_bluesky_provenance.sql new file mode 100644 index 0000000..8ceff70 --- /dev/null +++ b/src/loc_observatory/warehouse/migrations/0002_add_bluesky_provenance.sql @@ -0,0 +1,9 @@ +-- Preserve content-addressed provenance without storing the author-bearing AT URI. +ALTER TABLE posts_raw +ADD COLUMN content_cid TEXT NOT NULL DEFAULT 'unknown' +CHECK (length(content_cid) > 0); + +-- The record key is not globally identifying; the HMAC external_id supplies stable uniqueness. +ALTER TABLE posts_raw +ADD COLUMN record_key TEXT NOT NULL DEFAULT 'unknown' +CHECK (length(record_key) > 0); diff --git a/src/loc_observatory/warehouse/posts.py b/src/loc_observatory/warehouse/posts.py new file mode 100644 index 0000000..70d6418 --- /dev/null +++ b/src/loc_observatory/warehouse/posts.py @@ -0,0 +1,63 @@ +"""Persistence boundary for minimised source posts.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Sequence + +from loc_observatory.models import CollectedPost, PostWriteResult + + +class SQLitePostRepository: + """Insert immutable posts while treating existing source IDs as duplicates.""" + + def __init__(self, connection: sqlite3.Connection) -> None: + self._connection = connection + + def add_posts(self, posts: Sequence[CollectedPost]) -> PostWriteResult: + """Insert a batch atomically and leave existing rows unchanged.""" + inserted = 0 + with self._connection: + for post in posts: + cursor = self._connection.execute( + """ + INSERT INTO posts_raw ( + source, + external_id, + source_url, + content_cid, + record_key, + author_hmac, + created_at, + text, + like_count, + reply_count, + repost_count, + quote_count, + query, + collected_at, + collector_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (source, external_id) DO NOTHING + """, + ( + post.source, + post.external_id, + post.source_url, + post.content_cid, + post.record_key, + post.author_hmac, + post.created_at, + post.text, + post.like_count, + post.reply_count, + post.repost_count, + post.quote_count, + post.query, + post.collected_at, + post.collector_version, + ), + ) + inserted += cursor.rowcount + + return PostWriteResult(inserted=inserted, duplicates=len(posts) - inserted) diff --git a/tests/test_bluesky_access.py b/tests/test_bluesky_access.py index 62aeb0e..6608fee 100644 --- a/tests/test_bluesky_access.py +++ b/tests/test_bluesky_access.py @@ -90,6 +90,69 @@ def test_gateway_translates_provider_and_schema_failures( gateway.recent_post_ids("Claude AI", 10) +def test_gateway_retries_transient_failures_and_respects_retry_after() -> None: + """Retry a bounded transient failure using the provider hint.""" + attempts = 0 + delays: list[float] = [] + + def handle_request(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(429, headers={"Retry-After": "2"}) + return httpx.Response( + 200, + json={ + "posts": [ + {"uri": f"at://did:plc:test/app.bsky.feed.post/post-{index}"} + for index in range(10) + ] + }, + ) + + gateway = BlueskyGateway( + api_base_url="https://api.bsky.app", + user_agent="loc-observatory/0.1 test", + timeout_seconds=10, + max_attempts=3, + retry_base_seconds=0.5, + max_retry_delay_seconds=5, + sleeper=delays.append, + jitter=lambda _start, _end: 0, + transport=httpx.MockTransport(handle_request), + ) + + assert len(gateway.recent_post_ids("Claude AI", 10)) == 10 + assert attempts == 2 + assert delays == [2.0] + + +def test_gateway_does_not_retry_permanent_http_failures() -> None: + """Fail a bad request immediately rather than amplify it.""" + attempts = 0 + delays: list[float] = [] + + def handle_request(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(400) + + gateway = BlueskyGateway( + api_base_url="https://api.bsky.app", + user_agent="loc-observatory/0.1 test", + timeout_seconds=10, + max_attempts=3, + sleeper=delays.append, + transport=httpx.MockTransport(handle_request), + ) + + with pytest.raises(BlueskyAccessError, match="HTTP 400"): + gateway.recent_post_ids("Claude AI", 10) + + assert attempts == 1 + assert delays == [] + + def test_access_check_requires_requested_number_of_unique_posts() -> None: """Accept only a complete sample that proves the search endpoint could be read.""" result = run_access_check( diff --git a/tests/test_bluesky_collection.py b/tests/test_bluesky_collection.py new file mode 100644 index 0000000..3ca6a31 --- /dev/null +++ b/tests/test_bluesky_collection.py @@ -0,0 +1,319 @@ +"""Tests for privacy-first Bluesky collection and persistence.""" + +import hashlib +import hmac +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from loc_observatory.cli import main +from loc_observatory.collector.bluesky import ( + BlueskyAccessError, + BlueskySearchGateway, + BlueskySearchPage, + BlueskySourcePost, +) +from loc_observatory.collector.service import ( + Pseudonymizer, + build_search_queries, + collect_bluesky_posts, +) +from loc_observatory.config import AppSettings +from loc_observatory.warehouse.database import connect_database, migrate_database +from loc_observatory.warehouse.posts import SQLitePostRepository + + +class FakeSearchGateway: + """Deterministic paginated source boundary.""" + + def __init__(self, pages: dict[str | None, BlueskySearchPage]) -> None: + self.pages = pages + self.calls: list[tuple[str, int, str | None]] = [] + + def search_posts( + self, + query: str, + limit: int, + cursor: str | None = None, + ) -> BlueskySearchPage: + """Return the configured page and record pagination behavior.""" + self.calls.append((query, limit, cursor)) + return self.pages[cursor] + + +class PartiallyFailingGateway: + """Fail one query while allowing later independent work to continue.""" + + def search_posts( + self, + query: str, + limit: int, + cursor: str | None = None, + ) -> BlueskySearchPage: + """Return one failure and one successful page.""" + if query == "bad query": + raise BlueskyAccessError("Bluesky API request failed (HTTP 403)") + return BlueskySearchPage(posts=(make_source_post(1),), cursor=None) + + +class AlwaysFailingGateway: + """Reject every configured query.""" + + def search_posts( + self, + query: str, + limit: int, + cursor: str | None = None, + ) -> BlueskySearchPage: + """Raise one safe permanent error for each query.""" + raise BlueskyAccessError("Bluesky API request failed (HTTP 403)") + + +def make_source_post(index: int) -> BlueskySourcePost: + """Build one raw in-memory post containing identifiers that must be removed.""" + return BlueskySourcePost( + uri=f"at://did:plc:sensitive/app.bsky.feed.post/post-{index}", + cid=f"bafy-content-{index}", + record_key=f"post-{index}", + author_did="did:plc:sensitive", + author_handle="author.example", + author_display_name="Sensitive Person", + created_at=datetime(2026, 8, 4, 20, index, tzinfo=UTC), + text=( + "A report by Sensitive Person (@author.example) with @other.example " + "about Claude ignoring instructions" + ), + like_count=index, + reply_count=0, + repost_count=0, + quote_count=0, + ) + + +def test_collection_redacts_before_idempotent_persistence(tmp_path: Path) -> None: + """Never bind raw author or AT URI values, and ignore the same posts on a rerun.""" + database_path = tmp_path / "observatory.db" + connection = connect_database(database_path) + try: + migrate_database(connection) + repository = SQLitePostRepository(connection) + gateway = FakeSearchGateway( + {None: BlueskySearchPage(posts=(make_source_post(1), make_source_post(2)), cursor=None)} + ) + pseudonymizer = Pseudonymizer(b"a-secret-key-with-at-least-32-bytes") + + first = collect_bluesky_posts( + gateway, + repository, + pseudonymizer, + queries=("Claude ignored instructions",), + page_size=25, + max_pages_per_query=1, + collector_version="bluesky-v1", + now=lambda: datetime(2026, 8, 4, 21, 0, tzinfo=UTC), + ) + second = collect_bluesky_posts( + gateway, + repository, + pseudonymizer, + queries=("Claude ignored instructions",), + page_size=25, + max_pages_per_query=1, + collector_version="bluesky-v1", + now=lambda: datetime(2026, 8, 4, 21, 1, tzinfo=UTC), + ) + + expected_author_hmac = hmac.new( + b"a-secret-key-with-at-least-32-bytes", + b"author:v1:did:plc:sensitive", + hashlib.sha256, + ).hexdigest() + rows = connection.execute( + """ + SELECT external_id, source_url, content_cid, record_key, author_hmac, text + FROM posts_raw + ORDER BY record_key + """ + ).fetchall() + + assert first.posts_seen == 2 + assert first.posts_inserted == 2 + assert first.duplicates == 0 + assert first.query_failures == 0 + assert second.posts_seen == 2 + assert second.posts_inserted == 0 + assert second.duplicates == 2 + assert len(rows) == 2 + assert rows[0][1].startswith("bluesky://post/") + assert rows[0][2] == "bafy-content-1" + assert rows[0][3] == "post-1" + assert rows[0][4] == expected_author_hmac + assert rows[0][5] == ( + "A report by [author] ([author]) with [user] about Claude ignoring instructions" + ) + finally: + connection.close() + + database_bytes = database_path.read_bytes() + assert b"did:plc:sensitive" not in database_bytes + assert b"author.example" not in database_bytes + assert b"Sensitive Person" not in database_bytes + assert b"other.example" not in database_bytes + assert b"at://did:plc:sensitive" not in database_bytes + + +def test_collection_follows_cursors_only_within_the_configured_bound(tmp_path: Path) -> None: + """Stop pagination at the explicit page bound even when another cursor exists.""" + gateway = FakeSearchGateway( + { + None: BlueskySearchPage(posts=(make_source_post(1),), cursor="page-2"), + "page-2": BlueskySearchPage(posts=(make_source_post(2),), cursor="page-3"), + "page-3": BlueskySearchPage(posts=(make_source_post(3),), cursor=None), + } + ) + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + result = collect_bluesky_posts( + gateway, + SQLitePostRepository(connection), + Pseudonymizer(b"a-secret-key-with-at-least-32-bytes"), + queries=("Claude ignored instructions",), + page_size=1, + max_pages_per_query=2, + collector_version="bluesky-v1", + now=lambda: datetime(2026, 8, 4, 21, 0, tzinfo=UTC), + ) + finally: + connection.close() + + assert result.pages_fetched == 2 + assert result.posts_inserted == 2 + assert gateway.calls == [ + ("Claude ignored instructions", 1, None), + ("Claude ignored instructions", 1, "page-2"), + ] + + +def test_query_builder_pairs_report_terms_and_caps_requests() -> None: + """Build deterministic high-recall term pairs without an unbounded query fan-out.""" + queries = build_search_queries( + ai_terms=("AI", "Claude"), + scheming_terms=("ignored instructions", "lied"), + reaction_terms=("concerning",), + limit=4, + ) + + assert queries == ( + 'AI "ignored instructions"', + "AI lied", + "AI concerning", + 'Claude "ignored instructions"', + ) + + +def test_collection_isolates_a_permanent_query_failure(tmp_path: Path) -> None: + """Let one rejected query fail without discarding unrelated successful work.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + result = collect_bluesky_posts( + PartiallyFailingGateway(), + SQLitePostRepository(connection), + Pseudonymizer(b"a-secret-key-with-at-least-32-bytes"), + queries=("bad query", "good query"), + page_size=1, + max_pages_per_query=1, + collector_version="bluesky-v1", + now=lambda: datetime(2026, 8, 4, 21, 0, tzinfo=UTC), + ) + finally: + connection.close() + + assert result.queries_run == 2 + assert result.query_failures == 1 + assert result.posts_seen == 1 + assert result.posts_inserted == 1 + + +def test_cli_runs_bounded_collection_without_exposing_the_hmac_key( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wire configuration, collection, migration, and persistence through one command.""" + monkeypatch.delenv("AUTHOR_HMAC_KEY", raising=False) + env_path = tmp_path / ".env" + env_path.write_text( + "AUTHOR_HMAC_KEY=a-secret-key-with-at-least-32-bytes\n", + encoding="utf-8", + ) + database_path = tmp_path / "observatory.db" + gateway = FakeSearchGateway( + {None: BlueskySearchPage(posts=(make_source_post(1), make_source_post(2)), cursor=None)} + ) + + def gateway_factory(_settings: AppSettings) -> BlueskySearchGateway: + return gateway + + exit_code = main( + [ + "--config", + "config.yaml", + "--env-file", + str(env_path), + "bluesky", + "collect", + "--database", + str(database_path), + ], + bluesky_collection_gateway_factory=gateway_factory, + ) + + output = capsys.readouterr() + payload = json.loads(output.out) + assert exit_code == 0 + assert payload == { + "database": str(database_path), + "duplicates": 14, + "pages_fetched": 8, + "posts_inserted": 2, + "posts_seen": 16, + "query_failures": 0, + "queries_run": 8, + "source": "bluesky", + "status": "ok", + } + assert "a-secret-key" not in output.out + assert output.err == "" + + +def test_cli_fails_visibly_when_every_query_is_rejected( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not report a successful run when no query reached the provider boundary.""" + monkeypatch.setenv("AUTHOR_HMAC_KEY", "a-secret-key-with-at-least-32-bytes") + + exit_code = main( + [ + "--config", + "config.yaml", + "bluesky", + "collect", + "--database", + str(tmp_path / "observatory.db"), + ], + bluesky_collection_gateway_factory=lambda _settings: AlwaysFailingGateway(), + ) + + output = capsys.readouterr() + payload = json.loads(output.out) + assert exit_code == 1 + assert payload["status"] == "failed" + assert payload["query_failures"] == payload["queries_run"] == 8 + assert payload["posts_seen"] == 0 + assert output.err == "" diff --git a/tests/test_config.py b/tests/test_config.py index 4683de6..6de039f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,6 +18,12 @@ api_base_url: https://api.bsky.app sample_query: Claude AI request_limit: 10 + page_size: 25 + max_pages_per_query: 1 + max_queries: 8 + max_attempts: 3 + retry_base_seconds: 0.5 + max_retry_delay_seconds: 5 timeout_seconds: 10 user_agent: loc-observatory/0.1 access-check reddit: @@ -69,6 +75,8 @@ def test_loads_versioned_config_and_redacts_secrets(tmp_path: Path) -> None: assert str(settings.bluesky.api_base_url) == "https://api.bsky.app/" assert settings.bluesky.sample_query == "Claude AI" + assert settings.bluesky.page_size == 25 + assert settings.bluesky.max_queries == 8 assert settings.reddit.subreddits == ("ClaudeAI", "ChatGPT") assert settings.reddit.request_limit == 10 assert settings.warehouse.path == Path("data/observatory.db") diff --git a/tests/test_warehouse.py b/tests/test_warehouse.py index 7692db9..954ca3d 100644 --- a/tests/test_warehouse.py +++ b/tests/test_warehouse.py @@ -18,6 +18,8 @@ def insert_source_post(connection: sqlite3.Connection) -> None: source, external_id, source_url, + content_cid, + record_key, author_hmac, created_at, text, @@ -28,12 +30,14 @@ def insert_source_post(connection: sqlite3.Connection) -> None: query, collected_at, collector_version - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "bluesky", "at://did:plc:test/app.bsky.feed.post/example", "https://bsky.app/profile/example.test/post/example", + "bafytest", + "example", "a" * 64, "2026-08-04T20:00:00Z", "Redacted source text", @@ -59,7 +63,7 @@ def test_migrations_build_an_empty_database_and_are_idempotent(tmp_path: Path) - row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'") } - assert first == ("0001_initial.sql",) + assert first == ("0001_initial.sql", "0002_add_bluesky_provenance.sql") assert second == () assert { "schema_migrations", @@ -196,7 +200,10 @@ def test_cli_migrates_configured_database( output = capsys.readouterr() assert exit_code == 0 assert json.loads(output.out) == { - "applied_migrations": ["0001_initial.sql"], + "applied_migrations": [ + "0001_initial.sql", + "0002_add_bluesky_provenance.sql", + ], "database": str(database_path), "status": "ok", } From 4229538042cfbbfae93b08bbe4dcd37b4dc8fc33 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 15:28:40 -0700 Subject: [PATCH 08/29] Keep evidence scoring conservative and reproducible The Observatory now scores bounded batches with a packaged pilot-derived rubric and validates every provider result before storage. Prompt hashes, model identity, reasoning, token usage, configured cost, and timestamps preserve reproducibility, while malformed and failed items remain isolated in the dead-letter queue. Constraint: Classifier input is minimised public post text processed under the provider workspace's active retention terms Rejected: Accept loosely parsed model prose | invalid or out-of-range results could contaminate evidence scores Confidence: high Scope-risk: moderate Directive: Treat scores as review priorities, not verified incidents; preserve strict output validation and prompt hashing Tested: make check (40 tests); offline Anthropic HTTP contract; uv build with packaged prompt Not-tested: Paid live Anthropic classification; image and transcript artifact inputs are not connected yet --- README.md | 9 + RUNBOOK.md | 16 + config.yaml | 7 +- docs/CLASSIFICATION.md | 41 +++ docs/CONFIGURATION.md | 5 +- docs/DATA_PROTECTION.md | 3 + src/loc_observatory/classifier/anthropic.py | 139 +++++++ src/loc_observatory/classifier/prompt.py | 28 ++ .../classifier/prompts/__init__.py | 1 + .../classifier/prompts/scoring_v1.md | 42 +++ src/loc_observatory/classifier/service.py | 189 ++++++++++ src/loc_observatory/cli.py | 74 ++++ src/loc_observatory/config.py | 7 +- src/loc_observatory/warehouse/scoring.py | 88 +++++ tests/test_classifier.py | 344 ++++++++++++++++++ tests/test_config.py | 7 +- 16 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 docs/CLASSIFICATION.md create mode 100644 src/loc_observatory/classifier/anthropic.py create mode 100644 src/loc_observatory/classifier/prompt.py create mode 100644 src/loc_observatory/classifier/prompts/__init__.py create mode 100644 src/loc_observatory/classifier/prompts/scoring_v1.md create mode 100644 src/loc_observatory/classifier/service.py create mode 100644 src/loc_observatory/warehouse/scoring.py create mode 100644 tests/test_classifier.py diff --git a/README.md b/README.md index e56b097..6566466 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,15 @@ After setting `AUTHOR_HMAC_KEY` in the ignored `.env` file, collect one bounded uv run observatory bluesky collect ``` +After setting `ANTHROPIC_API_KEY`, score one bounded batch with the versioned pilot-derived rubric: + +```bash +uv run observatory classify +``` + +See [Evidence classification](docs/CLASSIFICATION.md) for what the score means and what it cannot +establish. + The individual commands are: ```bash diff --git a/RUNBOOK.md b/RUNBOOK.md index 4792849..13807c4 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -61,6 +61,22 @@ and common 5xx failures are retried within the configured attempt and delay boun is counted and isolated so later queries can continue. The command reports `"status": "partial"` when this happens. +## Score a bounded batch + +Set `ANTHROPIC_API_KEY` in the ignored `.env` file, then run: + +```bash +uv run observatory classify +``` + +The command scores at most the configured batch size. It skips posts already scored with the same +model and prompt hash. Valid 0–9 results store the reasoning, model, prompt hash, token counts, +configured cost estimate, and timestamp. Malformed output and per-post provider failures enter the +dead-letter queue while the batch continues. Command output contains aggregate counts only. + +Token prices are configuration, not code, because provider pricing changes. Check them against the +provider's current pricing before a live run. + ## Common setup failures ### `uv` cannot find Python 3.12 diff --git a/config.yaml b/config.yaml index 5fcc149..858be10 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -config_version: 2 +config_version: 3 bluesky: api_base_url: https://api.bsky.app @@ -29,7 +29,12 @@ warehouse: classifier: provider: anthropic model: claude-opus-4-6 + api_base_url: https://api.anthropic.com max_output_tokens: 2048 + batch_size: 10 + timeout_seconds: 60 + input_cost_per_million: 5 + output_cost_per_million: 25 retention: raw_posts_days: 90 diff --git a/docs/CLASSIFICATION.md b/docs/CLASSIFICATION.md new file mode 100644 index 0000000..287e21b --- /dev/null +++ b/docs/CLASSIFICATION.md @@ -0,0 +1,41 @@ +# Evidence classification + +## What the score means + +The classifier estimates how strong and credible the evidence is that a post reports real-world +scheming-related AI behaviour. It does not decide whether an incident is true. A high score means +the post contains evidence that deserves careful human review. + +The 0-9 rubric is adapted from Appendix C of CLTR's pilot report. It is deliberately conservative: +ordinary model errors, hallucinations, safety refusals, deliberate misuse, jokes, promotions, and +unsupported claims should remain at the bottom of the scale. Ambiguous cases default to the lower +score. Scores from 7 to 9 should be rare. + +## How results can be checked + +The prompt is stored with the code. Each score records a SHA-256 hash of its exact prompt bytes, +the provider-returned model ID, reasoning, input and output token counts, configured cost estimate, +and timestamp. The pipeline skips a post already scored with the same model and prompt hash. + +Only output matching the strict JSON schema and 0-9 range enters the scores table. Malformed output +and per-post provider failures enter the dead-letter queue without stopping unrelated posts. The +dead-letter record stores a short error category, not the provider response or post text. + +## Provider data handling + +Classification sends the minimised post text to Anthropic's commercial Messages API. Anthropic +states that API inputs and outputs are deleted from its backend within 30 days by default, subject +to stated exceptions, and that ad hoc deletion of an individual paid API request is not supported. +Zero-data-retention terms are separate and apply only to approved organisations and eligible APIs. + +These terms were checked on 4 August 2026. Confirm the active workspace settings and current +[retention policy](https://privacy.claude.com/en/articles/7996866-how-long-do-you-store-my-organization-s-data) +before a live run. Do not use the classifier for content whose external processing or default +retention is unacceptable. + +## Limits + +The model sees stored post text in this stage; image analysis and transcript snapshots are not yet +connected. The prompt therefore tells the model to score a post with no transcript, screenshot, or +supported share link as 0. Model reasoning can still be wrong, and agreement with human labels must +be measured before the scores are used as a trend signal. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ffa07b6..76dfa41 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -18,11 +18,12 @@ redacted secret type so they are not revealed by normal object logging or error and user agent - settings for the optional, approval-gated Reddit adapter - ignored local SQLite database path -- classifier provider, model, and output limit +- classifier provider, model, batch and timeout limits, plus explicit token prices for run cost + estimates - source and artifact retention periods - the first AI, scheming, and reaction search terms -The file has `config_version: 2`. Unknown fields and unsupported versions fail validation rather than +The file has `config_version: 3`. Unknown fields and unsupported versions fail validation rather than being ignored. ## Required environment variables diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md index b4f719a..8782d51 100644 --- a/docs/DATA_PROTECTION.md +++ b/docs/DATA_PROTECTION.md @@ -30,6 +30,9 @@ not implemented yet. The legal basis, final retention periods, deployed access controls, and processor terms must be reviewed before live deployment. This project documentation is not legal advice. +The classifier sends minimised post text to a configured provider. Current provider retention and +the operational restriction are documented in [Evidence classification](CLASSIFICATION.md). + The pseudonymous source reference is deliberately not a public Bluesky link because both Bluesky AT URIs and profile URLs contain an author identifier. The warehouse keeps the content CID and record key for provenance. A deployed review system that needs direct source retrieval would require diff --git a/src/loc_observatory/classifier/anthropic.py b/src/loc_observatory/classifier/anthropic.py new file mode 100644 index 0000000..435eb33 --- /dev/null +++ b/src/loc_observatory/classifier/anthropic.py @@ -0,0 +1,139 @@ +"""Narrow Anthropic Messages API adapter for evidence scoring.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from loc_observatory.config import AppSettings + + +class ClassifierProviderError(RuntimeError): + """Safe provider failure that contains no prompt, post text, or credential.""" + + +@dataclass(frozen=True, slots=True) +class ProviderResult: + """Provider output and billable usage for one classification.""" + + content: str + model_id: str + input_tokens: int + output_tokens: int + + +class ClassifierGateway(Protocol): + """Provider-independent classifier boundary.""" + + model_id: str + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + """Classify one post and return raw structured-output text plus usage.""" + ... + + +class _TextBlock(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + type: str + text: str | None = None + + +class _Usage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + input_tokens: int = Field(ge=0) + output_tokens: int = Field(ge=0) + + +class _MessageResponse(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + model: str = Field(min_length=1) + content: tuple[_TextBlock, ...] = Field(min_length=1) + usage: _Usage + + +class AnthropicGateway: + """Synchronous adapter for Anthropic's versioned Messages endpoint.""" + + def __init__( + self, + *, + api_key: str, + model_id: str, + api_base_url: str, + max_output_tokens: int, + timeout_seconds: float, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.model_id = model_id + self._api_key = api_key + self._api_base_url = api_base_url.rstrip("/") + self._max_output_tokens = max_output_tokens + self._timeout_seconds = timeout_seconds + self._transport = transport + + @classmethod + def from_settings(cls, settings: AppSettings) -> AnthropicGateway: + """Build the adapter without exposing the configured API key.""" + secret = settings.secrets.anthropic_api_key + if secret is None: + raise ClassifierProviderError("Anthropic API key is unavailable") + return cls( + api_key=secret.get_secret_value(), + model_id=settings.classifier.model, + api_base_url=str(settings.classifier.api_base_url), + max_output_tokens=settings.classifier.max_output_tokens, + timeout_seconds=settings.classifier.timeout_seconds, + ) + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + """Send one post to the Messages API and validate its response envelope.""" + try: + with httpx.Client( + base_url=self._api_base_url, + timeout=self._timeout_seconds, + transport=self._transport, + ) as client: + response = client.post( + "/v1/messages", + headers={ + "anthropic-version": "2023-06-01", + "x-api-key": self._api_key, + }, + json={ + "model": self.model_id, + "max_tokens": self._max_output_tokens, + "temperature": 0, + "system": system_prompt, + "messages": [{"role": "user", "content": post_text}], + }, + ) + response.raise_for_status() + payload = _MessageResponse.model_validate(response.json()) + except httpx.HTTPStatusError as error: + raise ClassifierProviderError( + f"provider request failed (HTTP {error.response.status_code})" + ) from None + except httpx.RequestError as error: + raise ClassifierProviderError( + f"provider request failed ({type(error).__name__})" + ) from None + except (ValidationError, ValueError): + raise ClassifierProviderError("provider returned an invalid response") from None + + text_blocks = tuple( + block.text for block in payload.content if block.type == "text" and block.text + ) + if len(text_blocks) != 1: + raise ClassifierProviderError("provider returned an invalid text response") + return ProviderResult( + content=text_blocks[0], + model_id=payload.model, + input_tokens=payload.usage.input_tokens, + output_tokens=payload.usage.output_tokens, + ) diff --git a/src/loc_observatory/classifier/prompt.py b/src/loc_observatory/classifier/prompt.py new file mode 100644 index 0000000..aed1f9f --- /dev/null +++ b/src/loc_observatory/classifier/prompt.py @@ -0,0 +1,28 @@ +"""Load immutable classifier prompts with content-derived versions.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from importlib import resources + +_PROMPT_PACKAGE = "loc_observatory.classifier.prompts" + + +@dataclass(frozen=True, slots=True) +class PromptArtifact: + """Prompt text and the hash used as its persistent version.""" + + name: str + text: str + sha256: str + + +def load_scoring_prompt(name: str = "scoring_v1.md") -> PromptArtifact: + """Load a packaged scoring prompt and hash its exact UTF-8 bytes.""" + prompt_bytes = resources.files(_PROMPT_PACKAGE).joinpath(name).read_bytes() + return PromptArtifact( + name=name, + text=prompt_bytes.decode("utf-8"), + sha256=hashlib.sha256(prompt_bytes).hexdigest(), + ) diff --git a/src/loc_observatory/classifier/prompts/__init__.py b/src/loc_observatory/classifier/prompts/__init__.py new file mode 100644 index 0000000..3f42b3b --- /dev/null +++ b/src/loc_observatory/classifier/prompts/__init__.py @@ -0,0 +1 @@ +"""Versioned classifier prompt resources.""" diff --git a/src/loc_observatory/classifier/prompts/scoring_v1.md b/src/loc_observatory/classifier/prompts/scoring_v1.md new file mode 100644 index 0000000..04baf0f --- /dev/null +++ b/src/loc_observatory/classifier/prompts/scoring_v1.md @@ -0,0 +1,42 @@ +You are assessing one public report of possible AI scheming-related behaviour. + +Scheming means an AI system covertly pursuing goals that conflict with the intentions or interests +of its user, developer, or deployer. Misalignment includes actions that differ from the user's intent. +Covertness includes concealing actions, objectives, or capabilities from human oversight. + +Use a conservative interpretation. Default to mundane error when uncertain. A hallucination, +misunderstanding, safety refusal, political-bias complaint, jailbreak, prompt injection, deliberate +misuse, roleplay, joke, promotion, or unsupported dramatic claim is not credible evidence of +autonomous scheming. Experimental or red-team results are less relevant than spontaneous use. + +Score the evidence from 0 to 9: + +- 0: No relevant AI behaviour, or a joke, promotion, hypothetical discussion, or other exclusion. +- 1: Unexpected behaviour is clearly a mundane error, hallucination, misunderstanding, jailbreak, + or user-driven misuse. +- 2: A scheming-related claim has no transcript, screenshot, share link, or other support. +- 3: Some specific evidence is present, but mundane error, misinterpretation, or prompting is at + least as plausible as scheming. +- 4: Unusual behaviour has supporting evidence, but context is incomplete or ordinary explanations + remain plausible. +- 5: Clear evidence suggests misalignment or covertness and mundane explanations are less + plausible, but the evidence is partial, minor, or has credibility concerns. +- 6: Strong, credible evidence clearly shows limited misalignment or covert behaviour. +- 7: Strong, verifiable evidence would concern a sceptical expert and shows significant strategic + misalignment or notable covertness. It is not a mundane error or engineered misuse. +- 8: Very strong direct evidence shows severe or clearly strategic behaviour, or both misalignment + and covertness. +- 9: Unambiguous, verifiable evidence shows serious, strategic, and covert behaviour in a real-world + deployment. This score should be exceptionally rare. + +When uncertain between two scores, choose the lower one. Evidence matters more than the author's +reaction. If there is no transcript, screenshot, or supported chat share link, score 0. + +Return only one JSON object with exactly these fields: + +{ + "score": 0, + "score_reasoning": "Two to four concise sentences grounded in the post and its evidence.", + "evidence_type": "transcript|screenshot_no_transcript|chat_share_link|description_only|other|none", + "mundane_error": false +} diff --git a/src/loc_observatory/classifier/service.py b/src/loc_observatory/classifier/service.py new file mode 100644 index 0000000..eba2d84 --- /dev/null +++ b/src/loc_observatory/classifier/service.py @@ -0,0 +1,189 @@ +"""Conservative scoring orchestration with per-post failure isolation.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from loc_observatory.classifier.anthropic import ( + ClassifierGateway, + ClassifierProviderError, + ProviderResult, +) +from loc_observatory.classifier.prompt import PromptArtifact + + +@dataclass(frozen=True, slots=True) +class PendingPost: + """Minimum source record required by the classifier.""" + + source: str + external_id: str + text: str + + +@dataclass(frozen=True, slots=True) +class StoredScore: + """Validated score and complete reproduction metadata.""" + + source: str + external_id: str + score: int + reasoning: str + model_id: str + prompt_hash: str + input_tokens: int + output_tokens: int + cost_usd: float + scored_at: str + + +@dataclass(frozen=True, slots=True) +class ScoringResult: + """Non-sensitive outcome of a bounded scoring run.""" + + attempted: int + scored: int + failed: int + input_tokens: int + output_tokens: int + cost_usd: float + + +class ScoreRepository(Protocol): + """Persistence boundary for pending posts, scores, and recoverable failures.""" + + def pending_posts(self, model_id: str, prompt_hash: str, limit: int) -> Sequence[PendingPost]: + """Return posts without a score for this model and prompt.""" + ... + + def add_score(self, score: StoredScore) -> bool: + """Insert a score if an identical result key does not already exist.""" + ... + + def add_failure(self, post: PendingPost, error_code: str, error_message: str, at: str) -> None: + """Record a safe, replayable per-post failure.""" + ... + + +class _Classification(BaseModel): + """Strict subset of the pilot output required by this pipeline stage.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + score: int = Field(ge=0, le=9) + score_reasoning: str = Field(min_length=1, max_length=2000) + evidence_type: Literal[ + "transcript", + "screenshot_no_transcript", + "chat_share_link", + "description_only", + "other", + "none", + ] + mundane_error: bool + + +def score_pending_posts( + gateway: ClassifierGateway, + repository: ScoreRepository, + prompt: PromptArtifact, + *, + limit: int, + input_cost_per_million: float, + output_cost_per_million: float, + now: Callable[[], datetime] = lambda: datetime.now(UTC), +) -> ScoringResult: + """Score one bounded batch while sending malformed and failed items to the DLQ.""" + if limit < 1: + raise ValueError("classification limit must be positive") + posts = repository.pending_posts(gateway.model_id, prompt.sha256, limit) + scored = 0 + failed = 0 + input_tokens = 0 + output_tokens = 0 + total_cost = 0.0 + + for post in posts: + attempted_at = _utc_string(now()) + try: + provider_result = gateway.classify(prompt.text, post.text) + if provider_result.model_id != gateway.model_id: + raise ClassifierProviderError("provider returned an unexpected model") + classification = _parse_classification(provider_result.content) + cost = _cost_usd( + provider_result, + input_cost_per_million=input_cost_per_million, + output_cost_per_million=output_cost_per_million, + ) + inserted = repository.add_score( + StoredScore( + source=post.source, + external_id=post.external_id, + score=classification.score, + reasoning=classification.score_reasoning, + model_id=provider_result.model_id, + prompt_hash=prompt.sha256, + input_tokens=provider_result.input_tokens, + output_tokens=provider_result.output_tokens, + cost_usd=cost, + scored_at=attempted_at, + ) + ) + except ClassifierProviderError as error: + failed += 1 + repository.add_failure(post, "provider_error", str(error), attempted_at) + continue + except (json.JSONDecodeError, ValidationError): + failed += 1 + repository.add_failure( + post, + "invalid_output", + "classifier output did not match the scoring schema", + attempted_at, + ) + continue + + if inserted: + scored += 1 + input_tokens += provider_result.input_tokens + output_tokens += provider_result.output_tokens + total_cost += cost + + return ScoringResult( + attempted=len(posts), + scored=scored, + failed=failed, + input_tokens=input_tokens, + output_tokens=output_tokens, + cost_usd=round(total_cost, 8), + ) + + +def _parse_classification(content: str) -> _Classification: + payload = json.loads(content) + return _Classification.model_validate(payload) + + +def _cost_usd( + result: ProviderResult, + *, + input_cost_per_million: float, + output_cost_per_million: float, +) -> float: + cost = ( + result.input_tokens * input_cost_per_million + + result.output_tokens * output_cost_per_million + ) / 1_000_000 + return round(cost, 8) + + +def _utc_string(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must be timezone-aware") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py index 012587e..e9b6e45 100644 --- a/src/loc_observatory/cli.py +++ b/src/loc_observatory/cli.py @@ -9,6 +9,13 @@ from collections.abc import Callable, Sequence from pathlib import Path +from loc_observatory.classifier.anthropic import ( + AnthropicGateway, + ClassifierGateway, + ClassifierProviderError, +) +from loc_observatory.classifier.prompt import load_scoring_prompt +from loc_observatory.classifier.service import score_pending_posts from loc_observatory.collector.bluesky import ( BlueskyAccessError, BlueskyGateway, @@ -40,9 +47,11 @@ ) from loc_observatory.warehouse.database import connect_database, migrate_database from loc_observatory.warehouse.posts import SQLitePostRepository +from loc_observatory.warehouse.scoring import SQLiteScoreRepository type BlueskyGatewayFactory = Callable[[AppSettings], BlueskyPostGateway] type BlueskyCollectionGatewayFactory = Callable[[AppSettings], BlueskySearchGateway] +type ClassifierGatewayFactory = Callable[[AppSettings], ClassifierGateway] type RedditGatewayFactory = Callable[[AppSettings], RedditGateway] @@ -66,6 +75,10 @@ def build_parser() -> argparse.ArgumentParser: ) collect_parser.add_argument("--database", type=Path) + classify_parser = commands.add_parser("classify", help="Score one bounded batch of posts") + classify_parser.add_argument("--database", type=Path) + classify_parser.add_argument("--limit", type=int) + reddit_parser = commands.add_parser("reddit", help="Reddit setup and collection commands") reddit_commands = reddit_parser.add_subparsers(dest="reddit_command", required=True) reddit_commands.add_parser( @@ -90,6 +103,7 @@ def main( bluesky_collection_gateway_factory: BlueskyCollectionGatewayFactory = ( BlueskyGateway.from_settings ), + classifier_gateway_factory: ClassifierGatewayFactory = AnthropicGateway.from_settings, reddit_gateway_factory: RedditGatewayFactory = PrawRedditGateway.from_settings, ) -> int: """Run a command and return a process exit status.""" @@ -101,6 +115,9 @@ def main( if arguments.command == "bluesky" and arguments.bluesky_command == "collect": return _collect_bluesky(arguments, bluesky_collection_gateway_factory) + if arguments.command == "classify": + return _classify(arguments, classifier_gateway_factory) + if arguments.command == "reddit" and arguments.reddit_command == "check-access": return _check_reddit_access(arguments, reddit_gateway_factory) @@ -251,6 +268,63 @@ def _collect_bluesky( return 1 if all_queries_failed else 0 +def _classify( + arguments: argparse.Namespace, + gateway_factory: ClassifierGatewayFactory, +) -> int: + """Score one bounded batch and report only aggregate operational data.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=frozenset({"ANTHROPIC_API_KEY"}), + ) + limit = settings.classifier.batch_size if arguments.limit is None else arguments.limit + if limit < 1: + raise ConfigurationError("classification limit must be positive") + database_path = arguments.database or settings.warehouse.path + connection = connect_database(database_path) + try: + migrate_database(connection) + result = score_pending_posts( + gateway_factory(settings), + SQLiteScoreRepository(connection), + load_scoring_prompt(), + limit=limit, + input_cost_per_million=settings.classifier.input_cost_per_million, + output_cost_per_million=settings.classifier.output_cost_per_million, + ) + finally: + connection.close() + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except ClassifierProviderError as error: + print(f"Classifier setup failed: {error}", file=sys.stderr) + return 1 + except (OSError, sqlite3.Error) as error: + print(f"Warehouse write failed ({type(error).__name__})", file=sys.stderr) + return 1 + + status = "partial" if result.failed else "ok" + print( + json.dumps( + { + "attempted": result.attempted, + "cost_usd": result.cost_usd, + "database": str(database_path), + "failed": result.failed, + "input_tokens": result.input_tokens, + "output_tokens": result.output_tokens, + "scored": result.scored, + "status": status, + }, + sort_keys=True, + ) + ) + return 0 + + def _check_reddit_access( arguments: argparse.Namespace, gateway_factory: RedditGatewayFactory, diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py index 336a812..05266e9 100644 --- a/src/loc_observatory/config.py +++ b/src/loc_observatory/config.py @@ -97,7 +97,12 @@ class ClassifierSettings(FrozenSettings): provider: Literal["anthropic"] model: str = Field(min_length=1) + api_base_url: HttpUrl max_output_tokens: int = Field(ge=128, le=8192) + batch_size: int = Field(ge=1, le=100) + timeout_seconds: float = Field(gt=0, le=120) + input_cost_per_million: float = Field(ge=0) + output_cost_per_million: float = Field(ge=0) class RetentionSettings(FrozenSettings): @@ -135,7 +140,7 @@ def validate_hmac_key(cls, value: SecretStr | None) -> SecretStr | None: class AppSettings(FrozenSettings): """Complete validated application configuration.""" - config_version: Literal[2] + config_version: Literal[3] bluesky: BlueskySettings reddit: RedditSettings warehouse: WarehouseSettings diff --git a/src/loc_observatory/warehouse/scoring.py b/src/loc_observatory/warehouse/scoring.py new file mode 100644 index 0000000..81b24a1 --- /dev/null +++ b/src/loc_observatory/warehouse/scoring.py @@ -0,0 +1,88 @@ +"""SQLite persistence for evidence scoring and dead-letter recovery.""" + +from __future__ import annotations + +import sqlite3 + +from loc_observatory.classifier.service import PendingPost, StoredScore + + +class SQLiteScoreRepository: + """Keep valid scores separate from recoverable item failures.""" + + def __init__(self, connection: sqlite3.Connection) -> None: + self._connection = connection + + def pending_posts(self, model_id: str, prompt_hash: str, limit: int) -> tuple[PendingPost, ...]: + """Select a deterministic batch without this model and prompt result.""" + rows = self._connection.execute( + """ + SELECT post.source, post.external_id, post.text + FROM posts_raw AS post + LEFT JOIN scores AS score + ON score.source = post.source + AND score.external_id = post.external_id + AND score.model_id = ? + AND score.prompt_hash = ? + WHERE score.id IS NULL + ORDER BY post.created_at, post.source, post.external_id + LIMIT ? + """, + (model_id, prompt_hash, limit), + ).fetchall() + return tuple(PendingPost(source=row[0], external_id=row[1], text=row[2]) for row in rows) + + def add_score(self, score: StoredScore) -> bool: + """Insert one valid score idempotently and clear its resolved DLQ record.""" + with self._connection: + cursor = self._connection.execute( + """ + INSERT INTO scores ( + source, external_id, score, reasoning, model_id, prompt_hash, + input_tokens, output_tokens, cost_usd, scored_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (source, external_id, model_id, prompt_hash) DO NOTHING + """, + ( + score.source, + score.external_id, + score.score, + score.reasoning, + score.model_id, + score.prompt_hash, + score.input_tokens, + score.output_tokens, + score.cost_usd, + score.scored_at, + ), + ) + if cursor.rowcount: + self._connection.execute( + "DELETE FROM dlq WHERE source = ? AND external_id = ? AND stage = 'classifier'", + (score.source, score.external_id), + ) + return bool(cursor.rowcount) + + def add_failure( + self, + post: PendingPost, + error_code: str, + error_message: str, + at: str, + ) -> None: + """Upsert one failure without retaining provider response content.""" + with self._connection: + self._connection.execute( + """ + INSERT INTO dlq ( + source, external_id, stage, error_code, error_message, + retry_count, last_attempt_at + ) VALUES (?, ?, 'classifier', ?, ?, 1, ?) + ON CONFLICT (source, external_id, stage) DO UPDATE SET + error_code = excluded.error_code, + error_message = excluded.error_message, + retry_count = dlq.retry_count + 1, + last_attempt_at = excluded.last_attempt_at + """, + (post.source, post.external_id, error_code, error_message, at), + ) diff --git a/tests/test_classifier.py b/tests/test_classifier.py new file mode 100644 index 0000000..94a4fd6 --- /dev/null +++ b/tests/test_classifier.py @@ -0,0 +1,344 @@ +"""Offline evidence-classifier contract and failure-isolation tests.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import httpx +import pytest + +from loc_observatory.classifier.anthropic import ( + AnthropicGateway, + ClassifierProviderError, + ProviderResult, +) +from loc_observatory.classifier.prompt import load_scoring_prompt +from loc_observatory.classifier.service import score_pending_posts +from loc_observatory.cli import main +from loc_observatory.models import CollectedPost +from loc_observatory.warehouse.database import connect_database, migrate_database +from loc_observatory.warehouse.posts import SQLitePostRepository +from loc_observatory.warehouse.scoring import SQLiteScoreRepository + +_NOW = datetime(2026, 8, 4, 22, 0, tzinfo=UTC) +_MODEL = "claude-opus-4-6" + + +class FixtureGateway: + """Return configured offline outcomes keyed by exact post text.""" + + model_id = _MODEL + + def __init__(self, outcomes: dict[str, ProviderResult | ClassifierProviderError]) -> None: + self._outcomes = outcomes + self.calls: list[str] = [] + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + assert "default to mundane" in system_prompt.lower() + self.calls.append(post_text) + outcome = self._outcomes[post_text] + if isinstance(outcome, ClassifierProviderError): + raise outcome + return outcome + + +def _provider_result(score: int, *, mundane: bool = False) -> ProviderResult: + return ProviderResult( + content=json.dumps( + { + "score": score, + "score_reasoning": "The evidence supports this conservative score.", + "evidence_type": "description_only" if mundane else "transcript", + "mundane_error": mundane, + } + ), + model_id=_MODEL, + input_tokens=100, + output_tokens=20, + ) + + +def _post(external_id: str, text: str) -> CollectedPost: + return CollectedPost( + source="bluesky", + external_id=external_id, + source_url=f"bluesky://post/{external_id}", + content_cid=f"cid-{external_id}", + record_key=f"key-{external_id}", + author_hmac="a" * 64, + created_at="2026-08-04T21:00:00Z", + text=text, + like_count=0, + reply_count=0, + repost_count=0, + quote_count=0, + query="AI scheming", + collected_at="2026-08-04T21:01:00Z", + collector_version="test", + ) + + +def test_valid_and_mundane_fixtures_store_reproducible_scores(tmp_path: Path) -> None: + """Accept both substantive and mundane valid outcomes with complete provenance.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts( + (_post("1", "valid incident"), _post("2", "ordinary model failure")) + ) + gateway = FixtureGateway( + { + "valid incident": _provider_result(7), + "ordinary model failure": _provider_result(1, mundane=True), + } + ) + prompt = load_scoring_prompt() + + result = score_pending_posts( + gateway, + SQLiteScoreRepository(connection), + prompt, + limit=10, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + rows = connection.execute( + "SELECT score, model_id, prompt_hash, input_tokens, output_tokens, cost_usd " + "FROM scores ORDER BY external_id" + ).fetchall() + finally: + connection.close() + + assert result.scored == 2 + assert result.failed == 0 + assert result.cost_usd == 0.002 + assert rows == [ + (7, _MODEL, prompt.sha256, 100, 20, 0.001), + (1, _MODEL, prompt.sha256, 100, 20, 0.001), + ] + + +def test_malformed_and_failed_fixtures_enter_dlq_without_stopping(tmp_path: Path) -> None: + """Keep invalid model output and provider failures out of the scores table.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts( + ( + _post("1", "malformed"), + _post("2", "provider failure"), + _post("3", "valid after failures"), + ) + ) + gateway = FixtureGateway( + { + "malformed": ProviderResult("{not-json", _MODEL, 10, 5), + "provider failure": ClassifierProviderError("provider request failed (HTTP 503)"), + "valid after failures": _provider_result(4), + } + ) + + result = score_pending_posts( + gateway, + SQLiteScoreRepository(connection), + load_scoring_prompt(), + limit=10, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + scores = connection.execute("SELECT external_id, score FROM scores").fetchall() + failures = connection.execute( + "SELECT external_id, error_code, error_message, retry_count " + "FROM dlq ORDER BY external_id" + ).fetchall() + finally: + connection.close() + + assert result.attempted == 3 + assert result.scored == 1 + assert result.failed == 2 + assert scores == [("3", 4)] + assert failures == [ + ("1", "invalid_output", "classifier output did not match the scoring schema", 1), + ("2", "provider_error", "provider request failed (HTTP 503)", 1), + ] + + +def test_out_of_range_score_never_enters_scores_table(tmp_path: Path) -> None: + """Enforce the 0-9 contract before the database boundary.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts((_post("1", "invalid range"),)) + gateway = FixtureGateway({"invalid range": _provider_result(10)}) + result = score_pending_posts( + gateway, + SQLiteScoreRepository(connection), + load_scoring_prompt(), + limit=1, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + score_count = connection.execute("SELECT count(*) FROM scores").fetchone()[0] + finally: + connection.close() + + assert result.failed == 1 + assert score_count == 0 + + +def test_unexpected_provider_model_enters_dlq(tmp_path: Path) -> None: + """Do not claim reproducibility when the provider served a different model.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts((_post("1", "model mismatch"),)) + response = _provider_result(4) + gateway = FixtureGateway( + { + "model mismatch": ProviderResult( + response.content, + "unexpected-model", + response.input_tokens, + response.output_tokens, + ) + } + ) + result = score_pending_posts( + gateway, + SQLiteScoreRepository(connection), + load_scoring_prompt(), + limit=1, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + failure = connection.execute("SELECT error_code, error_message FROM dlq").fetchone() + finally: + connection.close() + + assert result.failed == 1 + assert failure == ("provider_error", "provider returned an unexpected model") + + +def test_same_model_and_prompt_are_not_scored_twice(tmp_path: Path) -> None: + """Skip current results so retries do not spend money or change established scores.""" + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts((_post("1", "one post"),)) + gateway = FixtureGateway({"one post": _provider_result(5)}) + repository = SQLiteScoreRepository(connection) + prompt = load_scoring_prompt() + first = score_pending_posts( + gateway, + repository, + prompt, + limit=1, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + second = score_pending_posts( + gateway, + repository, + prompt, + limit=1, + input_cost_per_million=5, + output_cost_per_million=25, + now=lambda: _NOW, + ) + finally: + connection.close() + + assert first.scored == 1 + assert second.attempted == 0 + assert gateway.calls == ["one post"] + + +def test_anthropic_adapter_sends_versioned_request_and_reads_usage() -> None: + """Lock the narrow HTTP contract without a live provider call.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/messages" + assert request.headers["anthropic-version"] == "2023-06-01" + assert request.headers["x-api-key"] == "test-secret" + payload = json.loads(request.content) + assert payload["temperature"] == 0 + assert payload["system"] == "system prompt" + assert payload["messages"] == [{"role": "user", "content": "post text"}] + return httpx.Response( + 200, + json={ + "model": _MODEL, + "content": [{"type": "text", "text": _provider_result(3).content}], + "usage": {"input_tokens": 90, "output_tokens": 18}, + }, + ) + + gateway = AnthropicGateway( + api_key="test-secret", + model_id=_MODEL, + api_base_url="https://api.anthropic.com", + max_output_tokens=512, + timeout_seconds=10, + transport=httpx.MockTransport(handler), + ) + + result = gateway.classify("system prompt", "post text") + + assert result.model_id == _MODEL + assert result.input_tokens == 90 + assert result.output_tokens == 18 + + +def test_cli_scores_a_batch_without_exposing_credentials_or_post_text( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wire configuration, prompt loading, scoring, and safe output through one command.""" + database_path = tmp_path / "observatory.db" + connection = connect_database(database_path) + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts((_post("1", "private post text"),)) + finally: + connection.close() + + monkeypatch.setenv("ANTHROPIC_API_KEY", "credential-that-must-not-appear") + gateway = FixtureGateway({"private post text": _provider_result(5)}) + exit_code = main( + [ + "--config", + "config.yaml", + "classify", + "--database", + str(database_path), + "--limit", + "1", + ], + classifier_gateway_factory=lambda _settings: gateway, + ) + + output = capsys.readouterr() + payload = json.loads(output.out) + assert exit_code == 0 + assert payload == { + "attempted": 1, + "cost_usd": 0.001, + "database": str(database_path), + "failed": 0, + "input_tokens": 100, + "output_tokens": 20, + "scored": 1, + "status": "ok", + } + assert "credential-that-must-not-appear" not in output.out + assert "private post text" not in output.out + assert output.err == "" diff --git a/tests/test_config.py b/tests/test_config.py index 6de039f..b56c1ee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,7 +13,7 @@ ) VALID_CONFIG = """\ -config_version: 2 +config_version: 3 bluesky: api_base_url: https://api.bsky.app sample_query: Claude AI @@ -38,7 +38,12 @@ classifier: provider: anthropic model: claude-opus-4-6 + api_base_url: https://api.anthropic.com max_output_tokens: 2048 + batch_size: 10 + timeout_seconds: 60 + input_cost_per_million: 5 + output_cost_per_million: 25 retention: raw_posts_days: 90 artifacts_days: 180 From ccd519e844189767d52e3173c120a8a6f36dc650 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 16:01:47 -0700 Subject: [PATCH 09/29] Make the pilot pipeline observable and usable end to end Add high-recall prescreening, OpenAI structured scoring, persistent JSON run records, and a deterministic static report. The two-stage gate mirrors the CLTR method while keeping item failures isolated and all provider results reproducible. Constraint: OpenAI credits are available; Anthropic is outside the demo scope Rejected: Logit-weighted judging comparison | no label-free metric can establish accuracy Confidence: high Scope-risk: moderate Directive: Keep prescreen and score eligibility tied to exact model and prompt versions Tested: make check (48 tests); empty-database CLI report smoke; uv build Not-tested: Live OpenAI request and non-empty live report --- .env.example | 2 +- Makefile | 5 +- config.yaml | 15 +- src/loc_observatory/classifier/anthropic.py | 139 ---------- src/loc_observatory/classifier/openai.py | 219 ++++++++++++++++ src/loc_observatory/classifier/prescreen.py | 155 +++++++++++ src/loc_observatory/classifier/prompt.py | 5 + .../classifier/prompts/prescreen_v1.md | 27 ++ src/loc_observatory/classifier/service.py | 2 +- src/loc_observatory/cli.py | 243 +++++++++++++++--- src/loc_observatory/config.py | 15 +- src/loc_observatory/observability.py | 191 ++++++++++++++ src/loc_observatory/reporting/static.py | 220 ++++++++++++++++ src/loc_observatory/warehouse/database.py | 2 + .../migrations/0003_add_pipeline_runs.sql | 16 ++ .../migrations/0004_add_prescreening.sql | 22 ++ src/loc_observatory/warehouse/scoring.py | 46 ++++ src/loc_observatory/warehouse/screening.py | 86 +++++++ tests/test_bluesky_collection.py | 13 +- tests/test_classifier.py | 73 ++++-- tests/test_config.py | 19 +- tests/test_observability.py | 82 ++++++ tests/test_prescreen.py | 177 +++++++++++++ tests/test_reporting.py | 145 +++++++++++ tests/test_warehouse.py | 11 +- 25 files changed, 1717 insertions(+), 213 deletions(-) delete mode 100644 src/loc_observatory/classifier/anthropic.py create mode 100644 src/loc_observatory/classifier/openai.py create mode 100644 src/loc_observatory/classifier/prescreen.py create mode 100644 src/loc_observatory/classifier/prompts/prescreen_v1.md create mode 100644 src/loc_observatory/observability.py create mode 100644 src/loc_observatory/reporting/static.py create mode 100644 src/loc_observatory/warehouse/migrations/0003_add_pipeline_runs.sql create mode 100644 src/loc_observatory/warehouse/migrations/0004_add_prescreening.sql create mode 100644 src/loc_observatory/warehouse/screening.py create mode 100644 tests/test_observability.py create mode 100644 tests/test_prescreen.py create mode 100644 tests/test_reporting.py diff --git a/.env.example b/.env.example index 98056d2..e816145 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,4 @@ REDDIT_CLIENT_SECRET= AUTHOR_HMAC_KEY= # LLM classification credential -ANTHROPIC_API_KEY= +OPENAI_API_KEY= diff --git a/Makefile b/Makefile index 70120d5..9fe241b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check format format-check install lint test typecheck +.PHONY: check format format-check install lint report test typecheck install: uv sync --dev --locked @@ -18,4 +18,7 @@ typecheck: test: uv run pytest +report: + uv run observatory report + check: format-check lint typecheck test diff --git a/config.yaml b/config.yaml index 858be10..ec0aa3d 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -config_version: 3 +config_version: 4 bluesky: api_base_url: https://api.bsky.app @@ -27,14 +27,19 @@ warehouse: path: data/observatory.db classifier: - provider: anthropic - model: claude-opus-4-6 - api_base_url: https://api.anthropic.com + provider: openai + prescreen_model: gpt-5.6-luna + prescreen_reasoning_effort: low + prescreen_input_cost_per_million: 1 + prescreen_output_cost_per_million: 6 + model: gpt-5.6-sol + api_base_url: https://api.openai.com max_output_tokens: 2048 + reasoning_effort: medium batch_size: 10 timeout_seconds: 60 input_cost_per_million: 5 - output_cost_per_million: 25 + output_cost_per_million: 30 retention: raw_posts_days: 90 diff --git a/src/loc_observatory/classifier/anthropic.py b/src/loc_observatory/classifier/anthropic.py deleted file mode 100644 index 435eb33..0000000 --- a/src/loc_observatory/classifier/anthropic.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Narrow Anthropic Messages API adapter for evidence scoring.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol - -import httpx -from pydantic import BaseModel, ConfigDict, Field, ValidationError - -from loc_observatory.config import AppSettings - - -class ClassifierProviderError(RuntimeError): - """Safe provider failure that contains no prompt, post text, or credential.""" - - -@dataclass(frozen=True, slots=True) -class ProviderResult: - """Provider output and billable usage for one classification.""" - - content: str - model_id: str - input_tokens: int - output_tokens: int - - -class ClassifierGateway(Protocol): - """Provider-independent classifier boundary.""" - - model_id: str - - def classify(self, system_prompt: str, post_text: str) -> ProviderResult: - """Classify one post and return raw structured-output text plus usage.""" - ... - - -class _TextBlock(BaseModel): - model_config = ConfigDict(extra="ignore", frozen=True) - - type: str - text: str | None = None - - -class _Usage(BaseModel): - model_config = ConfigDict(extra="ignore", frozen=True) - - input_tokens: int = Field(ge=0) - output_tokens: int = Field(ge=0) - - -class _MessageResponse(BaseModel): - model_config = ConfigDict(extra="ignore", frozen=True) - - model: str = Field(min_length=1) - content: tuple[_TextBlock, ...] = Field(min_length=1) - usage: _Usage - - -class AnthropicGateway: - """Synchronous adapter for Anthropic's versioned Messages endpoint.""" - - def __init__( - self, - *, - api_key: str, - model_id: str, - api_base_url: str, - max_output_tokens: int, - timeout_seconds: float, - transport: httpx.BaseTransport | None = None, - ) -> None: - self.model_id = model_id - self._api_key = api_key - self._api_base_url = api_base_url.rstrip("/") - self._max_output_tokens = max_output_tokens - self._timeout_seconds = timeout_seconds - self._transport = transport - - @classmethod - def from_settings(cls, settings: AppSettings) -> AnthropicGateway: - """Build the adapter without exposing the configured API key.""" - secret = settings.secrets.anthropic_api_key - if secret is None: - raise ClassifierProviderError("Anthropic API key is unavailable") - return cls( - api_key=secret.get_secret_value(), - model_id=settings.classifier.model, - api_base_url=str(settings.classifier.api_base_url), - max_output_tokens=settings.classifier.max_output_tokens, - timeout_seconds=settings.classifier.timeout_seconds, - ) - - def classify(self, system_prompt: str, post_text: str) -> ProviderResult: - """Send one post to the Messages API and validate its response envelope.""" - try: - with httpx.Client( - base_url=self._api_base_url, - timeout=self._timeout_seconds, - transport=self._transport, - ) as client: - response = client.post( - "/v1/messages", - headers={ - "anthropic-version": "2023-06-01", - "x-api-key": self._api_key, - }, - json={ - "model": self.model_id, - "max_tokens": self._max_output_tokens, - "temperature": 0, - "system": system_prompt, - "messages": [{"role": "user", "content": post_text}], - }, - ) - response.raise_for_status() - payload = _MessageResponse.model_validate(response.json()) - except httpx.HTTPStatusError as error: - raise ClassifierProviderError( - f"provider request failed (HTTP {error.response.status_code})" - ) from None - except httpx.RequestError as error: - raise ClassifierProviderError( - f"provider request failed ({type(error).__name__})" - ) from None - except (ValidationError, ValueError): - raise ClassifierProviderError("provider returned an invalid response") from None - - text_blocks = tuple( - block.text for block in payload.content if block.type == "text" and block.text - ) - if len(text_blocks) != 1: - raise ClassifierProviderError("provider returned an invalid text response") - return ProviderResult( - content=text_blocks[0], - model_id=payload.model, - input_tokens=payload.usage.input_tokens, - output_tokens=payload.usage.output_tokens, - ) diff --git a/src/loc_observatory/classifier/openai.py b/src/loc_observatory/classifier/openai.py new file mode 100644 index 0000000..fcfd0ba --- /dev/null +++ b/src/loc_observatory/classifier/openai.py @@ -0,0 +1,219 @@ +"""Narrow OpenAI Responses API adapter for evidence scoring.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Protocol + +import httpx +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from loc_observatory.config import AppSettings + + +class ClassifierProviderError(RuntimeError): + """Safe provider failure that contains no prompt, post text, or credential.""" + + +@dataclass(frozen=True, slots=True) +class ProviderResult: + """Provider output and billable usage for one classification.""" + + content: str + model_id: str + input_tokens: int + output_tokens: int + + +class ClassifierGateway(Protocol): + """Provider-independent classifier boundary.""" + + model_id: str + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + """Classify one post and return structured-output text plus usage.""" + ... + + +class _ContentBlock(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + type: str + text: str | None = None + + +class _OutputItem(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + type: str + content: tuple[_ContentBlock, ...] = () + + +class _Usage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + input_tokens: int = Field(ge=0) + output_tokens: int = Field(ge=0) + + +class _Response(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + status: str + model: str = Field(min_length=1) + output: tuple[_OutputItem, ...] + usage: _Usage + + +class OpenAIGateway: + """Synchronous adapter for OpenAI structured Responses.""" + + def __init__( + self, + *, + api_key: str, + model_id: str, + api_base_url: str, + max_output_tokens: int, + reasoning_effort: str, + response_kind: Literal["prescreen", "score"] = "score", + timeout_seconds: float, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.model_id = model_id + self._api_key = api_key + self._api_base_url = api_base_url.rstrip("/") + self._max_output_tokens = max_output_tokens + self._reasoning_effort = reasoning_effort + self._response_kind = response_kind + self._timeout_seconds = timeout_seconds + self._transport = transport + + @classmethod + def from_settings(cls, settings: AppSettings) -> OpenAIGateway: + """Build the adapter without exposing the configured API key.""" + secret = settings.secrets.openai_api_key + if secret is None: + raise ClassifierProviderError("OpenAI API key is unavailable") + return cls( + api_key=secret.get_secret_value(), + model_id=settings.classifier.model, + api_base_url=str(settings.classifier.api_base_url), + max_output_tokens=settings.classifier.max_output_tokens, + reasoning_effort=settings.classifier.reasoning_effort, + timeout_seconds=settings.classifier.timeout_seconds, + ) + + @classmethod + def for_prescreen(cls, settings: AppSettings) -> OpenAIGateway: + """Build the lower-cost high-recall prescreen adapter.""" + secret = settings.secrets.openai_api_key + if secret is None: + raise ClassifierProviderError("OpenAI API key is unavailable") + return cls( + api_key=secret.get_secret_value(), + model_id=settings.classifier.prescreen_model, + api_base_url=str(settings.classifier.api_base_url), + max_output_tokens=settings.classifier.max_output_tokens, + reasoning_effort=settings.classifier.prescreen_reasoning_effort, + response_kind="prescreen", + timeout_seconds=settings.classifier.timeout_seconds, + ) + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + """Send one post to the Responses API and validate its response envelope.""" + try: + with httpx.Client( + base_url=self._api_base_url, + timeout=self._timeout_seconds, + transport=self._transport, + ) as client: + response = client.post( + "/v1/responses", + headers={"Authorization": f"Bearer {self._api_key}"}, + json={ + "model": self.model_id, + "input": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": post_text}, + ], + "max_output_tokens": self._max_output_tokens, + "reasoning": {"effort": self._reasoning_effort}, + "store": False, + "text": {"format": _response_format(self._response_kind)}, + }, + ) + response.raise_for_status() + payload = _Response.model_validate(response.json()) + except httpx.HTTPStatusError as error: + raise ClassifierProviderError( + f"provider request failed (HTTP {error.response.status_code})" + ) from None + except httpx.RequestError as error: + raise ClassifierProviderError( + f"provider request failed ({type(error).__name__})" + ) from None + except (ValidationError, ValueError): + raise ClassifierProviderError("provider returned an invalid response") from None + + if payload.status != "completed": + raise ClassifierProviderError("provider did not complete the response") + text_blocks = tuple( + block.text + for item in payload.output + if item.type == "message" + for block in item.content + if block.type == "output_text" and block.text + ) + if len(text_blocks) != 1: + raise ClassifierProviderError("provider returned an invalid text response") + return ProviderResult( + content=text_blocks[0], + model_id=payload.model, + input_tokens=payload.usage.input_tokens, + output_tokens=payload.usage.output_tokens, + ) + + +def _response_format(kind: Literal["prescreen", "score"]) -> dict[str, object]: + """Return the strict provider schema for one pipeline stage.""" + if kind == "prescreen": + properties: dict[str, object] = { + "risk_level": { + "type": "string", + "enum": ["none", "low", "medium", "high"], + }, + "reasoning": {"type": "string"}, + } + required = ["risk_level", "reasoning"] + name = "evidence_prescreen" + else: + properties = { + "score": {"type": "integer", "minimum": 0, "maximum": 9}, + "score_reasoning": {"type": "string"}, + "evidence_type": { + "type": "string", + "enum": [ + "transcript", + "screenshot_no_transcript", + "chat_share_link", + "description_only", + "other", + "none", + ], + }, + "mundane_error": {"type": "boolean"}, + } + required = ["score", "score_reasoning", "evidence_type", "mundane_error"] + name = "evidence_score" + return { + "type": "json_schema", + "name": name, + "strict": True, + "schema": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + } diff --git a/src/loc_observatory/classifier/prescreen.py b/src/loc_observatory/classifier/prescreen.py new file mode 100644 index 0000000..0b5cc29 --- /dev/null +++ b/src/loc_observatory/classifier/prescreen.py @@ -0,0 +1,155 @@ +"""High-recall prescreening before detailed evidence scoring.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from loc_observatory.classifier.openai import ClassifierGateway, ClassifierProviderError +from loc_observatory.classifier.prompt import PromptArtifact +from loc_observatory.classifier.service import PendingPost + + +@dataclass(frozen=True, slots=True) +class StoredScreening: + """Validated prescreen result with reproduction metadata.""" + + source: str + external_id: str + risk_level: Literal["none", "low", "medium", "high"] + reasoning: str + model_id: str + prompt_hash: str + input_tokens: int + output_tokens: int + cost_usd: float + screened_at: str + + +@dataclass(frozen=True, slots=True) +class PrescreenResult: + """Aggregate outcome of one bounded prescreen run.""" + + attempted: int + screened: int + high_risk: int + failed: int + input_tokens: int + output_tokens: int + cost_usd: float + + +class PrescreenRepository(Protocol): + """Persistence boundary for prescreen results and isolated failures.""" + + def pending_posts(self, model_id: str, prompt_hash: str, limit: int) -> Sequence[PendingPost]: + """Return posts not yet screened by this model and prompt.""" + ... + + def add_screening(self, screening: StoredScreening) -> bool: + """Store one idempotent prescreen result.""" + ... + + def add_failure(self, post: PendingPost, error_code: str, error_message: str, at: str) -> None: + """Record one safe, replayable prescreen failure.""" + ... + + +class _Screening(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + risk_level: Literal["none", "low", "medium", "high"] + reasoning: str = Field(min_length=1, max_length=1000) + + +def prescreen_pending_posts( + gateway: ClassifierGateway, + repository: PrescreenRepository, + prompt: PromptArtifact, + *, + limit: int, + input_cost_per_million: float, + output_cost_per_million: float, + now: Callable[[], datetime] = lambda: datetime.now(UTC), +) -> PrescreenResult: + """Prescreen a bounded batch without allowing one bad item to stop the run.""" + if limit < 1: + raise ValueError("prescreen limit must be positive") + posts = repository.pending_posts(gateway.model_id, prompt.sha256, limit) + screened = 0 + high_risk = 0 + failed = 0 + input_tokens = 0 + output_tokens = 0 + total_cost = 0.0 + + for post in posts: + attempted_at = _utc_string(now()) + try: + provider_result = gateway.classify(prompt.text, post.text) + if provider_result.model_id != gateway.model_id: + raise ClassifierProviderError("provider returned an unexpected model") + result = _Screening.model_validate(json.loads(provider_result.content)) + cost = round( + ( + provider_result.input_tokens * input_cost_per_million + + provider_result.output_tokens * output_cost_per_million + ) + / 1_000_000, + 8, + ) + inserted = repository.add_screening( + StoredScreening( + source=post.source, + external_id=post.external_id, + risk_level=result.risk_level, + reasoning=result.reasoning, + model_id=provider_result.model_id, + prompt_hash=prompt.sha256, + input_tokens=provider_result.input_tokens, + output_tokens=provider_result.output_tokens, + cost_usd=cost, + screened_at=attempted_at, + ) + ) + except ClassifierProviderError as error: + failed += 1 + repository.add_failure(post, "provider_error", str(error), attempted_at) + continue + except (json.JSONDecodeError, ValidationError): + failed += 1 + repository.add_failure( + post, + "invalid_output", + "classifier output did not match the prescreen schema", + attempted_at, + ) + continue + + if inserted: + screened += 1 + high_risk += int(result.risk_level == "high") + input_tokens += provider_result.input_tokens + output_tokens += provider_result.output_tokens + total_cost += cost + + return PrescreenResult( + attempted=len(posts), + screened=screened, + high_risk=high_risk, + failed=failed, + input_tokens=input_tokens, + output_tokens=output_tokens, + cost_usd=round(total_cost, 8), + ) + + +def _utc_string(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must be timezone-aware") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/loc_observatory/classifier/prompt.py b/src/loc_observatory/classifier/prompt.py index aed1f9f..3f8ccae 100644 --- a/src/loc_observatory/classifier/prompt.py +++ b/src/loc_observatory/classifier/prompt.py @@ -26,3 +26,8 @@ def load_scoring_prompt(name: str = "scoring_v1.md") -> PromptArtifact: text=prompt_bytes.decode("utf-8"), sha256=hashlib.sha256(prompt_bytes).hexdigest(), ) + + +def load_prescreen_prompt(name: str = "prescreen_v1.md") -> PromptArtifact: + """Load the packaged high-recall prescreen prompt.""" + return load_scoring_prompt(name) diff --git a/src/loc_observatory/classifier/prompts/prescreen_v1.md b/src/loc_observatory/classifier/prompts/prescreen_v1.md new file mode 100644 index 0000000..1aaea78 --- /dev/null +++ b/src/loc_observatory/classifier/prompts/prescreen_v1.md @@ -0,0 +1,27 @@ +You are conducting a high-recall first review of one public post about AI behaviour. + +Decide how likely the post is to contain evidence of scheming-related AI behaviour. Scheming means +an AI system pursuing goals that conflict with its user's, developer's, or deployer's intentions or +interests, especially when it conceals actions, objectives, or capabilities. + +Use these four levels: + +- none: no relevant report, or clearly a joke, promotion, hypothetical discussion, roleplay, or + unrelated content. +- low: mentions unexpected AI behaviour, but it is probably an ordinary error, hallucination, + refusal, jailbreak, deliberate misuse, or unsupported claim. +- medium: contains a specific scheming-related claim or some evidence, but ordinary explanations + remain at least as plausible. +- high: plausibly contains evidence of misalignment, covert behaviour, or an AI taking consequential + action against a user's intent. It deserves detailed scoring even if the evidence is incomplete. + +This stage should favour recall. It is far better to include a borderline relevant post for detailed +scoring than to discard a potentially important report. Do not raise a clearly mundane failure only +because the author sounds alarmed. + +Return only one JSON object with exactly these fields: + +{ + "risk_level": "none|low|medium|high", + "reasoning": "One or two concise sentences grounded in the post." +} diff --git a/src/loc_observatory/classifier/service.py b/src/loc_observatory/classifier/service.py index eba2d84..48687f4 100644 --- a/src/loc_observatory/classifier/service.py +++ b/src/loc_observatory/classifier/service.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError -from loc_observatory.classifier.anthropic import ( +from loc_observatory.classifier.openai import ( ClassifierGateway, ClassifierProviderError, ProviderResult, diff --git a/src/loc_observatory/cli.py b/src/loc_observatory/cli.py index e9b6e45..786d36d 100644 --- a/src/loc_observatory/cli.py +++ b/src/loc_observatory/cli.py @@ -9,12 +9,13 @@ from collections.abc import Callable, Sequence from pathlib import Path -from loc_observatory.classifier.anthropic import ( - AnthropicGateway, +from loc_observatory.classifier.openai import ( ClassifierGateway, ClassifierProviderError, + OpenAIGateway, ) -from loc_observatory.classifier.prompt import load_scoring_prompt +from loc_observatory.classifier.prescreen import prescreen_pending_posts +from loc_observatory.classifier.prompt import load_prescreen_prompt, load_scoring_prompt from loc_observatory.classifier.service import score_pending_posts from loc_observatory.collector.bluesky import ( BlueskyAccessError, @@ -45,9 +46,12 @@ ConfigurationError, load_settings, ) +from loc_observatory.observability import EventLogger, JsonEventLogger, ObservedRun +from loc_observatory.reporting.static import write_report from loc_observatory.warehouse.database import connect_database, migrate_database from loc_observatory.warehouse.posts import SQLitePostRepository -from loc_observatory.warehouse.scoring import SQLiteScoreRepository +from loc_observatory.warehouse.scoring import SQLiteScreenedScoreRepository +from loc_observatory.warehouse.screening import SQLitePrescreenRepository type BlueskyGatewayFactory = Callable[[AppSettings], BlueskyPostGateway] type BlueskyCollectionGatewayFactory = Callable[[AppSettings], BlueskySearchGateway] @@ -79,6 +83,16 @@ def build_parser() -> argparse.ArgumentParser: classify_parser.add_argument("--database", type=Path) classify_parser.add_argument("--limit", type=int) + prescreen_parser = commands.add_parser( + "prescreen", help="Run the high-recall screen on one bounded batch" + ) + prescreen_parser.add_argument("--database", type=Path) + prescreen_parser.add_argument("--limit", type=int) + + report_parser = commands.add_parser("report", help="Generate a self-contained HTML report") + report_parser.add_argument("--database", type=Path) + report_parser.add_argument("--output", type=Path, default=Path("reports/observatory.html")) + reddit_parser = commands.add_parser("reddit", help="Reddit setup and collection commands") reddit_commands = reddit_parser.add_subparsers(dest="reddit_command", required=True) reddit_commands.add_parser( @@ -103,20 +117,29 @@ def main( bluesky_collection_gateway_factory: BlueskyCollectionGatewayFactory = ( BlueskyGateway.from_settings ), - classifier_gateway_factory: ClassifierGatewayFactory = AnthropicGateway.from_settings, + classifier_gateway_factory: ClassifierGatewayFactory = OpenAIGateway.from_settings, + prescreen_gateway_factory: ClassifierGatewayFactory = OpenAIGateway.for_prescreen, reddit_gateway_factory: RedditGatewayFactory = PrawRedditGateway.from_settings, + event_logger: EventLogger | None = None, ) -> int: """Run a command and return a process exit status.""" arguments = build_parser().parse_args(argv) + logger = event_logger or JsonEventLogger() if arguments.command == "bluesky" and arguments.bluesky_command == "check-access": return _check_bluesky_access(arguments, bluesky_gateway_factory) if arguments.command == "bluesky" and arguments.bluesky_command == "collect": - return _collect_bluesky(arguments, bluesky_collection_gateway_factory) + return _collect_bluesky(arguments, bluesky_collection_gateway_factory, logger) if arguments.command == "classify": - return _classify(arguments, classifier_gateway_factory) + return _classify(arguments, classifier_gateway_factory, logger) + + if arguments.command == "prescreen": + return _prescreen(arguments, prescreen_gateway_factory, logger) + + if arguments.command == "report": + return _report(arguments) if arguments.command == "reddit" and arguments.reddit_command == "check-access": return _check_reddit_access(arguments, reddit_gateway_factory) @@ -204,6 +227,7 @@ def _check_bluesky_access( def _collect_bluesky( arguments: argparse.Namespace, gateway_factory: BlueskyCollectionGatewayFactory, + logger: EventLogger, ) -> int: """Collect one configured, privacy-minimised Bluesky batch.""" try: @@ -220,21 +244,47 @@ def _collect_bluesky( connection = connect_database(database_path) try: migrate_database(connection) - queries = build_search_queries( - ai_terms=settings.search.ai_terms, - scheming_terms=settings.search.scheming_terms, - reaction_terms=settings.search.reaction_terms, - limit=settings.bluesky.max_queries, - ) - result = collect_bluesky_posts( - gateway_factory(settings), - SQLitePostRepository(connection), - Pseudonymizer(author_key.get_secret_value().encode()), - queries=queries, - page_size=settings.bluesky.page_size, - max_pages_per_query=settings.bluesky.max_pages_per_query, - collector_version="bluesky-v1", - ) + with ObservedRun(connection, logger, "collect") as run: + queries = build_search_queries( + ai_terms=settings.search.ai_terms, + scheming_terms=settings.search.scheming_terms, + reaction_terms=settings.search.reaction_terms, + limit=settings.bluesky.max_queries, + ) + result = collect_bluesky_posts( + gateway_factory(settings), + SQLitePostRepository(connection), + Pseudonymizer(author_key.get_secret_value().encode()), + queries=queries, + page_size=settings.bluesky.page_size, + max_pages_per_query=settings.bluesky.max_pages_per_query, + collector_version="bluesky-v1", + ) + all_queries_failed = result.query_failures == result.queries_run + status = ( + "failed" if all_queries_failed else "partial" if result.query_failures else "ok" + ) + details = { + "duplicates": result.duplicates, + "pages_fetched": result.pages_fetched, + "queries_run": result.queries_run, + } + if all_queries_failed: + run.fail( + "all_queries_failed", + items_seen=result.posts_seen, + items_succeeded=result.posts_inserted, + items_failed=result.query_failures, + details=details, + ) + else: + run.complete( + status, + items_seen=result.posts_seen, + items_succeeded=result.posts_inserted, + items_failed=result.query_failures, + details=details, + ) finally: connection.close() except ConfigurationError as error: @@ -247,8 +297,6 @@ def _collect_bluesky( print(f"Warehouse write failed ({type(error).__name__})", file=sys.stderr) return 1 - all_queries_failed = result.query_failures == result.queries_run - status = "failed" if all_queries_failed else "partial" if result.query_failures else "ok" print( json.dumps( { @@ -271,13 +319,14 @@ def _collect_bluesky( def _classify( arguments: argparse.Namespace, gateway_factory: ClassifierGatewayFactory, + logger: EventLogger, ) -> int: """Score one bounded batch and report only aggregate operational data.""" try: settings = load_settings( arguments.config, env_path=arguments.env_file, - required_secrets=frozenset({"ANTHROPIC_API_KEY"}), + required_secrets=frozenset({"OPENAI_API_KEY"}), ) limit = settings.classifier.batch_size if arguments.limit is None else arguments.limit if limit < 1: @@ -286,14 +335,32 @@ def _classify( connection = connect_database(database_path) try: migrate_database(connection) - result = score_pending_posts( - gateway_factory(settings), - SQLiteScoreRepository(connection), - load_scoring_prompt(), - limit=limit, - input_cost_per_million=settings.classifier.input_cost_per_million, - output_cost_per_million=settings.classifier.output_cost_per_million, - ) + with ObservedRun(connection, logger, "classify") as run: + prescreen_prompt = load_prescreen_prompt() + result = score_pending_posts( + gateway_factory(settings), + SQLiteScreenedScoreRepository( + connection, + prescreen_model_id=settings.classifier.prescreen_model, + prescreen_prompt_hash=prescreen_prompt.sha256, + ), + load_scoring_prompt(), + limit=limit, + input_cost_per_million=settings.classifier.input_cost_per_million, + output_cost_per_million=settings.classifier.output_cost_per_million, + ) + status = "partial" if result.failed else "ok" + run.complete( + status, + items_seen=result.attempted, + items_succeeded=result.scored, + items_failed=result.failed, + cost_usd=result.cost_usd, + details={ + "input_tokens": result.input_tokens, + "output_tokens": result.output_tokens, + }, + ) finally: connection.close() except ConfigurationError as error: @@ -306,7 +373,6 @@ def _classify( print(f"Warehouse write failed ({type(error).__name__})", file=sys.stderr) return 1 - status = "partial" if result.failed else "ok" print( json.dumps( { @@ -325,6 +391,115 @@ def _classify( return 0 +def _prescreen( + arguments: argparse.Namespace, + gateway_factory: ClassifierGatewayFactory, + logger: EventLogger, +) -> int: + """Run the pilot-style high-recall screen on one bounded batch.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=frozenset({"OPENAI_API_KEY"}), + ) + limit = settings.classifier.batch_size if arguments.limit is None else arguments.limit + if limit < 1: + raise ConfigurationError("prescreen limit must be positive") + database_path = arguments.database or settings.warehouse.path + connection = connect_database(database_path) + try: + migrate_database(connection) + with ObservedRun(connection, logger, "prescreen") as run: + result = prescreen_pending_posts( + gateway_factory(settings), + SQLitePrescreenRepository(connection), + load_prescreen_prompt(), + limit=limit, + input_cost_per_million=(settings.classifier.prescreen_input_cost_per_million), + output_cost_per_million=(settings.classifier.prescreen_output_cost_per_million), + ) + status = "partial" if result.failed else "ok" + run.complete( + status, + items_seen=result.attempted, + items_succeeded=result.screened, + items_failed=result.failed, + cost_usd=result.cost_usd, + details={ + "high_risk": result.high_risk, + "input_tokens": result.input_tokens, + "output_tokens": result.output_tokens, + }, + ) + finally: + connection.close() + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except ClassifierProviderError as error: + print(f"Prescreen setup failed: {error}", file=sys.stderr) + return 1 + except (OSError, sqlite3.Error) as error: + print(f"Warehouse write failed ({type(error).__name__})", file=sys.stderr) + return 1 + + print( + json.dumps( + { + "attempted": result.attempted, + "cost_usd": result.cost_usd, + "database": str(database_path), + "failed": result.failed, + "high_risk": result.high_risk, + "input_tokens": result.input_tokens, + "output_tokens": result.output_tokens, + "screened": result.screened, + "status": status, + }, + sort_keys=True, + ) + ) + return 0 + + +def _report(arguments: argparse.Namespace) -> int: + """Generate a deterministic static report from one chosen database.""" + try: + settings = load_settings( + arguments.config, + env_path=arguments.env_file, + required_secrets=frozenset(), + ) + database_path = arguments.database or settings.warehouse.path + connection = connect_database(database_path) + try: + migrate_database(connection) + data = write_report(connection, arguments.output) + finally: + connection.close() + except ConfigurationError as error: + print(f"Configuration error: {error}", file=sys.stderr) + return 2 + except (OSError, sqlite3.Error) as error: + print(f"Report generation failed ({type(error).__name__})", file=sys.stderr) + return 1 + + print( + json.dumps( + { + "credible_reports": data.credible_reports, + "output": str(arguments.output), + "scored_reports": data.scored_reports, + "status": "ok", + "total_reports": data.total_reports, + }, + sort_keys=True, + ) + ) + return 0 + + def _check_reddit_access( arguments: argparse.Namespace, gateway_factory: RedditGatewayFactory, diff --git a/src/loc_observatory/config.py b/src/loc_observatory/config.py index 05266e9..d08a75f 100644 --- a/src/loc_observatory/config.py +++ b/src/loc_observatory/config.py @@ -21,7 +21,7 @@ from pydantic.functional_validators import model_validator SECRET_FIELDS = { - "ANTHROPIC_API_KEY": "anthropic_api_key", + "OPENAI_API_KEY": "openai_api_key", "AUTHOR_HMAC_KEY": "author_hmac_key", "REDDIT_CLIENT_ID": "reddit_client_id", "REDDIT_CLIENT_SECRET": "reddit_client_secret", @@ -29,7 +29,7 @@ ALL_SECRET_NAMES = frozenset(SECRET_FIELDS) AUTHOR_SECRET_NAMES = frozenset({"AUTHOR_HMAC_KEY"}) -CORE_SECRET_NAMES = frozenset({"ANTHROPIC_API_KEY", "AUTHOR_HMAC_KEY"}) +CORE_SECRET_NAMES = frozenset({"OPENAI_API_KEY", "AUTHOR_HMAC_KEY"}) REDDIT_SECRET_NAMES = frozenset({"REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"}) @@ -95,10 +95,15 @@ class WarehouseSettings(FrozenSettings): class ClassifierSettings(FrozenSettings): """Non-secret LLM classification settings.""" - provider: Literal["anthropic"] + provider: Literal["openai"] + prescreen_model: str = Field(min_length=1) + prescreen_reasoning_effort: Literal["none", "low", "medium", "high"] + prescreen_input_cost_per_million: float = Field(ge=0) + prescreen_output_cost_per_million: float = Field(ge=0) model: str = Field(min_length=1) api_base_url: HttpUrl max_output_tokens: int = Field(ge=128, le=8192) + reasoning_effort: Literal["none", "low", "medium", "high", "xhigh"] batch_size: int = Field(ge=1, le=100) timeout_seconds: float = Field(gt=0, le=120) input_cost_per_million: float = Field(ge=0) @@ -123,7 +128,7 @@ class SearchSettings(FrozenSettings): class SecretSettings(FrozenSettings): """Credentials loaded from the process environment or an ignored `.env` file.""" - anthropic_api_key: SecretStr | None = None + openai_api_key: SecretStr | None = None author_hmac_key: SecretStr | None = None reddit_client_id: SecretStr | None = None reddit_client_secret: SecretStr | None = None @@ -140,7 +145,7 @@ def validate_hmac_key(cls, value: SecretStr | None) -> SecretStr | None: class AppSettings(FrozenSettings): """Complete validated application configuration.""" - config_version: Literal[3] + config_version: Literal[4] bluesky: BlueskySettings reddit: RedditSettings warehouse: WarehouseSettings diff --git a/src/loc_observatory/observability.py b/src/loc_observatory/observability.py new file mode 100644 index 0000000..a8b3ba9 --- /dev/null +++ b/src/loc_observatory/observability.py @@ -0,0 +1,191 @@ +"""Structured, privacy-safe lifecycle records for pipeline operations.""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +from time import monotonic +from types import TracebackType +from typing import Literal, Protocol, TextIO +from uuid import uuid4 + + +class EventLogger(Protocol): + """Stable structured-event boundary.""" + + def emit(self, event: str, fields: Mapping[str, object]) -> None: + """Emit one event without adding sensitive fields.""" + ... + + +class JsonEventLogger: + """Write one compact JSON object per line.""" + + def __init__(self, stream: TextIO | None = None) -> None: + self._stream = stream or sys.stderr + + def emit(self, event: str, fields: Mapping[str, object]) -> None: + """Write a stable event name and caller-approved fields.""" + payload = {"event": event, **fields} + print(json.dumps(payload, sort_keys=True, separators=(",", ":")), file=self._stream) + + +class ObservedRun: + """Persist and emit a pipeline operation's start and terminal state.""" + + def __init__( + self, + connection: sqlite3.Connection, + logger: EventLogger, + operation: str, + *, + run_id: str | None = None, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + timer: Callable[[], float] = monotonic, + ) -> None: + self.run_id = run_id or str(uuid4()) + self._connection = connection + self._logger = logger + self._operation = operation + self._now = now + self._timer = timer + self._started_at = _utc_string(now()) + self._started_timer = timer() + self._finished = False + + def __enter__(self) -> ObservedRun: + with self._connection: + self._connection.execute( + "INSERT INTO pipeline_runs (run_id, operation, status, started_at) " + "VALUES (?, ?, 'running', ?)", + (self.run_id, self._operation, self._started_at), + ) + self._logger.emit( + "pipeline.run.started", + {"operation": self._operation, "run_id": self.run_id, "status": "running"}, + ) + return self + + def complete( + self, + status: str, + *, + items_seen: int, + items_succeeded: int, + items_failed: int, + cost_usd: float = 0, + details: Mapping[str, object] | None = None, + ) -> None: + """Record one successful or partial terminal outcome.""" + if status not in {"ok", "partial"}: + raise ValueError("completed run status must be ok or partial") + self._finish( + status, + items_seen=items_seen, + items_succeeded=items_succeeded, + items_failed=items_failed, + cost_usd=cost_usd, + error_code=None, + details=details or {}, + ) + + def fail( + self, + error_code: str, + *, + items_seen: int, + items_succeeded: int, + items_failed: int, + details: Mapping[str, object] | None = None, + ) -> None: + """Record an expected terminal failure without raising an exception.""" + self._finish( + "failed", + items_seen=items_seen, + items_succeeded=items_succeeded, + items_failed=items_failed, + cost_usd=0, + error_code=error_code, + details=details or {}, + ) + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: + del traceback + if exception_type is not None and not self._finished: + self._finish( + "failed", + items_seen=0, + items_succeeded=0, + items_failed=1, + cost_usd=0, + error_code=exception_type.__name__, + details={}, + ) + return False + + def _finish( + self, + status: str, + *, + items_seen: int, + items_succeeded: int, + items_failed: int, + cost_usd: float, + error_code: str | None, + details: Mapping[str, object], + ) -> None: + if self._finished: + raise RuntimeError("pipeline run already finished") + duration_ms = max(0, round((self._timer() - self._started_timer) * 1000)) + finished_at = _utc_string(self._now()) + details_json = json.dumps(details, sort_keys=True, separators=(",", ":")) + with self._connection: + self._connection.execute( + """ + UPDATE pipeline_runs + SET status = ?, finished_at = ?, duration_ms = ?, items_seen = ?, + items_succeeded = ?, items_failed = ?, cost_usd = ?, error_code = ?, + details_json = ? + WHERE run_id = ? AND status = 'running' + """, + ( + status, + finished_at, + duration_ms, + items_seen, + items_succeeded, + items_failed, + cost_usd, + error_code, + details_json, + self.run_id, + ), + ) + fields: dict[str, object] = { + "cost_usd": cost_usd, + "duration_ms": duration_ms, + "items_failed": items_failed, + "items_seen": items_seen, + "items_succeeded": items_succeeded, + "operation": self._operation, + "run_id": self.run_id, + "status": status, + } + if error_code is not None: + fields["error_code"] = error_code + self._logger.emit("pipeline.run.finished", fields) + self._finished = True + + +def _utc_string(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must be timezone-aware") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/loc_observatory/reporting/static.py b/src/loc_observatory/reporting/static.py new file mode 100644 index 0000000..c60fc15 --- /dev/null +++ b/src/loc_observatory/reporting/static.py @@ -0,0 +1,220 @@ +"""Deterministic, self-contained HTML reporting from the local warehouse.""" + +from __future__ import annotations + +import html +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class DailyCount: + date: str + reports: int + credible: int + + +@dataclass(frozen=True, slots=True) +class HighScore: + created_at: str + score: int + reasoning: str + source_reference: str + + +@dataclass(frozen=True, slots=True) +class ReportData: + daily: tuple[DailyCount, ...] + high_scores: tuple[HighScore, ...] + total_reports: int + scored_reports: int + credible_reports: int + run_count: int + item_failures: int + classification_cost_usd: float + + +def load_report_data(connection: sqlite3.Connection, *, high_score_limit: int = 20) -> ReportData: + """Read one consistent report view using the latest score for each post.""" + latest_scores = """ + WITH ranked_scores AS ( + SELECT source, external_id, score, reasoning, scored_at, id, + ROW_NUMBER() OVER ( + PARTITION BY source, external_id + ORDER BY scored_at DESC, id DESC + ) AS rank + FROM scores + ) + """ + daily_rows = connection.execute( + latest_scores + + """ + SELECT substr(post.created_at, 1, 10) AS report_date, + count(*) AS reports, + coalesce(sum(CASE WHEN score.score >= 5 THEN 1 ELSE 0 END), 0) AS credible + FROM posts_raw AS post + LEFT JOIN ranked_scores AS score + ON score.source = post.source + AND score.external_id = post.external_id + AND score.rank = 1 + GROUP BY report_date + ORDER BY report_date + """ + ).fetchall() + high_score_rows = connection.execute( + latest_scores + + """ + SELECT post.created_at, score.score, score.reasoning, post.source_url + FROM posts_raw AS post + JOIN ranked_scores AS score + ON score.source = post.source + AND score.external_id = post.external_id + AND score.rank = 1 + WHERE score.score >= 5 + ORDER BY score.score DESC, post.created_at DESC, post.external_id + LIMIT ? + """, + (high_score_limit,), + ).fetchall() + totals = connection.execute( + latest_scores + + """ + SELECT count(*) AS total, + count(score.score) AS scored, + coalesce(sum(CASE WHEN score.score >= 5 THEN 1 ELSE 0 END), 0) AS credible + FROM posts_raw AS post + LEFT JOIN ranked_scores AS score + ON score.source = post.source + AND score.external_id = post.external_id + AND score.rank = 1 + """ + ).fetchone() + run_totals = connection.execute( + """ + SELECT count(*), + coalesce(sum(items_failed), 0), + coalesce( + sum(CASE WHEN operation IN ('prescreen', 'classify') THEN cost_usd ELSE 0 END), + 0 + ) + FROM pipeline_runs + WHERE status != 'running' + """ + ).fetchone() + return ReportData( + daily=tuple(DailyCount(str(row[0]), int(row[1]), int(row[2])) for row in daily_rows), + high_scores=tuple( + HighScore(str(row[0]), int(row[1]), str(row[2]), str(row[3])) for row in high_score_rows + ), + total_reports=int(totals[0]), + scored_reports=int(totals[1]), + credible_reports=int(totals[2]), + run_count=int(run_totals[0]), + item_failures=int(run_totals[1]), + classification_cost_usd=float(run_totals[2]), + ) + + +def render_report(data: ReportData) -> str: + """Render deterministic HTML with no external scripts, fonts, or stylesheets.""" + chart = _render_chart(data.daily) + rows = ( + "".join( + "" + f"{html.escape(item.created_at[:10])}" + f"{item.score}" + f"{html.escape(item.reasoning)}" + f"{html.escape(item.source_reference)}" + "" + for item in data.high_scores + ) + or 'No reports currently score 5 or above.' + ) + return f""" + + + +Loss of Control Observatory + +
+

Loss of Control Observatory

+

Public Bluesky reports prioritised for review using a conservative evidence +rubric adapted from CLTR's pilot.

+
+
{data.total_reports}Total reports
+
{data.scored_reports}Scored reports
+
{data.credible_reports}Scores 5-9
+
{data.run_count}Completed runs
+
{data.item_failures}Item failures
+
${data.classification_cost_usd:.4f}Model cost
+
+

Reports over time

+{chart} +

Reports prioritised for review

+ +{rows}
DateScoreReasoningSource reference
+

How to read this

+

A score is a review priority, not proof that an incident occurred. Public +reporting volume cannot establish prevalence or a model's propensity to scheme. Screenshots may be +altered, context may be missing, and ordinary model failures can resemble strategic behaviour.

+

This report counts posts, not deduplicated real-world incidents. Direct Bluesky links are omitted +because they contain author identifiers; pseudonymous source references are shown instead.

+
+ +""" + + +def write_report(connection: sqlite3.Connection, output_path: Path) -> ReportData: + """Generate one report at the requested local path.""" + data = load_report_data(connection) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render_report(data), encoding="utf-8") + return data + + +def _render_chart(daily: tuple[DailyCount, ...]) -> str: + if not daily: + return "

No collected reports yet.

" + width, height, margin = 920, 280, 42 + chart_height = height - margin * 2 + slot = (width - margin * 2) / len(daily) + max_count = max(item.reports for item in daily) or 1 + bars: list[str] = [] + for index, item in enumerate(daily): + x = margin + index * slot + slot * 0.1 + report_height = chart_height * item.reports / max_count + credible_height = chart_height * item.credible / max_count + bars.append( + "" + f"{html.escape(item.date)}: {item.reports} reports, " + f"{item.credible} scored 5-9" + f'' + f'' + "" + ) + return ( + f'' + f'{"".join(bars)}' + '

Total reports   ' + ' Scores 5-9

' + ) diff --git a/src/loc_observatory/warehouse/database.py b/src/loc_observatory/warehouse/database.py index d6f40db..178ca19 100644 --- a/src/loc_observatory/warehouse/database.py +++ b/src/loc_observatory/warehouse/database.py @@ -9,6 +9,8 @@ _MIGRATION_NAMES = ( "0001_initial.sql", "0002_add_bluesky_provenance.sql", + "0003_add_pipeline_runs.sql", + "0004_add_prescreening.sql", ) _MIGRATION_PACKAGE = "loc_observatory.warehouse.migrations" diff --git a/src/loc_observatory/warehouse/migrations/0003_add_pipeline_runs.sql b/src/loc_observatory/warehouse/migrations/0003_add_pipeline_runs.sql new file mode 100644 index 0000000..4bedadc --- /dev/null +++ b/src/loc_observatory/warehouse/migrations/0003_add_pipeline_runs.sql @@ -0,0 +1,16 @@ +CREATE TABLE pipeline_runs ( + run_id TEXT PRIMARY KEY CHECK (length(run_id) > 0), + operation TEXT NOT NULL CHECK (length(operation) > 0), + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'partial', 'failed')), + started_at TEXT NOT NULL CHECK (length(started_at) > 0), + finished_at TEXT, + duration_ms INTEGER CHECK (duration_ms IS NULL OR duration_ms >= 0), + items_seen INTEGER NOT NULL DEFAULT 0 CHECK (items_seen >= 0), + items_succeeded INTEGER NOT NULL DEFAULT 0 CHECK (items_succeeded >= 0), + items_failed INTEGER NOT NULL DEFAULT 0 CHECK (items_failed >= 0), + cost_usd REAL NOT NULL DEFAULT 0 CHECK (cost_usd >= 0), + error_code TEXT, + details_json TEXT NOT NULL DEFAULT '{}' +) STRICT; + +CREATE INDEX pipeline_runs_started_at_idx ON pipeline_runs (started_at); diff --git a/src/loc_observatory/warehouse/migrations/0004_add_prescreening.sql b/src/loc_observatory/warehouse/migrations/0004_add_prescreening.sql new file mode 100644 index 0000000..0471c5b --- /dev/null +++ b/src/loc_observatory/warehouse/migrations/0004_add_prescreening.sql @@ -0,0 +1,22 @@ +-- Versioned high-recall screening results. Only "high" results advance to detailed scoring. +CREATE TABLE screenings ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + risk_level TEXT NOT NULL CHECK (risk_level IN ('none', 'low', 'medium', 'high')), + reasoning TEXT NOT NULL CHECK (length(reasoning) > 0), + model_id TEXT NOT NULL CHECK (length(model_id) > 0), + prompt_hash TEXT NOT NULL CHECK (length(prompt_hash) = 64), + input_tokens INTEGER NOT NULL CHECK (input_tokens >= 0), + output_tokens INTEGER NOT NULL CHECK (output_tokens >= 0), + cost_usd REAL NOT NULL CHECK (cost_usd >= 0), + screened_at TEXT NOT NULL CHECK (length(screened_at) > 0), + FOREIGN KEY (source, external_id) + REFERENCES posts_raw (source, external_id) + ON UPDATE RESTRICT + ON DELETE CASCADE, + UNIQUE (source, external_id, model_id, prompt_hash) +) STRICT; + +CREATE INDEX screenings_result_idx +ON screenings (model_id, prompt_hash, risk_level, source, external_id); diff --git a/src/loc_observatory/warehouse/scoring.py b/src/loc_observatory/warehouse/scoring.py index 81b24a1..f0ec0c7 100644 --- a/src/loc_observatory/warehouse/scoring.py +++ b/src/loc_observatory/warehouse/scoring.py @@ -86,3 +86,49 @@ def add_failure( """, (post.source, post.external_id, error_code, error_message, at), ) + + +class SQLiteScreenedScoreRepository(SQLiteScoreRepository): + """Select only posts that passed the configured prescreen version.""" + + def __init__( + self, + connection: sqlite3.Connection, + *, + prescreen_model_id: str, + prescreen_prompt_hash: str, + ) -> None: + super().__init__(connection) + self._prescreen_model_id = prescreen_model_id + self._prescreen_prompt_hash = prescreen_prompt_hash + + def pending_posts(self, model_id: str, prompt_hash: str, limit: int) -> tuple[PendingPost, ...]: + """Select high-risk prescreen results without an equivalent detailed score.""" + rows = self._connection.execute( + """ + SELECT post.source, post.external_id, post.text + FROM posts_raw AS post + JOIN screenings AS screening + ON screening.source = post.source + AND screening.external_id = post.external_id + AND screening.model_id = ? + AND screening.prompt_hash = ? + AND screening.risk_level = 'high' + LEFT JOIN scores AS score + ON score.source = post.source + AND score.external_id = post.external_id + AND score.model_id = ? + AND score.prompt_hash = ? + WHERE score.id IS NULL + ORDER BY post.created_at, post.source, post.external_id + LIMIT ? + """, + ( + self._prescreen_model_id, + self._prescreen_prompt_hash, + model_id, + prompt_hash, + limit, + ), + ).fetchall() + return tuple(PendingPost(source=row[0], external_id=row[1], text=row[2]) for row in rows) diff --git a/src/loc_observatory/warehouse/screening.py b/src/loc_observatory/warehouse/screening.py new file mode 100644 index 0000000..20564c8 --- /dev/null +++ b/src/loc_observatory/warehouse/screening.py @@ -0,0 +1,86 @@ +"""SQLite persistence for high-recall prescreening.""" + +from __future__ import annotations + +import sqlite3 + +from loc_observatory.classifier.prescreen import StoredScreening +from loc_observatory.classifier.service import PendingPost + + +class SQLitePrescreenRepository: + """Store versioned prescreen results separately from detailed scores.""" + + def __init__(self, connection: sqlite3.Connection) -> None: + self._connection = connection + + def pending_posts(self, model_id: str, prompt_hash: str, limit: int) -> tuple[PendingPost, ...]: + rows = self._connection.execute( + """ + SELECT post.source, post.external_id, post.text + FROM posts_raw AS post + LEFT JOIN screenings AS screening + ON screening.source = post.source + AND screening.external_id = post.external_id + AND screening.model_id = ? + AND screening.prompt_hash = ? + WHERE screening.id IS NULL + ORDER BY post.created_at, post.source, post.external_id + LIMIT ? + """, + (model_id, prompt_hash, limit), + ).fetchall() + return tuple(PendingPost(source=row[0], external_id=row[1], text=row[2]) for row in rows) + + def add_screening(self, screening: StoredScreening) -> bool: + with self._connection: + cursor = self._connection.execute( + """ + INSERT INTO screenings ( + source, external_id, risk_level, reasoning, model_id, prompt_hash, + input_tokens, output_tokens, cost_usd, screened_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (source, external_id, model_id, prompt_hash) DO NOTHING + """, + ( + screening.source, + screening.external_id, + screening.risk_level, + screening.reasoning, + screening.model_id, + screening.prompt_hash, + screening.input_tokens, + screening.output_tokens, + screening.cost_usd, + screening.screened_at, + ), + ) + if cursor.rowcount: + self._connection.execute( + "DELETE FROM dlq WHERE source = ? AND external_id = ? AND stage = 'prescreen'", + (screening.source, screening.external_id), + ) + return bool(cursor.rowcount) + + def add_failure( + self, + post: PendingPost, + error_code: str, + error_message: str, + at: str, + ) -> None: + with self._connection: + self._connection.execute( + """ + INSERT INTO dlq ( + source, external_id, stage, error_code, error_message, + retry_count, last_attempt_at + ) VALUES (?, ?, 'prescreen', ?, ?, 1, ?) + ON CONFLICT (source, external_id, stage) DO UPDATE SET + error_code = excluded.error_code, + error_message = excluded.error_message, + retry_count = dlq.retry_count + 1, + last_attempt_at = excluded.last_attempt_at + """, + (post.source, post.external_id, error_code, error_message, at), + ) diff --git a/tests/test_bluesky_collection.py b/tests/test_bluesky_collection.py index 3ca6a31..75e8d88 100644 --- a/tests/test_bluesky_collection.py +++ b/tests/test_bluesky_collection.py @@ -287,7 +287,14 @@ def gateway_factory(_settings: AppSettings) -> BlueskySearchGateway: "status": "ok", } assert "a-secret-key" not in output.out - assert output.err == "" + events = [json.loads(line) for line in output.err.splitlines()] + assert [event["event"] for event in events] == [ + "pipeline.run.started", + "pipeline.run.finished", + ] + assert events[0]["run_id"] == events[1]["run_id"] + assert events[1]["items_seen"] == 16 + assert events[1]["items_succeeded"] == 2 def test_cli_fails_visibly_when_every_query_is_rejected( @@ -316,4 +323,6 @@ def test_cli_fails_visibly_when_every_query_is_rejected( assert payload["status"] == "failed" assert payload["query_failures"] == payload["queries_run"] == 8 assert payload["posts_seen"] == 0 - assert output.err == "" + events = [json.loads(line) for line in output.err.splitlines()] + assert events[-1]["status"] == "failed" + assert events[-1]["error_code"] == "all_queries_failed" diff --git a/tests/test_classifier.py b/tests/test_classifier.py index 94a4fd6..b26708d 100644 --- a/tests/test_classifier.py +++ b/tests/test_classifier.py @@ -9,12 +9,12 @@ import httpx import pytest -from loc_observatory.classifier.anthropic import ( - AnthropicGateway, +from loc_observatory.classifier.openai import ( ClassifierProviderError, + OpenAIGateway, ProviderResult, ) -from loc_observatory.classifier.prompt import load_scoring_prompt +from loc_observatory.classifier.prompt import load_prescreen_prompt, load_scoring_prompt from loc_observatory.classifier.service import score_pending_posts from loc_observatory.cli import main from loc_observatory.models import CollectedPost @@ -23,7 +23,7 @@ from loc_observatory.warehouse.scoring import SQLiteScoreRepository _NOW = datetime(2026, 8, 4, 22, 0, tzinfo=UTC) -_MODEL = "claude-opus-4-6" +_MODEL = "gpt-5.6-sol" class FixtureGateway: @@ -261,31 +261,47 @@ def test_same_model_and_prompt_are_not_scored_twice(tmp_path: Path) -> None: assert gateway.calls == ["one post"] -def test_anthropic_adapter_sends_versioned_request_and_reads_usage() -> None: +def test_openai_adapter_requests_strict_output_and_reads_usage() -> None: """Lock the narrow HTTP contract without a live provider call.""" def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/messages" - assert request.headers["anthropic-version"] == "2023-06-01" - assert request.headers["x-api-key"] == "test-secret" + assert request.url.path == "/v1/responses" + assert request.headers["authorization"] == "Bearer test-secret" payload = json.loads(request.content) - assert payload["temperature"] == 0 - assert payload["system"] == "system prompt" - assert payload["messages"] == [{"role": "user", "content": "post text"}] + assert payload["store"] is False + assert payload["reasoning"] == {"effort": "medium"} + assert payload["input"] == [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "post text"}, + ] + assert payload["text"]["format"]["strict"] is True + assert payload["text"]["format"]["schema"]["required"] == [ + "score", + "score_reasoning", + "evidence_type", + "mundane_error", + ] return httpx.Response( 200, json={ + "status": "completed", "model": _MODEL, - "content": [{"type": "text", "text": _provider_result(3).content}], + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": _provider_result(3).content}], + } + ], "usage": {"input_tokens": 90, "output_tokens": 18}, }, ) - gateway = AnthropicGateway( + gateway = OpenAIGateway( api_key="test-secret", model_id=_MODEL, - api_base_url="https://api.anthropic.com", + api_base_url="https://api.openai.com", max_output_tokens=512, + reasoning_effort="medium", timeout_seconds=10, transport=httpx.MockTransport(handler), ) @@ -308,10 +324,27 @@ def test_cli_scores_a_batch_without_exposing_credentials_or_post_text( try: migrate_database(connection) SQLitePostRepository(connection).add_posts((_post("1", "private post text"),)) + connection.execute( + """ + INSERT INTO screenings ( + source, external_id, risk_level, reasoning, model_id, prompt_hash, + input_tokens, output_tokens, cost_usd, screened_at + ) VALUES (?, ?, 'high', ?, ?, ?, 1, 1, 0, ?) + """, + ( + "bluesky", + "1", + "Potentially relevant evidence.", + "gpt-5.6-luna", + load_prescreen_prompt().sha256, + "2026-08-04T21:30:00Z", + ), + ) + connection.commit() finally: connection.close() - monkeypatch.setenv("ANTHROPIC_API_KEY", "credential-that-must-not-appear") + monkeypatch.setenv("OPENAI_API_KEY", "credential-that-must-not-appear") gateway = FixtureGateway({"private post text": _provider_result(5)}) exit_code = main( [ @@ -331,7 +364,7 @@ def test_cli_scores_a_batch_without_exposing_credentials_or_post_text( assert exit_code == 0 assert payload == { "attempted": 1, - "cost_usd": 0.001, + "cost_usd": 0.0011, "database": str(database_path), "failed": 0, "input_tokens": 100, @@ -341,4 +374,10 @@ def test_cli_scores_a_batch_without_exposing_credentials_or_post_text( } assert "credential-that-must-not-appear" not in output.out assert "private post text" not in output.out - assert output.err == "" + events = [json.loads(line) for line in output.err.splitlines()] + assert [event["event"] for event in events] == [ + "pipeline.run.started", + "pipeline.run.finished", + ] + assert events[0]["run_id"] == events[1]["run_id"] + assert events[1]["cost_usd"] == 0.0011 diff --git a/tests/test_config.py b/tests/test_config.py index b56c1ee..73e8126 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,7 +13,7 @@ ) VALID_CONFIG = """\ -config_version: 3 +config_version: 4 bluesky: api_base_url: https://api.bsky.app sample_query: Claude AI @@ -36,14 +36,19 @@ warehouse: path: data/observatory.db classifier: - provider: anthropic - model: claude-opus-4-6 - api_base_url: https://api.anthropic.com + provider: openai + prescreen_model: gpt-5.6-luna + prescreen_reasoning_effort: low + prescreen_input_cost_per_million: 1 + prescreen_output_cost_per_million: 6 + model: gpt-5.6-sol + api_base_url: https://api.openai.com max_output_tokens: 2048 + reasoning_effort: medium batch_size: 10 timeout_seconds: 60 input_cost_per_million: 5 - output_cost_per_million: 25 + output_cost_per_million: 30 retention: raw_posts_days: 90 artifacts_days: 180 @@ -60,7 +65,7 @@ """ VALID_SECRETS = { - "ANTHROPIC_API_KEY": "anthropic-secret", + "OPENAI_API_KEY": "openai-secret", "AUTHOR_HMAC_KEY": "hmac-secret-with-at-least-32-bytes", "REDDIT_CLIENT_ID": "reddit-client-id", "REDDIT_CLIENT_SECRET": "reddit-client-secret", @@ -89,7 +94,7 @@ def test_loads_versioned_config_and_redacts_secrets(tmp_path: Path) -> None: assert settings.secrets.reddit_client_id is not None assert settings.secrets.reddit_client_id.get_secret_value() == "reddit-client-id" assert "reddit-client-id" not in repr(settings) - assert "anthropic-secret" not in repr(settings) + assert "openai-secret" not in repr(settings) def test_process_environment_overrides_dotenv_file(tmp_path: Path) -> None: diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..1711042 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,82 @@ +"""Structured logging and persistent pipeline-run tests.""" + +from __future__ import annotations + +import io +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from loc_observatory.observability import JsonEventLogger, ObservedRun +from loc_observatory.warehouse.database import connect_database, migrate_database + + +def test_observed_run_records_consistent_safe_events(tmp_path: Path) -> None: + """Persist and emit the same run context without sensitive application data.""" + connection = connect_database(tmp_path / "observatory.db") + stream = io.StringIO() + times = iter((10.0, 10.125)) + try: + migrate_database(connection) + with ObservedRun( + connection, + JsonEventLogger(stream), + "classify", + run_id="run-test", + now=lambda: datetime(2026, 8, 4, 20, 0, tzinfo=UTC), + timer=lambda: next(times), + ) as run: + run.complete( + "partial", + items_seen=3, + items_succeeded=2, + items_failed=1, + cost_usd=0.004, + details={"input_tokens": 120}, + ) + row = connection.execute( + "SELECT operation, status, duration_ms, items_seen, items_succeeded, " + "items_failed, cost_usd, details_json FROM pipeline_runs WHERE run_id = 'run-test'" + ).fetchone() + finally: + connection.close() + + events = [json.loads(line) for line in stream.getvalue().splitlines()] + assert row == ("classify", "partial", 125, 3, 2, 1, 0.004, '{"input_tokens":120}') + assert [event["event"] for event in events] == [ + "pipeline.run.started", + "pipeline.run.finished", + ] + assert all(event["run_id"] == "run-test" for event in events) + assert "post" not in stream.getvalue().lower() + assert "secret" not in stream.getvalue().lower() + + +def test_observed_run_safely_records_exception_type(tmp_path: Path) -> None: + """Record an exception class without serialising its potentially sensitive message.""" + connection = connect_database(tmp_path / "observatory.db") + stream = io.StringIO() + times = iter((20.0, 20.01)) + try: + migrate_database(connection) + with pytest.raises(RuntimeError, match="sensitive post body"): + with ObservedRun( + connection, + JsonEventLogger(stream), + "collect", + run_id="run-failed", + now=lambda: datetime(2026, 8, 4, 20, 0, tzinfo=UTC), + timer=lambda: next(times), + ): + raise RuntimeError("sensitive post body and secret") + row = connection.execute( + "SELECT status, error_code, items_failed FROM pipeline_runs WHERE run_id = 'run-failed'" + ).fetchone() + finally: + connection.close() + + assert row == ("failed", "RuntimeError", 1) + assert "sensitive post body" not in stream.getvalue() + assert "secret" not in stream.getvalue() diff --git a/tests/test_prescreen.py b/tests/test_prescreen.py new file mode 100644 index 0000000..2060015 --- /dev/null +++ b/tests/test_prescreen.py @@ -0,0 +1,177 @@ +"""Offline tests for the pilot-style high-recall prescreen.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import httpx + +from loc_observatory.classifier.openai import ( + ClassifierProviderError, + OpenAIGateway, + ProviderResult, +) +from loc_observatory.classifier.prescreen import prescreen_pending_posts +from loc_observatory.classifier.prompt import load_prescreen_prompt, load_scoring_prompt +from loc_observatory.models import CollectedPost +from loc_observatory.warehouse.database import connect_database, migrate_database +from loc_observatory.warehouse.posts import SQLitePostRepository +from loc_observatory.warehouse.scoring import SQLiteScreenedScoreRepository +from loc_observatory.warehouse.screening import SQLitePrescreenRepository + +_MODEL = "gpt-5.6-luna" +_NOW = datetime(2026, 8, 4, 22, 0, tzinfo=UTC) + + +class FixtureGateway: + model_id = _MODEL + + def __init__(self, outcomes: dict[str, ProviderResult | ClassifierProviderError]) -> None: + self._outcomes = outcomes + + def classify(self, system_prompt: str, post_text: str) -> ProviderResult: + assert "favour recall" in system_prompt.lower() + outcome = self._outcomes[post_text] + if isinstance(outcome, ClassifierProviderError): + raise outcome + return outcome + + +def _post(external_id: str, text: str) -> CollectedPost: + return CollectedPost( + source="bluesky", + external_id=external_id, + source_url=f"bluesky://post/{external_id}", + content_cid=f"cid-{external_id}", + record_key=f"key-{external_id}", + author_hmac="a" * 64, + created_at="2026-08-04T21:00:00Z", + text=text, + like_count=0, + reply_count=0, + repost_count=0, + quote_count=0, + query="AI scheming", + collected_at="2026-08-04T21:01:00Z", + collector_version="test", + ) + + +def _result(level: str) -> ProviderResult: + return ProviderResult( + content=json.dumps({"risk_level": level, "reasoning": "Bounded test reason."}), + model_id=_MODEL, + input_tokens=100, + output_tokens=20, + ) + + +def test_only_high_prescreen_results_advance_to_detailed_scoring(tmp_path: Path) -> None: + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts( + (_post("1", "candidate"), _post("2", "mundane failure")) + ) + prompt = load_prescreen_prompt() + result = prescreen_pending_posts( + FixtureGateway({"candidate": _result("high"), "mundane failure": _result("low")}), + SQLitePrescreenRepository(connection), + prompt, + limit=10, + input_cost_per_million=1, + output_cost_per_million=6, + now=lambda: _NOW, + ) + pending = SQLiteScreenedScoreRepository( + connection, + prescreen_model_id=_MODEL, + prescreen_prompt_hash=prompt.sha256, + ).pending_posts("gpt-5.6-sol", load_scoring_prompt().sha256, 10) + rows = connection.execute( + "SELECT external_id, risk_level FROM screenings ORDER BY external_id" + ).fetchall() + finally: + connection.close() + + assert result.screened == 2 + assert result.high_risk == 1 + assert result.cost_usd == 0.00044 + assert rows == [("1", "high"), ("2", "low")] + assert [(post.external_id, post.text) for post in pending] == [("1", "candidate")] + + +def test_prescreen_failure_is_isolated_and_safe(tmp_path: Path) -> None: + connection = connect_database(tmp_path / "observatory.db") + try: + migrate_database(connection) + SQLitePostRepository(connection).add_posts((_post("1", "private failure text"),)) + result = prescreen_pending_posts( + FixtureGateway( + { + "private failure text": ClassifierProviderError( + "provider request failed (HTTP 503)" + ) + } + ), + SQLitePrescreenRepository(connection), + load_prescreen_prompt(), + limit=1, + input_cost_per_million=1, + output_cost_per_million=6, + now=lambda: _NOW, + ) + failure = connection.execute("SELECT stage, error_code, error_message FROM dlq").fetchone() + finally: + connection.close() + + assert result.failed == 1 + assert failure == ("prescreen", "provider_error", "provider request failed (HTTP 503)") + assert "private failure text" not in str(failure) + + +def test_openai_prescreen_requests_the_four_level_schema() -> None: + """Keep provider-side constraints aligned with the local prescreen parser.""" + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + response_format = payload["text"]["format"] + assert response_format["name"] == "evidence_prescreen" + assert response_format["schema"]["properties"]["risk_level"]["enum"] == [ + "none", + "low", + "medium", + "high", + ] + return httpx.Response( + 200, + json={ + "status": "completed", + "model": _MODEL, + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": _result("high").content}], + } + ], + "usage": {"input_tokens": 80, "output_tokens": 12}, + }, + ) + + gateway = OpenAIGateway( + api_key="test-secret", + model_id=_MODEL, + api_base_url="https://api.openai.com", + max_output_tokens=256, + reasoning_effort="low", + response_kind="prescreen", + timeout_seconds=10, + transport=httpx.MockTransport(handler), + ) + + result = gateway.classify("system prompt", "post text") + + assert result.model_id == _MODEL + assert result.input_tokens == 80 diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..eb147c1 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,145 @@ +"""Static Observatory report integration tests.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from loc_observatory.cli import main +from loc_observatory.reporting.static import write_report +from loc_observatory.warehouse.database import connect_database, migrate_database + + +def _insert_post_and_score( + connection: sqlite3.Connection, + *, + external_id: str, + created_at: str, + score: int, + reasoning: str, +) -> None: + connection.execute( + """ + INSERT INTO posts_raw ( + source, external_id, source_url, content_cid, record_key, author_hmac, + created_at, text, like_count, reply_count, repost_count, quote_count, + query, collected_at, collector_version + ) VALUES ('bluesky', ?, ?, ?, ?, ?, ?, 'redacted', 0, 0, 0, 0, + 'AI scheming', '2026-08-04T20:01:00Z', 'test') + """, + ( + external_id, + f"bluesky://post/{external_id}", + f"cid-{external_id}", + f"key-{external_id}", + "a" * 64, + created_at, + ), + ) + connection.execute( + """ + INSERT INTO scores ( + source, external_id, score, reasoning, model_id, prompt_hash, + input_tokens, output_tokens, cost_usd, scored_at + ) VALUES ('bluesky', ?, ?, ?, 'test-model', ?, 100, 20, 0.001, + '2026-08-04T20:02:00Z') + """, + (external_id, score, reasoning, "b" * 64), + ) + + +def _build_report_database(path: Path) -> None: + connection = connect_database(path) + try: + migrate_database(connection) + _insert_post_and_score( + connection, + external_id="high", + created_at="2026-08-01T10:00:00Z", + score=7, + reasoning="Strong transcript ", + ) + _insert_post_and_score( + connection, + external_id="low", + created_at="2026-08-02T10:00:00Z", + score=2, + reasoning="Unsupported claim", + ) + connection.execute( + """ + INSERT INTO pipeline_runs ( + run_id, operation, status, started_at, finished_at, duration_ms, + items_seen, items_succeeded, items_failed, cost_usd, details_json + ) VALUES ('run-1', 'classify', 'partial', '2026-08-04T20:00:00Z', + '2026-08-04T20:00:01Z', 1000, 2, 1, 1, 0.002, '{}') + """ + ) + connection.commit() + finally: + connection.close() + + +def test_report_is_self_contained_escaped_and_deterministic(tmp_path: Path) -> None: + """Render the same chosen database identically with no executable source content.""" + database_path = tmp_path / "observatory.db" + _build_report_database(database_path) + first_path = tmp_path / "first.html" + second_path = tmp_path / "second.html" + connection = connect_database(database_path) + try: + first = write_report(connection, first_path) + second = write_report(connection, second_path) + finally: + connection.close() + + first_html = first_path.read_text(encoding="utf-8") + assert first == second + assert first_html == second_path.read_text(encoding="utf-8") + assert first.total_reports == 2 + assert first.scored_reports == 2 + assert first.credible_reports == 1 + assert first.run_count == 1 + assert first.item_failures == 1 + assert first.classification_cost_usd == 0.002 + assert "<script>alert(1)</script>" in first_html + assert " + + +
+
+ +

Loss of Control Observatory

+

Enter the local review key. It is kept only for this browser tab.

+ + + + +
+
+ + + + +

Record detail

+
+
+ + +
+

Save current view

+ +
+
+
+ + diff --git a/src/loc_observatory/dashboard/static/styles.css b/src/loc_observatory/dashboard/static/styles.css index 206e94b..a771c9d 100644 --- a/src/loc_observatory/dashboard/static/styles.css +++ b/src/loc_observatory/dashboard/static/styles.css @@ -1 +1,204 @@ -body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #1a1a1a; } +:root { + --bg: #f7f8fa; + --surface: #ffffff; + --text: #181a1f; + --muted: #686d78; + --faint: #9499a3; + --border: #dfe2e7; + --border-strong: #c9cdd5; + --blue: #2463d4; + --blue-soft: #eaf1ff; + --green: #18794e; + --green-soft: #e9f7ef; + --amber: #9a5b00; + --amber-soft: #fff5df; + --red: #c53b3f; + --red-soft: #fff0f0; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html { background: var(--bg); } +body { margin: 0; color: var(--text); background: var(--bg); font: 13px/1.42 var(--sans); } +button, input, select { color: inherit; font: 12px/1.2 var(--sans); } +button, select { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } +h1, h2, h3, p { margin: 0; } +h1 { font-size: 15px; line-height: 1.2; font-weight: 650; letter-spacing: -.01em; } +h2 { font-size: 20px; line-height: 1.2; font-weight: 650; letter-spacing: -.02em; } +h3 { font-size: 13px; font-weight: 650; } +a { color: var(--blue); } +.mono, .kpi-value, td.numeric { font-variant-numeric: tabular-nums; } +.mono { font-family: var(--mono); } +.muted { color: var(--muted); } + +.app { width: min(1600px, 100%); min-height: 100vh; margin: 0 auto; padding: 0 14px 28px; } +.topbar { height: 60px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); } +.brand-block, .header-actions, .toolbar, .status-strip, .tabs, .section-heading, .panel-heading, .pager, .subtabs, .dialog-heading, .dialog-actions { display: flex; align-items: center; } +.brand-block { gap: 10px; } +.brand-block p { margin-top: 2px; color: var(--muted); font-size: 11px; } +.brand-mark { width: 28px; height: 28px; display: grid; place-items: center; color: #fff; background: var(--text); font: 700 10px/1 var(--mono); letter-spacing: .04em; } +.header-actions { gap: 12px; } +.text-link { color: var(--muted); text-decoration: none; font-size: 12px; } +.text-link:hover { color: var(--text); } + +.status-strip { min-height: 34px; gap: 18px; color: var(--muted); border-bottom: 1px solid var(--border); font-size: 11px; } +.status-strip span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } +.status-strip strong { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.status-right { margin-left: auto; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); } +.status-dot.ok { background: var(--green); } +.status-dot.warning { background: var(--amber); } +.status-dot.failed { background: var(--red); } + +.tabs { gap: 22px; height: 42px; border-bottom: 1px solid var(--border); } +.tab { height: 42px; padding: 0; border: 0; border-bottom: 2px solid transparent; color: var(--muted); background: transparent; font-weight: 600; } +.tab.active { color: var(--text); border-bottom-color: var(--blue); } +.view { padding-top: 18px; } +.section-heading { justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.section-heading p, .panel-heading p { margin-top: 3px; color: var(--muted); font-size: 11px; } +.toolbar { gap: 6px; flex-wrap: wrap; justify-content: flex-end; } + +.button { min-height: 30px; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 3px; background: var(--surface); font-weight: 600; } +.button:hover { border-color: #9fa5b0; background: #f5f6f8; } +.button.primary { color: #fff; border-color: var(--blue); background: var(--blue); } +.button.quiet { color: var(--muted); border-color: var(--border); } +.button:disabled { opacity: .45; cursor: not-allowed; } +select, input { min-height: 30px; border: 1px solid var(--border-strong); border-radius: 3px; background: var(--surface); padding: 0 8px; } + +.filters { display: grid; grid-template-columns: repeat(8, minmax(105px, 1fr)) auto; gap: 7px; margin-bottom: 12px; padding: 10px; background: #f0f2f5; border: 1px solid var(--border); } +.filters label { display: grid; gap: 4px; color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .025em; text-transform: uppercase; } +.filters select, .filters input { width: 100%; color: var(--text); text-transform: none; letter-spacing: normal; } +.filter-reset { align-self: end; } + +.kpi-strip { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin-bottom: 14px; background: var(--surface); } +.kpi { min-height: 78px; padding: 11px 12px; border-right: 1px solid var(--border); } +.kpi:last-child { border-right: 0; } +.kpi-value { font: 650 24px/1.1 var(--sans); letter-spacing: -.025em; } +.kpi-label { margin-top: 6px; color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .055em; text-transform: uppercase; } +.kpi-context { margin-top: 3px; color: var(--faint); font-size: 10px; } +.semantic-ok { color: var(--green); } +.semantic-warning { color: var(--amber); } +.semantic-error { color: var(--red); } + +.panel { border: 1px solid var(--border); background: var(--surface); } +.panel-heading { min-height: 50px; justify-content: space-between; gap: 12px; padding: 9px 11px; border-bottom: 1px solid var(--border); } +.panel-heading.compact { min-height: 47px; } +.chart-panel { margin-bottom: 12px; } +.chart { min-height: 270px; padding: 8px 8px 3px; overflow: hidden; } +.chart.short { min-height: 200px; } +.chart svg { display: block; width: 100%; height: 100%; min-height: inherit; } +.chart text { fill: var(--muted); font: 10px var(--sans); } +.chart .grid { stroke: #e8eaee; stroke-width: 1; } +.chart .axis { stroke: #cdd1d8; stroke-width: 1; } +.chart .bar { fill: #aeb5c0; } +.chart .line { fill: none; stroke: var(--blue); stroke-width: 2; vector-effect: non-scaling-stroke; } +.chart .failure { fill: var(--red); } +.chart .success { fill: #6f7784; } +.chart .point { fill: var(--blue); } +.chart .tooltip-target { fill: transparent; } + +.analysis-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 12px; margin-bottom: 12px; } +.breakdown-chart { padding: 9px 11px 12px; min-height: 170px; } +.breakdown-row { display: grid; grid-template-columns: minmax(90px, .55fr) 1.45fr 44px; gap: 9px; align-items: center; min-height: 25px; } +.breakdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } +.breakdown-track { height: 7px; background: #edf0f3; } +.breakdown-fill { height: 100%; background: var(--blue); } +.breakdown-count { text-align: right; font-family: var(--mono); font-size: 11px; } +.methodology-panel dl { margin: 0; padding: 8px 11px 11px; } +.methodology-panel dl div { display: grid; grid-template-columns: 105px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid #eceef1; } +.methodology-panel dl div:last-child { border-bottom: 0; } +.methodology-panel dt { color: var(--text); font-weight: 650; } +.methodology-panel dd { margin: 0; color: var(--muted); } + +.table-panel { margin-bottom: 12px; } +.table-heading { min-height: 55px; } +.subtabs { gap: 14px; } +.subtab { padding: 0 0 5px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-weight: 650; } +.subtab.active { color: var(--text); border-bottom-color: var(--blue); } +.pager { gap: 8px; } +.table-wrap { overflow: auto; max-height: 500px; } +table { width: 100%; border-collapse: collapse; table-layout: fixed; } +th, td { min-height: 29px; padding: 6px 9px; border-bottom: 1px solid #e9ebef; text-align: left; vertical-align: top; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +th { position: sticky; top: 0; z-index: 1; color: var(--muted); background: #f6f7f9; font-size: 10px; font-weight: 650; letter-spacing: .035em; text-transform: uppercase; } +th.numeric, td.numeric { text-align: right; } +tbody tr { cursor: pointer; } +tbody tr:hover { background: #f5f8fe; } +.score-cell { font-weight: 700; } +.score-0, .score-1, .score-2, .score-3, .score-4 { color: var(--muted); } +.score-5, .score-6 { color: #a44e00; } +.score-7, .score-8, .score-9 { color: var(--red); } +.status-cell { font-weight: 650; } +.empty-state { padding: 30px 12px; color: var(--muted); text-align: center; } + +.operations-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; margin-bottom: 12px; } +.span-two { min-width: 0; } +.health-panel { min-width: 260px; } +.health-metric { padding: 16px 12px; } +.health-metric strong { display: block; margin-bottom: 7px; font: 650 25px/1 var(--sans); font-variant-numeric: tabular-nums; } +.health-metric p { color: var(--muted); } +.lower-grid { grid-template-columns: 1fr 1fr; } +.control-list, .limit-list { margin: 0; padding: 10px 11px 10px 28px; } +.control-list li, .limit-list li { margin: 4px 0; } +.control-list li::marker { color: var(--green); } +.limit-list { color: var(--muted); border-top: 1px solid var(--border); } +.schema-list { margin: 0; padding: 4px 11px 12px; } +.schema-row { display: grid; grid-template-columns: 1fr auto; padding: 5px 0; border-bottom: 1px solid #eceef1; } + +.notice { margin-bottom: 12px; padding: 9px 11px; border: 1px solid; } +.notice.error { color: #8b2428; border-color: #e7b9bb; background: var(--red-soft); } +.notice.success { color: #0d633e; border-color: #acd8bf; background: var(--green-soft); } + +.auth-gate { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: #f1f3f6; } +.auth-panel { width: min(390px, 100%); padding: 26px; border: 1px solid var(--border-strong); background: var(--surface); } +.auth-panel .brand-mark { margin-bottom: 18px; } +.auth-panel h1 { font-size: 20px; } +.auth-panel p { margin: 8px 0 18px; color: var(--muted); } +.auth-panel label, #save-form label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 11px; font-weight: 650; } +.auth-panel input, #save-form input { width: 100%; margin-bottom: 10px; } +.auth-panel .button { width: 100%; margin-top: 4px; } +.form-error { min-height: 18px; color: var(--red) !important; margin: 0 !important; } + +dialog { width: min(720px, calc(100% - 28px)); max-height: calc(100vh - 48px); padding: 0; border: 1px solid var(--border-strong); border-radius: 3px; color: var(--text); background: var(--surface); box-shadow: 0 18px 60px rgb(21 25 34 / 18%); } +dialog::backdrop { background: rgb(22 25 31 / 32%); } +.dialog-heading { min-height: 48px; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); } +.icon-button { width: 28px; height: 28px; border: 0; background: transparent; color: var(--muted); font-size: 21px; } +#detail-body { padding: 12px 14px 18px; overflow: auto; } +.detail-grid { display: grid; grid-template-columns: 145px 1fr; margin: 0; } +.detail-grid dt, .detail-grid dd { margin: 0; padding: 7px 0; border-bottom: 1px solid #eceef1; } +.detail-grid dt { color: var(--muted); } +.detail-grid dd { overflow-wrap: anywhere; white-space: pre-wrap; } +#save-form { padding-bottom: 12px; } +#save-form > label, #save-form > input { margin-left: 14px; width: calc(100% - 28px); } +#save-form > label { margin-top: 14px; } +.dialog-actions { justify-content: flex-end; gap: 7px; padding: 11px 14px 0; } + +@media (max-width: 1100px) { + .filters { grid-template-columns: repeat(4, minmax(125px, 1fr)); } + .kpi-strip { grid-template-columns: repeat(3, 1fr); } + .kpi:nth-child(3) { border-right: 0; } + .kpi:nth-child(-n+3) { border-bottom: 1px solid var(--border); } +} + +@media (max-width: 760px) { + .app { padding: 0 9px 20px; } + .topbar { height: auto; min-height: 60px; padding: 9px 0; } + .brand-block p, .text-link { display: none; } + .status-strip { overflow-x: auto; } + .status-right { margin-left: 0; } + .section-heading { align-items: flex-start; flex-direction: column; } + .toolbar { justify-content: flex-start; } + .filters { grid-template-columns: repeat(2, minmax(125px, 1fr)); } + .kpi-strip { grid-template-columns: repeat(2, 1fr); } + .kpi, .kpi:nth-child(3) { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } + .kpi:nth-child(even) { border-right: 0; } + .kpi:nth-last-child(-n+2) { border-bottom: 0; } + .analysis-grid, .operations-grid, .lower-grid { grid-template-columns: 1fr; } + .health-panel { min-width: 0; } + .chart { min-height: 220px; } + .panel-heading.table-heading { align-items: flex-start; flex-direction: column; } + .detail-grid { grid-template-columns: 1fr; } + .detail-grid dt { border-bottom: 0; padding-bottom: 0; } +} diff --git a/src/loc_observatory/warehouse/database.py b/src/loc_observatory/warehouse/database.py index 75c9031..31b7114 100644 --- a/src/loc_observatory/warehouse/database.py +++ b/src/loc_observatory/warehouse/database.py @@ -23,10 +23,14 @@ _MIGRATION_PACKAGE = "loc_observatory.warehouse.migrations" -def connect_database(path: Path) -> sqlite3.Connection: +def connect_database( + path: Path, + *, + check_same_thread: bool = True, +) -> sqlite3.Connection: """Open a local database with referential integrity enabled.""" path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(path) + connection = sqlite3.connect(path, check_same_thread=check_same_thread) connection.execute("PRAGMA foreign_keys = ON") connection.execute("PRAGMA busy_timeout = 5000") return connection diff --git a/tests/test_api.py b/tests/test_api.py index a23e7f0..d40d2ea 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -141,6 +141,21 @@ def test_health_is_public_but_data_requires_a_constant_time_api_key(tmp_path: Pa assert client.get("/api/v1/incidents", headers=_HEADERS).status_code == 200 +def test_dashboard_shell_is_data_free_and_uses_a_restrictive_csp(tmp_path: Path) -> None: + """Serve login and code assets publicly while keeping warehouse data behind the API key.""" + with _client(tmp_path / "observatory.db") as client: + response = client.get("/") + script = client.get("/assets/app.js") + + assert response.status_code == 200 + assert "Enter the local review key" in response.text + assert "Private source text" not in response.text + assert "script-src 'self'" in response.headers["content-security-policy"] + assert "unsafe-inline" not in response.headers["content-security-policy"] + assert script.status_code == 200 + assert script.headers["x-content-type-options"] == "nosniff" + + def test_incident_contract_pagination_filters_and_openapi_security(tmp_path: Path) -> None: database_path = tmp_path / "observatory.db" _seed(database_path) @@ -166,6 +181,12 @@ def test_incident_contract_pagination_filters_and_openapi_security(tmp_path: Pat schema = client.get("/openapi.json").json() assert "/api/v1/incidents" in schema["paths"] assert schema["components"]["securitySchemes"]["APIKeyHeader"]["in"] == "header" + assert ( + schema["paths"]["/api/v1/operations/summary"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["schema"]["$ref"] + == "#/components/schemas/OperationsSummary" + ) def test_reports_reconcile_with_summary_and_export_minimises_source_data(tmp_path: Path) -> None: @@ -198,6 +219,33 @@ def test_reports_reconcile_with_summary_and_export_minimises_source_data(tmp_pat assert "reasoning" not in rows[0] assert "source_url" not in rows[0] + operations = client.get("/api/v1/operations/summary", headers=_HEADERS) + assert operations.status_code == 200 + assert operations.json()["dlq_depth"] == 0 + assert operations.json()["item_success_rate"] == 1.0 + + +def test_trend_fills_missing_calendar_days_and_bounds_query_span(tmp_path: Path) -> None: + database_path = tmp_path / "observatory.db" + _seed(database_path) + with _client(database_path) as client: + trend = client.get( + "/api/v1/analytics/trend?date_from=2026-08-01&date_to=2026-08-03", + headers=_HEADERS, + ).json() + oversized = client.get( + "/api/v1/analytics/trend?date_from=2025-01-01&date_to=2026-08-02", + headers=_HEADERS, + ) + + assert [point["date"] for point in trend] == [ + "2026-08-01", + "2026-08-02", + "2026-08-03", + ] + assert [point["reports"] for point in trend] == [1, 1, 0] + assert oversized.status_code == 422 + def test_saved_views_are_auth_gated_and_round_trip_validated_filters(tmp_path: Path) -> None: database_path = tmp_path / "observatory.db" diff --git a/tests/test_bluesky_access.py b/tests/test_bluesky_access.py index 6608fee..4c27fcb 100644 --- a/tests/test_bluesky_access.py +++ b/tests/test_bluesky_access.py @@ -153,6 +153,30 @@ def handle_request(_request: httpx.Request) -> httpx.Response: assert delays == [] +@pytest.mark.parametrize("text", ["", " \n"]) +def test_collection_gateway_rejects_empty_post_text(text: str) -> None: + """Validate non-empty evidence before constructing a source-domain record.""" + payload = { + "posts": [ + { + "uri": "at://did:plc:test/app.bsky.feed.post/post-1", + "cid": "bafy-test", + "author": {"did": "did:plc:test", "handle": "test.example"}, + "record": {"text": text, "createdAt": "2026-08-04T20:00:00Z"}, + } + ] + } + gateway = BlueskyGateway( + api_base_url="https://api.bsky.app", + user_agent="loc-observatory/0.1 test", + timeout_seconds=10, + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json=payload)), + ) + + with pytest.raises(BlueskyAccessError, match="invalid response"): + gateway.search_posts("Claude AI", 10) + + def test_access_check_requires_requested_number_of_unique_posts() -> None: """Accept only a complete sample that proves the search endpoint could be read.""" result = run_access_check( diff --git a/tests/test_warehouse.py b/tests/test_warehouse.py index dfd0d85..6f40d60 100644 --- a/tests/test_warehouse.py +++ b/tests/test_warehouse.py @@ -2,6 +2,7 @@ import json import sqlite3 +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -10,6 +11,28 @@ from loc_observatory.warehouse.database import connect_database, migrate_database +def test_thread_opt_in_supports_request_owned_asgi_connections(tmp_path: Path) -> None: + """Allow FastAPI dependency teardown on another worker without changing the safe default.""" + connection = connect_database(tmp_path / "observatory.db", check_same_thread=False) + try: + migrate_database(connection) + with ThreadPoolExecutor(max_workers=1) as executor: + result = executor.submit(lambda: connection.execute("SELECT 1").fetchone()[0]).result() + finally: + connection.close() + + assert result == 1 + + default_connection = connect_database(tmp_path / "default.db") + try: + with ThreadPoolExecutor(max_workers=1) as executor: + attempted = executor.submit(lambda: default_connection.execute("SELECT 1").fetchone()) + with pytest.raises(sqlite3.ProgrammingError): + attempted.result() + finally: + default_connection.close() + + def insert_source_post(connection: sqlite3.Connection) -> None: """Insert one valid, already-redacted source record.""" connection.execute( From 4123440a0d882c91a47c6455ca351bd9c27d37ea Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 19:52:14 -0700 Subject: [PATCH 25/29] Make the service boundary reviewable and honest Document how non-engineers use the evidence view, how operators prove recovery and data health, and which privacy and deployment controls remain outside the local demonstration. Constraint: External documentation must lead with plain language and distinguish engineering controls from legal compliance\nRejected: Claim GDPR compliance | local safeguards do not establish a lawful basis, deployed access control, or backup erasure\nConfidence: high\nScope-risk: narrow\nDirective: Update validation evidence and deployment limits whenever the service boundary changes\nTested: Commands reconciled with CLI help; links and documented counts checked against the live warehouse\nNot-tested: Fresh-clone manual setup on a second machine --- README.md | 23 ++++-- RUNBOOK.md | 28 +++++++- docs/CONFIGURATION.md | 8 ++- docs/DASHBOARD_AND_API.md | 71 +++++++++++++++++++ docs/DATA_PROTECTION.md | 19 +++-- docs/OBSERVABILITY.md | 19 +++-- docs/VALIDATION.md | 18 +++++ .../0006-serve-dashboard-and-api-together.md | 40 +++++++++++ 8 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 docs/DASHBOARD_AND_API.md create mode 100644 docs/adr/0006-serve-dashboard-and-api-together.md diff --git a/README.md b/README.md index 5380092..b8e9b24 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ AI systems scheme across all uses, and classifier scores are not ground truth. ## Current status The repository now has a complete local path from privacy-first Bluesky collection through an -OpenAI prescreen and detailed scorer to versioned incident grouping and a self-contained HTML -report. Each operation records a structured run lifecycle, volume, failures, duration, and model -cost. Work is tracked in the +OpenAI prescreen and detailed scorer to versioned incident grouping. An authenticated dashboard +lets reviewers explore and export the data, while a separate operations view shows run health, +cost, safe logs, recovery, privacy controls, and warehouse integrity. The same service exposes a +versioned API with generated documentation. Each operation records a structured run lifecycle, +volume, failures, duration, and model cost. Work is tracked in the [project roadmap](https://github.com/code259/loc-observatory/issues/27). No collected dataset is committed. @@ -58,7 +60,7 @@ outside Git. See - Model scores help prioritise evidence. No independent labels were created, so accuracy is not claimed. - Text-only posts are sufficient to test the pipeline. Images, chatbot share links, and transcript - authenticity are not yet handled. + authenticity are not yet handled. Screenshot storage fails closed until face redaction exists. - A successful bounded run does not establish continuous reliability, legal compliance, or production security. @@ -98,6 +100,8 @@ The separate 50,000-record infrastructure benchmark is documented in [`docs/SCALE_TEST.md`](docs/SCALE_TEST.md). The structured logging and durable run-metrics contract is documented in [`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md). +Dashboard and API use, including their security limits, is documented in +[`docs/DASHBOARD_AND_API.md`](docs/DASHBOARD_AND_API.md). Bluesky access is documented in [`docs/BLUESKY_ACCESS.md`](docs/BLUESKY_ACCESS.md). The reason Reddit is not the live source is recorded in @@ -144,6 +148,17 @@ uv run observatory classify uv run observatory incidents analyze ``` +Set a separate `OBSERVATORY_API_KEY` of at least 32 characters, then start the local dashboard and +API: + +```bash +uv run observatory dashboard +``` + +Open `http://127.0.0.1:8000` for the dashboard or `http://127.0.0.1:8000/docs` for the interactive +API contract. Data endpoints require the key in the `X-API-Key` header. The service is local by +default; review the deployment limits before binding it to an external interface. + Generate the self-contained report: ```bash diff --git a/RUNBOOK.md b/RUNBOOK.md index 16b7c46..27698c4 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -3,9 +3,8 @@ ## Current state The repository runs a bounded Bluesky collector, two-stage classifier, incident grouping, SQLite -warehouse, structured run logging, and static report generator. It does not run a deployed web -service. Operational recovery steps will be added only after the related behaviour has been -exercised. +warehouse, structured run logging, authenticated dashboard, versioned API, and static report +generator. The web service is for local demonstration; it has not been deployed or load tested. ## Set up a local environment @@ -30,6 +29,23 @@ Expected result: one JSON object listing migrations applied in that invocation. returns an empty list and leaves the schema unchanged. The default database is `data/observatory.db`, which Git ignores. +## Run the dashboard and API + +Set a separate random `OBSERVATORY_API_KEY` of at least 32 characters in the ignored `.env` file, +then run: + +```bash +uv run observatory dashboard +``` + +Expected result: the process binds to `127.0.0.1:8000`. Open `/` for the two-view dashboard and +`/docs` for Swagger. `/health/live` checks the process; `/health/ready` also verifies that the +warehouse is writable and current. Every `/api/v1` route requires the key in `X-API-Key`. + +Stop with `Ctrl-C`. Do not use `--host 0.0.0.0` outside a controlled network. The service has one +shared key and no TLS, roles, or request limiter. See +[`docs/DASHBOARD_AND_API.md`](docs/DASHBOARD_AND_API.md) before changing the bind address. + ## Check Bluesky access Run: @@ -144,6 +160,12 @@ The database must be empty and must differ from the configured collection databa does not read an API credential or alter a live adapter. Remove or rename the generated database before repeating the demo; the clean-database check is deliberate. +The Operations view exposes the same proof through **Run recovery demo**. It creates a temporary +warehouse, forces one model timeout, verifies a dead-letter row, replays it once, and confirms a +second replay attempts zero work. Only the content-free outcome is written to the main warehouse as +an operational event. If any count differs from the expected `1 → 0` queue transition, treat the +demo as failed and inspect the application event before changing retry limits. + ## Run the local scale test The deterministic scale command and the recorded 50,000-record result are documented in diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 21276c7..1140902 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -34,6 +34,8 @@ default Bluesky access path does not require credentials. - `AUTHOR_HMAC_KEY`: at least 32 characters; used to pseudonymise author IDs before storage - `OPENAI_API_KEY`: credential for prescreening and detailed scoring +- `OBSERVATORY_API_KEY`: at least 32 characters; protects dashboard data and every `/api/v1` + endpoint The following variables are optional and must be used only if Reddit approves access for this project: @@ -47,5 +49,7 @@ Generate the HMAC key with: openssl rand -hex 32 ``` -Never commit `.env`, paste credentials into issues, or pass them as command-line arguments. Tests -use inert values and do not read the developer's environment. +Use a different random value for `AUTHOR_HMAC_KEY` and `OBSERVATORY_API_KEY`. Never commit `.env`, +paste credentials into issues, or pass them as command-line arguments. The dashboard keeps its key +in the browser session only; closing that tab clears it. Tests use inert values and do not read the +developer's environment. diff --git a/docs/DASHBOARD_AND_API.md b/docs/DASHBOARD_AND_API.md new file mode 100644 index 0000000..544aced --- /dev/null +++ b/docs/DASHBOARD_AND_API.md @@ -0,0 +1,71 @@ +# Dashboard and API + +## What reviewers can do + +The dashboard has two views backed by the same trusted warehouse queries. + +**Explore data** is for researchers and other non-engineers. It shows exact collection and +classification counts, a daily trend, a selected breakdown, and report or incident records. A +reviewer can filter by date, source, evidence score, evidence type, model, prescreen result, or +deployment context; drill into a record; save or share the filter definition; and export a +privacy-minimised CSV. + +**Operations** is for the person running the system. It shows freshness, the latest run, dead-letter +depth, completed-item success, model cost, process uptime, run duration and volume, safe application +events, privacy controls, and warehouse integrity. Its recovery action injects a timeout into an +isolated fixture warehouse, replays the failed item, and proves that a second replay is a no-op. + +The views report what the warehouse contains. A score is a review priority, not proof that an AI +system schemed. Zero incidents means no stored report met the current evidence and grouping rules; +it does not mean no concerning behaviour occurred. + +## Start locally + +Create a random `OBSERVATORY_API_KEY` of at least 32 characters and place it in the ignored `.env` +file. Keep it different from `AUTHOR_HMAC_KEY`. + +```bash +openssl rand -hex 32 +uv run observatory dashboard +``` + +Paste the generated value into `.env`; do not put it on the command line. Then open: + +- dashboard: `http://127.0.0.1:8000` +- interactive API documentation: `http://127.0.0.1:8000/docs` +- OpenAPI document: `http://127.0.0.1:8000/openapi.json` + +The browser keeps the key in `sessionStorage`, not local persistent storage. Warehouse responses, +including CSV exports, use `Cache-Control: no-store`. The public dashboard shell contains no source +data; every `/api/v1` request requires `X-API-Key`. + +## Self-service and export rules + +All filters use parameterised queries and the same repository functions across summaries, trends, +tables, and exports. Date spans are limited to 366 days. Interactive pages are bounded. CSV exports +are capped at 10,000 rows and omit post text, classifier reasoning, direct source URLs, and author +identifiers. The response states the exact exported row count. + +Saved views contain filter definitions only. They do not copy results or bypass authentication. +Their share links still require the recipient to have the dashboard key. + +## External API boundary + +The service can bind to another interface: + +```bash +uv run observatory dashboard --host 0.0.0.0 --port 8000 +``` + +Do not expose that process directly to the public internet. The demonstration has one shared API +key, no roles, no TLS termination, no request-rate limiter, and no external identity provider. A +deployment needs a reverse proxy or managed ingress with TLS, user authentication and role-based +access, secret management, request limits, central logs, monitoring, backups, and a tested rollback +path. The generated OpenAPI contract makes clients possible without claiming those controls exist. + +## Interface choices + +The layout follows an operations-tool hierarchy: status first, then exact key figures, the main +trend, and dense tables. Color has meaning: blue is interactive, green is healthy, amber is a +warning, and red is a failure or high score. Daily bars and a seven-day line replace decorative +gauges and donut charts. All timestamps are UTC and numbers use tabular figures. diff --git a/docs/DATA_PROTECTION.md b/docs/DATA_PROTECTION.md index ba8353e..546a3cc 100644 --- a/docs/DATA_PROTECTION.md +++ b/docs/DATA_PROTECTION.md @@ -8,9 +8,10 @@ identifiers before storage, keeps only fields needed for evidence review, delete schedule, and supports deletion by pseudonymous author ID. The current system is a local demonstration, not a deployed public service. Its database, generated -reports, and evidence files are excluded from Git. A deployment would need access control, a privacy -notice, a completed legal assessment, processor review, and tested backup deletion before collecting -data continuously. +reports, and evidence files are excluded from Git. Dashboard and API data require a separate key, +and exports remove source text and model reasoning. A deployment would still need user identities, +roles, a privacy notice, a completed legal assessment, processor review, and tested backup deletion +before collecting data continuously. ## What is collected @@ -38,7 +39,10 @@ necessary. Evidence artifacts have their own metadata table and ignored filesystem directory. This allows a verified artifact to have a different retention period from the source post. The current collector -does not yet create these artifacts. +does not yet create these artifacts. Image collection is disabled, and the warehouse rejects any +record marked as a screenshot. This is a fail-closed control: screenshots cannot be retained until a +tested pipeline removes faces and other unnecessary personal details before persistence. It is not +a claim that image privacy is solved. ## Purpose and provisional legal basis @@ -119,12 +123,13 @@ reviewed at that time rather than assumed from this repository. ## Access and operating controls -- Keep `AUTHOR_HMAC_KEY` and `OPENAI_API_KEY` in a secret manager or ignored `.env`, never in Git, - issues, commands, or logs. +- Keep `AUTHOR_HMAC_KEY`, `OPENAI_API_KEY`, and `OBSERVATORY_API_KEY` in a secret manager or ignored + `.env`, never in Git, issues, commands, or logs. - Restrict the database, artifact directory, exports, and reports to authorised reviewers. - Keep the HMAC key separate from pseudonymous data and rotate it through a documented migration. - Review high scores as leads, not findings; public reports and model judgments may be wrong. -- Do not deploy the current static report as a public site. It has no authentication or user roles. +- Do not expose the dashboard directly to the public internet. Its local shared key is not a user or + role system. - Test cleanup, erasure, backup deletion, and access review on the deployment environment. Implementation commands and failure handling are in [`RUNBOOK.md`](../RUNBOOK.md). Classification diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index a721224..db33c19 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -8,9 +8,10 @@ The application writes a machine-readable start event and final event, then stor in SQLite. This makes it possible to answer: what ran, which source and stage it covered, how long it took, how many items it fetched or stored, what failed, and what it cost. -The current implementation provides structured application logging and durable run metrics. It is -not yet a complete production monitoring service: logs remain on standard error, there is no central -log backend or trace collector, and only low collection volume has an automated warning. +The current implementation provides structured application logging, durable run metrics, and an +authenticated operations dashboard. It is not yet a complete production monitoring service: logs +are not shipped to a central backend, metrics are not exported to a time-series system, there is no +trace collector, and only low collection volume has an automated warning. ## Structured event contract @@ -41,8 +42,14 @@ keys, or raw provider responses. Unexpected exceptions record the exception clas `pipeline_runs` stores one row per operation with indexed `source`, `stage`, and start time. It keeps the lifecycle times, duration, generic item counts, cost, safe error code, and a JSON object of -stage-specific aggregate metrics. The later dashboard can query this table directly without parsing -log files. +stage-specific aggregate metrics. The operations dashboard queries this table directly without +parsing log files. It shows exact run counts, freshness, item success, cost, queue depth, latency +summaries, and per-run metrics. + +HTTP requests also create an append-only `operational_events` record. It contains the route template, +method, status, duration, request ID, and safe exception class when applicable. It excludes query +values, headers, credentials, response bodies, and source text. Failure to write an event never +turns a successful data request into a failed one. Collection records also preserve the exact operational bounds used for the run: post limit, page size, pages per query, request interval, pages and requests completed, duplicates, and categorized @@ -90,7 +97,7 @@ use these records without rerunning text similarity or parsing logs. ## Production additions still needed - Ship standard-error JSON to a central log service with retention and access controls. -- Export counters, latency histograms, and queue depth to a metrics backend. +- Export counters, latency histograms, freshness, spend, and queue depth to a metrics backend. - Add alerts for repeated failures, DLQ growth, latency, spend, and stale successful runs. - Propagate trace context if collection, workers, storage, and the dashboard become separate services. - Define service-level objectives only after measuring the deployed environment and provider limits. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index fd6d32b..321d2e3 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -165,6 +165,24 @@ eligible detailed-scoring work, confirming model-and-prompt idempotency. That earlier check established API wiring. It did not validate the current 1,255-post dataset, and its outputs are not mixed into the current database. +## Dashboard, API, and recovery check + +On 5 August 2026, the authenticated service was run against the ignored live warehouse and exercised +through a real Chromium browser at desktop and mobile sizes. The check covered login, both views, +the 30-day trend, 50-row report pagination, report drill-down, operational runs and events, privacy +and warehouse panels, and the isolated recovery action. No browser console or request errors were +recorded, and the mobile page had no horizontal overflow. + +The recovery action recorded one injected timeout, a dead-letter transition from 1 to 0, one +successful replay, zero work on a duplicate replay, and one stored result. API contract tests also +verified key enforcement, response schemas, query validation, privacy-minimised CSV export, saved +views, safe request events, readiness failure, and the request-owned SQLite threading boundary. + +The real warehouse showed 1,255 posts, 1,000 prescreens, 39 detailed scores, 20 pipeline runs, zero +dead-letter items, zero stored screenshots, `ok` SQLite integrity, and zero foreign-key failures. +The first durable collection-volume check returned `insufficient_history`: it had one prior baseline +run, below the required three. The signal is visible, but no calibrated volume-anomaly claim is made. + ## What this run does not establish - Image evidence and chatbot share-link transcripts are outside the collector. This is the main diff --git a/docs/adr/0006-serve-dashboard-and-api-together.md b/docs/adr/0006-serve-dashboard-and-api-together.md new file mode 100644 index 0000000..f317126 --- /dev/null +++ b/docs/adr/0006-serve-dashboard-and-api-together.md @@ -0,0 +1,40 @@ +# ADR 0006: Serve the dashboard and API together + +## Status + +Accepted on 5 August 2026. + +## Context + +Reviewers need a self-service dashboard, while external clients need a documented data contract. +Both must apply the same filters, data minimisation, and access checks. Running separate local +services would duplicate configuration and introduce a cross-origin boundary without improving the +demonstration. + +## Decision + +Use one FastAPI ASGI service for the versioned JSON API, generated OpenAPI documentation, and static +dashboard assets. Keep analytical queries in repositories shared by API responses and CSV exports. +Use one API key for the local demonstration, bind to loopback by default, and keep health endpoints +separate from protected data endpoints. + +Each API request owns a SQLite connection. FastAPI may enter and close a synchronous dependency on +different worker threads, so those request-owned connections explicitly opt out of SQLite's thread +identity check. They are never shared between requests. Command-line connections retain SQLite's +safer thread-confined default. + +## Consequences + +- The dashboard and API cannot silently disagree about a metric or filter. +- Generated Swagger documentation stays aligned with validated request and response models. +- Local operation needs one process and no JavaScript build step. +- The shared-key model is suitable only for an authorised local demonstration. +- A public deployment needs an identity provider, roles, TLS ingress, request limiting, central + observability, and deployment recovery. + +## Rejected alternatives + +- A separate dashboard server: more moving parts and a new trust boundary for no present benefit. +- A static report only: it cannot support filtering, drill-down, saved views, or live operations. +- A client-side database export: it would expose unnecessary source data and bypass server-side + access and export controls. From 5f14e42e0aff32fca6d52eae19fefb2073fb7c1d Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 19:54:20 -0700 Subject: [PATCH 26/29] Make provider failures operable by someone new Add exact detection, impact, response, recovery, and verification steps for Bluesky rate limits and model-provider outages, while marking the real outage path as untested. Constraint: Recovery commands must avoid printing source content or credentials\nRejected: Automatic unbounded replay | amplifies provider outages and permanent schema failures\nConfidence: high\nScope-risk: narrow\nDirective: Keep external-outage claims separate from the exercised local timeout path\nTested: Commands checked against current CLI and warehouse schema\nNot-tested: Real OpenAI outage and authenticated Bluesky rate-limit response --- RUNBOOK.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/RUNBOOK.md b/RUNBOOK.md index 27698c4..f8eadd7 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -82,6 +82,31 @@ For a larger but still bounded run, use the hard post limit, explicit request bo documented in [`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md). Keep the same `AUTHOR_HMAC_KEY` for the life of a dataset; changing it breaks author-level deduplication and erasure lookup. +### Respond to Bluesky rate limits + +**Detect.** The collection result is `partial` or `failed`, the final structured event reports a +non-zero `query_failures`, and `failure_counts` includes `http_429` or another provider status. The +collector does not log the response body. + +**Impact.** Completed pages remain committed and later queries continue. Coverage for failed queries +is incomplete, so do not compare that run's volume with a complete run as if collection conditions +were equal. + +**Respond.** Stop immediate reruns. Check the aggregate record without selecting source text: + +```bash +sqlite3 data/observatory.db \ + "SELECT run_id, status, started_at, items_seen, items_succeeded, items_failed, error_code FROM pipeline_runs WHERE operation = 'collect' ORDER BY started_at DESC LIMIT 5;" +``` + +Wait for the provider limit to clear. Review the configured attempt, delay, pacing, page, and query +bounds before changing them. Do not add a browser or proxy bypass. + +**Recover and verify.** Rerun the same bounded collection command. Uniqueness constraints make +completed posts no-ops. Confirm the new run is `ok` or that any remaining failures are explicit, +then run `uv run observatory health collection-volume`. A low-volume result is a coverage warning, +not evidence that incident prevalence fell. + ## Prescreen and score a bounded batch Set `OPENAI_API_KEY` in the ignored `.env` file, then run: @@ -100,6 +125,30 @@ only; structured lifecycle events are written to standard error. Token prices are configuration, not code, because provider pricing changes. Check them against the provider's current pricing before a live run. +### Respond to an unavailable model provider + +**Detect.** The prescreen or classifier run is `partial` or `failed`, its structured event contains a +safe provider error code, and affected items appear in `dlq`. Successful items remain stored under +their exact model and prompt version. + +**Impact.** The batch continues past individual failures, but its classified count is incomplete. +Do not run incident analysis as if the missing results were negative scores. + +**Respond.** Pause new paid batches and inspect only aggregate queue state: + +```bash +sqlite3 data/observatory.db \ + "SELECT stage, error_code, retry_count, count(*) FROM dlq GROUP BY 1, 2, 3 ORDER BY 1, 2, 3;" +``` + +Confirm provider status and credentials outside logs. A schema error is permanent until code or the +prompt contract changes; do not spend retries on it. + +**Recover and verify.** After the dependency recovers, replay a small leased batch with the existing +`dlq replay` commands below. Confirm the queue depth falls, the replay run records the same prompt +version, and a second replay attempts no completed item. The local forced-timeout path has exercised +these queue and idempotency mechanics. A real external provider outage has not been staged. + ## Group credible reports into incidents After the detailed scorer finishes, run: From 48b3f563085af36ec1f649444129c48e805de6b3 Mon Sep 17 00:00:00 2001 From: Nikhil Maturi Date: Tue, 4 Aug 2026 20:46:32 -0700 Subject: [PATCH 27/29] Make operational controls legible and inspectable Clarify partial classification coverage and source-date semantics, repair Swagger under the strict CSP, and expose safe demonstrations for validation, recovery, and warehouse structure. Constraint: Demonstrations must not send external requests or write synthetic evidence into the main warehouse Rejected: Relax the CSP for FastAPI's inline Swagger initializer | weakens a demonstrated security boundary Confidence: high Scope-risk: moderate Directive: Keep validation demonstrations on the production validator with fixed synthetic inputs Tested: node syntax checks; ruff format and lint; mypy; 92 pytest tests; browser verification of Swagger, recovery, validation, and schema inspection Not-tested: Cross-browser rendering outside the Codex Chromium runtime --- RUNBOOK.md | 3 +- docs/DASHBOARD_AND_API.md | 25 +++- src/loc_observatory/api.py | 120 ++++++++++++++- src/loc_observatory/collector/bluesky.py | 55 +++---- src/loc_observatory/dashboard/analytics.py | 2 + src/loc_observatory/dashboard/operations.py | 52 ++++++- src/loc_observatory/dashboard/schemas.py | 45 ++++++ src/loc_observatory/dashboard/static/app.js | 138 ++++++++++++++++-- .../dashboard/static/index.html | 46 +++++- .../dashboard/static/styles.css | 27 +++- .../dashboard/static/swagger-docs.css | 39 +++++ .../dashboard/static/swagger-init.js | 29 ++++ .../dashboard/static/swagger.html | 22 +++ tests/test_api.py | 70 +++++++++ 14 files changed, 611 insertions(+), 62 deletions(-) create mode 100644 src/loc_observatory/dashboard/static/swagger-docs.css create mode 100644 src/loc_observatory/dashboard/static/swagger-init.js create mode 100644 src/loc_observatory/dashboard/static/swagger.html diff --git a/RUNBOOK.md b/RUNBOOK.md index f8eadd7..8fe5afc 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -40,7 +40,8 @@ uv run observatory dashboard Expected result: the process binds to `127.0.0.1:8000`. Open `/` for the two-view dashboard and `/docs` for Swagger. `/health/live` checks the process; `/health/ready` also verifies that the -warehouse is writable and current. Every `/api/v1` route requires the key in `X-API-Key`. +warehouse is writable and current. Every `/api/v1` route requires the key in `X-API-Key`. Swagger +does not share the dashboard login; select **Authorize** and enter the same local key. Stop with `Ctrl-C`. Do not use `--host 0.0.0.0` outside a controlled network. The service has one shared key and no TLS, roles, or request limiter. See diff --git a/docs/DASHBOARD_AND_API.md b/docs/DASHBOARD_AND_API.md index 544aced..551eda3 100644 --- a/docs/DASHBOARD_AND_API.md +++ b/docs/DASHBOARD_AND_API.md @@ -10,15 +10,29 @@ reviewer can filter by date, source, evidence score, evidence type, model, presc deployment context; drill into a record; save or share the filter definition; and export a privacy-minimised CSV. -**Operations** is for the person running the system. It shows freshness, the latest run, dead-letter -depth, completed-item success, model cost, process uptime, run duration and volume, safe application -events, privacy controls, and warehouse integrity. Its recovery action injects a timeout into an -isolated fixture warehouse, replays the failed item, and proves that a second replay is a no-op. +**Operations** is for the person running the system. It shows freshness, the latest run, recovery +queue depth, completed-item success, model cost, process uptime, run duration and volume, safe +application events, privacy controls, and warehouse integrity. The recovery queue is also called the +dead-letter queue (DLQ). Zero means no failed item is waiting for replay. + +Two safe demonstrations make otherwise hidden controls inspectable. Failure recovery injects one +timeout into an isolated fixture warehouse, queues and replays the item, proves that a repeated +replay is a no-op, and confirms that the main evidence rows did not change. Input validation passes +fixed synthetic posts through the production Bluesky validator. It shows a complete post being +accepted and malformed fields, timestamps, and text being rejected before storage. Neither +demonstration sends an external request or writes synthetic evidence to the main warehouse. + +The warehouse schema inspector shows actual SQLite columns, primary keys, row counts, and foreign-key +deletion rules. It exposes metadata only, not stored rows. The views report what the warehouse contains. A score is a review priority, not proof that an AI system schemed. Zero incidents means no stored report met the current evidence and grouping rules; it does not mean no concerning behaviour occurred. +The trend groups reports by the original source-post publication time in UTC, not collection time. +“Not run” means a report was outside the bounded prescreen cohort; it does not mean the prescreen +returned a low-risk result. “Not scored” means detailed classification did not run. + ## Start locally Create a random `OBSERVATORY_API_KEY` of at least 32 characters and place it in the ignored `.env` @@ -35,6 +49,9 @@ Paste the generated value into `.env`; do not put it on the command line. Then o - interactive API documentation: `http://127.0.0.1:8000/docs` - OpenAPI document: `http://127.0.0.1:8000/openapi.json` +The Swagger page does not share the dashboard browser session. Select **Authorize** there and enter +the same local key before calling an authenticated endpoint. + The browser keeps the key in `sessionStorage`, not local persistent storage. Warehouse responses, including CSV exports, use `Cache-Control: no-store`. The public dashboard shell contains no source data; every `/api/v1` request requires `X-API-Key`. diff --git a/src/loc_observatory/api.py b/src/loc_observatory/api.py index 3984521..7e71028 100644 --- a/src/loc_observatory/api.py +++ b/src/loc_observatory/api.py @@ -20,6 +20,7 @@ from fastapi.security import APIKeyHeader from fastapi.staticfiles import StaticFiles +from loc_observatory.collector.bluesky import BlueskyAccessError, validate_collection_payload from loc_observatory.config import AppSettings from loc_observatory.dashboard.analytics import ( BreakdownDimension, @@ -43,6 +44,7 @@ SavedViewCreate, SummaryResponse, TrendPoint, + ValidationDemoResponse, WarehouseSummary, ) from loc_observatory.failure_demo import run_failure_cycle @@ -83,11 +85,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: summary="Authenticated access to incident, report, and operating data.", description=( "Scores are review priorities, not proof of scheming. Public report volume does not " - "measure population prevalence. All data endpoints require X-API-Key." + "measure population prevalence. All data endpoints require X-API-Key. The dashboard " + "login is not shared with this page; select Authorize and enter the same local key." ), version="1.0.0", lifespan=lifespan, - docs_url="/docs", + docs_url=None, redoc_url=None, ) @@ -160,8 +163,8 @@ async def observe_request(request: Request, call_next: Callable[[Request], objec if request.url.path == "/docs": response.headers["Content-Security-Policy"] = ( "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "img-src 'self' data: https://fastapi.tiangolo.com; object-src 'none'; " + "style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; " + "object-src 'none'; " "base-uri 'none'; frame-ancestors 'none'" ) else: @@ -363,6 +366,9 @@ def recovery_demo( connection: sqlite3.Connection = Depends(get_database), ) -> object: """Inject a provider failure and prove idempotent recovery in an isolated warehouse.""" + main_score_rows_before = int( + connection.execute("SELECT count(*) FROM scores").fetchone()[0] + ) with tempfile.TemporaryDirectory(prefix="observatory-recovery-") as directory: demo_connection = connect_database(Path(directory) / "recovery.db") try: @@ -370,15 +376,76 @@ def recovery_demo( result = run_failure_cycle(demo_connection) finally: demo_connection.close() + main_score_rows_after = int(connection.execute("SELECT count(*) FROM scores").fetchone()[0]) + event_fields = { + **asdict(result), + "main_score_rows_before": main_score_rows_before, + "main_score_rows_after": main_score_rows_after, + } event_id = SQLiteOperationsRepository(connection).record_event( level="info", component="recovery", event="recovery.demo.completed", outcome="recovered", occurred_at=now(), - fields=asdict(result), + fields=event_fields, ) - return {"status": "recovered", "event_id": event_id, **asdict(result)} + return {"status": "recovered", "event_id": event_id, **event_fields} + + @router.post( + "/operations/validation-demo", + response_model=ValidationDemoResponse, + tags=["operations"], + ) + def validation_demo( + connection: sqlite3.Connection = Depends(get_database), + ) -> object: + """Exercise fixed synthetic cases through the production Bluesky response validator.""" + checks: list[dict[str, str]] = [] + accepted = 0 + rejected = 0 + for case_name, rule, expectation, payload in _validation_demo_cases(): + try: + validate_collection_payload(payload) + result = "accepted" + accepted += 1 + except BlueskyAccessError: + result = "rejected" + rejected += 1 + expected_result = "accepted" if expectation == "accept" else "rejected" + if result != expected_result: + raise RuntimeError(f"validation demonstration failed for {case_name}") + checks.append( + { + "case": case_name, + "rule": rule, + "expectation": expectation, + "result": result, + } + ) + event_fields: dict[str, object] = { + "valid_payloads_accepted": accepted, + "invalid_payloads_rejected": rejected, + "persistent_rows_written": 0, + "cases": [check["case"] for check in checks], + "checks": checks, + } + event_id = SQLiteOperationsRepository(connection).record_event( + level="info", + component="collector", + event="validation.demo.completed", + outcome="passed", + occurred_at=now(), + fields=event_fields, + ) + return { + "status": "passed", + "event_id": event_id, + "valid_payloads_accepted": accepted, + "invalid_payloads_rejected": rejected, + "persistent_rows_written": 0, + "checks": checks, + } @router.get("/privacy", response_model=PrivacySummary, tags=["privacy"]) def privacy( @@ -443,6 +510,10 @@ def delete_saved_view( def dashboard() -> FileResponse: return FileResponse(_STATIC_ROOT / "index.html") + @app.get("/docs", include_in_schema=False) + def api_documentation() -> FileResponse: + return FileResponse(_STATIC_ROOT / "swagger.html") + return app @@ -473,6 +544,43 @@ def _query_filters( raise HTTPException(status_code=422, detail=str(error)) from None +def _validation_demo_cases() -> tuple[tuple[str, str, str, dict[str, object]], ...]: + valid_post: dict[str, object] = { + "uri": "at://did:plc:validation/app.bsky.feed.post/example", + "cid": "bafy-validation", + "author": {"did": "did:plc:validation", "handle": "validation.invalid"}, + "record": {"text": "Synthetic validation post", "createdAt": "2026-08-04T20:00:00Z"}, + "likeCount": 0, + "replyCount": 0, + "repostCount": 0, + "quoteCount": 0, + } + missing_cid = {key: value for key, value in valid_post.items() if key != "cid"} + invalid_timestamp = {**valid_post, "record": {"text": "Synthetic", "createdAt": "invalid"}} + empty_text = {**valid_post, "record": {"text": " ", "createdAt": "2026-08-04T20:00:00Z"}} + return ( + ( + "complete_post", + "required fields, timezone-aware timestamp, non-empty text, non-negative counts", + "accept", + {"posts": [valid_post]}, + ), + ("missing_required_field", "cid is required", "reject", {"posts": [missing_cid]}), + ( + "invalid_timestamp", + "createdAt must be a parseable timezone-aware timestamp", + "reject", + {"posts": [invalid_timestamp]}, + ), + ( + "empty_text", + "text must contain non-whitespace content", + "reject", + {"posts": [empty_text]}, + ), + ) + + def _saved_filters(payload: SavedViewCreate) -> ExploreFilters: filters = payload.filters return ExploreFilters( diff --git a/src/loc_observatory/collector/bluesky.py b/src/loc_observatory/collector/bluesky.py index 330bbc5..4002e9c 100644 --- a/src/loc_observatory/collector/bluesky.py +++ b/src/loc_observatory/collector/bluesky.py @@ -161,6 +161,35 @@ class BlueskySearchPage: cursor: str | None +def validate_collection_payload(raw_payload: object) -> BlueskySearchPage: + """Apply the production provider contract before constructing domain records.""" + try: + payload = _CollectionResponse.model_validate(raw_payload) + except ValidationError: + raise BlueskyAccessError( + "Bluesky API returned an invalid response", code="invalid_response" + ) from None + + posts = tuple( + BlueskySourcePost( + uri=post.uri, + cid=post.cid, + record_key=post.uri.rsplit("/", maxsplit=1)[-1], + author_did=post.author.did, + author_handle=post.author.handle, + created_at=post.record.created_at, + text=post.record.text, + like_count=post.like_count, + reply_count=post.reply_count, + repost_count=post.repost_count, + quote_count=post.quote_count, + author_display_name=post.author.display_name, + ) + for post in payload.posts + ) + return BlueskySearchPage(posts=posts, cursor=payload.cursor) + + class BlueskyGateway: """HTTP adapter for the public, read-only Bluesky AppView.""" @@ -219,31 +248,7 @@ def search_posts( ) -> BlueskySearchPage: """Fetch and validate one collection page.""" raw_payload = self._search_payload(query=query, limit=limit, cursor=cursor) - try: - payload = _CollectionResponse.model_validate(raw_payload) - except ValidationError: - raise BlueskyAccessError( - "Bluesky API returned an invalid response", code="invalid_response" - ) from None - - posts = tuple( - BlueskySourcePost( - uri=post.uri, - cid=post.cid, - record_key=post.uri.rsplit("/", maxsplit=1)[-1], - author_did=post.author.did, - author_handle=post.author.handle, - created_at=post.record.created_at, - text=post.record.text, - like_count=post.like_count, - reply_count=post.reply_count, - repost_count=post.repost_count, - quote_count=post.quote_count, - author_display_name=post.author.display_name, - ) - for post in payload.posts - ) - return BlueskySearchPage(posts=posts, cursor=payload.cursor) + return validate_collection_payload(raw_payload) def _search_payload( self, diff --git a/src/loc_observatory/dashboard/analytics.py b/src/loc_observatory/dashboard/analytics.py index 0c97c68..d6dd967 100644 --- a/src/loc_observatory/dashboard/analytics.py +++ b/src/loc_observatory/dashboard/analytics.py @@ -153,7 +153,9 @@ def summary(self, filters: ExploreFilters) -> dict[str, object]: return { "reports": int(totals[0]), "screened_reports": int(totals[1]), + "unscreened_reports": int(totals[0]) - int(totals[1]), "scored_reports": int(totals[2]), + "unscored_reports": int(totals[0]) - int(totals[2]), "credible_reports": int(totals[3]), "incidents": incident_totals["current"], "incidents_previous_period": incident_totals["previous"], diff --git a/src/loc_observatory/dashboard/operations.py b/src/loc_observatory/dashboard/operations.py index 5ef15c2..7d8f23b 100644 --- a/src/loc_observatory/dashboard/operations.py +++ b/src/loc_observatory/dashboard/operations.py @@ -269,7 +269,7 @@ def privacy_summary( } def warehouse_summary(self, *, database_path: Path) -> dict[str, object]: - """Return schema, integrity, and row-count signals without the database path.""" + """Return inspectable schema and integrity signals without source rows or the path.""" tables = [ str(row[0]) for row in self._connection.execute( @@ -280,10 +280,45 @@ def warehouse_summary(self, *, database_path: Path) -> dict[str, object]: """ ) ] - row_counts = { - table: int(self._connection.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0]) - for table in tables - } + row_counts: dict[str, int] = {} + schema: list[dict[str, object]] = [] + for table in tables: + quoted_table = _quote_identifier(table) + row_count = int( + self._connection.execute(f"SELECT count(*) FROM {quoted_table}").fetchone()[0] + ) + row_counts[table] = row_count + columns = self._connection.execute(f"PRAGMA table_info({quoted_table})").fetchall() + foreign_keys = self._connection.execute( + f"PRAGMA foreign_key_list({quoted_table})" + ).fetchall() + schema.append( + { + "name": table, + "row_count": row_count, + "columns": [ + { + "name": str(row[1]), + "data_type": str(row[2]), + "not_null": bool(row[3]), + "primary_key_position": int(row[5]), + } + for row in columns + ], + "foreign_keys": [ + { + "constraint_id": int(row[0]), + "sequence": int(row[1]), + "target_table": str(row[2]), + "from_column": str(row[3]), + "target_column": str(row[4]) if row[4] is not None else None, + "on_update": str(row[5]), + "on_delete": str(row[6]), + } + for row in foreign_keys + ], + } + ) integrity = str(self._connection.execute("PRAGMA integrity_check").fetchone()[0]) foreign_key_failures = len(self._connection.execute("PRAGMA foreign_key_check").fetchall()) return { @@ -296,6 +331,7 @@ def warehouse_summary(self, *, database_path: Path) -> dict[str, object]: "integrity_check": integrity, "foreign_key_failures": foreign_key_failures, "tables": row_counts, + "table_schemas": schema, } def save_view( @@ -406,6 +442,12 @@ def _json_object(value: object) -> dict[str, object]: return {str(key): item for key, item in parsed.items()} +def _quote_identifier(value: str) -> str: + """Quote a schema-derived SQLite identifier before using it in metadata queries.""" + escaped = value.replace('"', '""') + return f'"{escaped}"' + + def _parse_utc(value: str) -> datetime: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) _require_aware(parsed) diff --git a/src/loc_observatory/dashboard/schemas.py b/src/loc_observatory/dashboard/schemas.py index 25aacc1..8dfb71d 100644 --- a/src/loc_observatory/dashboard/schemas.py +++ b/src/loc_observatory/dashboard/schemas.py @@ -80,7 +80,9 @@ class IncidentRecord(ApiModel): class SummaryResponse(ApiModel): reports: int = Field(ge=0) screened_reports: int = Field(ge=0) + unscreened_reports: int = Field(ge=0) scored_reports: int = Field(ge=0) + unscored_reports: int = Field(ge=0) credible_reports: int = Field(ge=0) incidents: int = Field(ge=0) incidents_previous_period: int = Field(ge=0) @@ -214,6 +216,30 @@ class PrivacySummary(ApiModel): recent_audit_actions: list[AuditAction] +class WarehouseColumn(ApiModel): + name: str + data_type: str + not_null: bool + primary_key_position: int = Field(ge=0) + + +class WarehouseForeignKey(ApiModel): + constraint_id: int = Field(ge=0) + sequence: int = Field(ge=0) + from_column: str + target_table: str + target_column: str | None + on_update: str + on_delete: str + + +class WarehouseTable(ApiModel): + name: str + row_count: int = Field(ge=0) + columns: list[WarehouseColumn] + foreign_keys: list[WarehouseForeignKey] + + class WarehouseSummary(ApiModel): engine: Literal["SQLite"] database_bytes: int = Field(ge=0) @@ -222,6 +248,7 @@ class WarehouseSummary(ApiModel): integrity_check: str foreign_key_failures: int = Field(ge=0) tables: dict[str, int] + table_schemas: list[WarehouseTable] class OperationalEvent(ApiModel): @@ -288,3 +315,21 @@ class RecoveryDemoResponse(ApiModel): dlq_after_replay: int second_replay_attempted: int stored_scores: int + main_score_rows_before: int = Field(ge=0) + main_score_rows_after: int = Field(ge=0) + + +class ValidationCheck(ApiModel): + case: str + rule: str + expectation: Literal["accept", "reject"] + result: Literal["accepted", "rejected"] + + +class ValidationDemoResponse(ApiModel): + status: Literal["passed"] + event_id: str + valid_payloads_accepted: int = Field(ge=0) + invalid_payloads_rejected: int = Field(ge=0) + persistent_rows_written: Literal[0] + checks: list[ValidationCheck] diff --git a/src/loc_observatory/dashboard/static/app.js b/src/loc_observatory/dashboard/static/app.js index ca91e25..bc1dad1 100644 --- a/src/loc_observatory/dashboard/static/app.js +++ b/src/loc_observatory/dashboard/static/app.js @@ -11,6 +11,7 @@ const state = { savedViews: [], currentSavedView: null, exploreRequest: 0, + warehouse: null, }; const authGate = byId("auth-gate"); @@ -50,6 +51,10 @@ function bindEvents() { byId("saved-view-select").addEventListener("change", loadSelectedView); byId("share-view-button").addEventListener("click", copyShareLink); byId("recovery-button").addEventListener("click", runRecoveryDemo); + byId("validation-button").addEventListener("click", runValidationDemo); + byId("schema-button").addEventListener("click", openSchemaInspector); + byId("schema-close").addEventListener("click", () => byId("schema-dialog").close()); + byId("schema-table-select").addEventListener("change", renderSelectedSchema); byId("detail-close").addEventListener("click", () => detailDialog.close()); detailDialog.addEventListener("click", (event) => { if (event.target === detailDialog) detailDialog.close(); @@ -145,8 +150,8 @@ function renderExploreKpis(summary) { renderKpis(byId("explore-kpis"), [ [exact(summary.incidents), "Unique incidents", delta === 0 ? "No period change" : `${signed(delta)} vs prior period`, delta > 0 ? "semantic-warning" : ""], [exact(summary.credible_reports), "Credible reports", "Score 5–9; real-world only"], - [exact(summary.reports), "Collected reports", `${exact(summary.screened_reports)} prescreened`], - [exact(summary.scored_reports), "Detailed scores", "Conservative evidence rubric"], + [exact(summary.reports), "Collected reports", `${exact(summary.screened_reports)} prescreened · ${exact(summary.unscreened_reports)} not processed`], + [exact(summary.scored_reports), "Detailed scores", `${exact(summary.unscored_reports)} reports not scored`], [money(summary.model_cost_usd), "Filtered model cost", "Prescreen + detailed score"], [formatUtc(summary.latest_collection_at), "Latest record", "Collection timestamp (UTC)"], ]); @@ -186,7 +191,7 @@ function renderRecords() { const columns = state.recordsMode === "incidents" ? [ ["Incident", "incident_id"], ["First report", "first_reported_at"], ["Score", "max_score", "numeric"], ["Reports", "report_count", "numeric"], ["Model", "models"], ["Behaviour", "behaviour_summary"], ["Review", "review_status"], ] : [ - ["Date", "created_at"], ["Platform", "source"], ["Prescreen", "risk_level"], ["Score", "score", "numeric"], ["Model", "models"], ["Evidence", "evidence_type"], ["Behaviour", "behaviour_summary"], + ["Published", "created_at"], ["Platform", "source"], ["Prescreen result", "risk_level"], ["Score", "score", "numeric"], ["Model", "models"], ["Evidence", "evidence_type"], ["Behaviour", "behaviour_summary"], ]; renderTable(table, columns, state.records, showRecordDetail); } @@ -213,6 +218,7 @@ async function loadOperations() { renderVolumeHealth(summary.volume_anomaly); renderPrivacy(privacy); renderWarehouse(warehouse); + renderLatestDemonstrations(events.items); updateStatusStrip(summary); } catch (error) { showError("operations-error", error); @@ -224,7 +230,7 @@ function renderOperationsSummary(summary) { renderKpis(byId("operations-kpis"), [ [freshness(summary.freshness_minutes), "Data freshness", summary.last_successful_ingest_at ? formatUtc(summary.last_successful_ingest_at) : "No successful ingest", freshnessClass(summary.freshness_minutes)], [latest ? latest.status.toUpperCase() : "NONE", "Latest run", latest ? `${latest.operation} · ${duration(latest.duration_ms)}` : "No run history", latest ? statusClass(latest.status) : ""], - [exact(summary.dlq_depth), "DLQ depth", `${exact(summary.dlq_leased)} currently leased`, summary.dlq_depth ? "semantic-warning" : "semantic-ok"], + [exact(summary.dlq_depth), "Recovery queue (DLQ)", `${exact(summary.dlq_depth)} failed items waiting · ${exact(summary.dlq_leased)} leased`, summary.dlq_depth ? "semantic-warning" : "semantic-ok"], [percent(summary.item_success_rate), "Item success", `${exact(summary.items_failed)} failures recorded`], [money(summary.model_cost_usd), "Total model cost", `${exact(summary.completed_runs)} terminal runs`], [duration(summary.service_uptime_seconds * 1000), "Service uptime", "Current process"], @@ -263,6 +269,7 @@ function renderPrivacy(privacy) { } function renderWarehouse(warehouse) { + state.warehouse = warehouse; const container = byId("warehouse-panel"); container.replaceChildren(); const health = node("div", "health-metric"); @@ -284,18 +291,112 @@ async function runRecoveryDemo() { hide("recovery-result"); try { const result = await state.client.request("/api/v1/operations/recovery-demo", { method: "POST" }); - const notice = byId("recovery-result"); - notice.textContent = `Recovered cleanly: ${result.injected_failures} injected timeout, DLQ ${result.dlq_after_failure} → ${result.dlq_after_replay}, ${result.replay_succeeded} replayed, duplicate replay ${result.second_replay_attempted}.`; - notice.hidden = false; + renderRecoveryEvidence(result); await loadOperations(); } catch (error) { showError("operations-error", error); } finally { button.disabled = false; - button.textContent = "Run recovery demonstration"; + button.textContent = "Run demonstration"; } } +async function runValidationDemo() { + const button = byId("validation-button"); + button.disabled = true; + button.textContent = "Running…"; + try { + const result = await state.client.request("/api/v1/operations/validation-demo", { method: "POST" }); + renderValidationEvidence(result); + await loadOperations(); + } catch (error) { + const status = byId("validation-result"); + status.className = "demo-status error"; + status.textContent = error instanceof Error ? error.message : "Validation demonstration failed."; + } finally { + button.disabled = false; + button.textContent = "Run demonstration"; + } +} + +function renderLatestDemonstrations(events) { + const recovery = events.find((event) => event.event === "recovery.demo.completed"); + const validation = events.find((event) => event.event === "validation.demo.completed"); + if (recovery) renderRecoveryEvidence({ ...recovery.fields, completed_at: recovery.occurred_at }); + if (validation) renderValidationEvidence({ ...validation.fields, completed_at: validation.occurred_at }); +} + +function renderRecoveryEvidence(result) { + const status = byId("recovery-result"); + status.className = "demo-status success"; + status.textContent = `Passed at ${formatUtc(result.completed_at || new Date().toISOString())}: one failed item was isolated, replayed, and not duplicated.`; + const unchanged = result.main_score_rows_before === undefined ? "Main evidence warehouse is not used by this isolated test." : `Main evidence rows remained ${exact(result.main_score_rows_before)} → ${exact(result.main_score_rows_after)}.`; + renderDemoSteps(byId("recovery-evidence"), [ + ["1 · Inject failure", `${exact(result.injected_failures)} synthetic model timeout.`], + ["2 · Isolate", `Recovery queue reached ${exact(result.dlq_after_failure)}; the batch stayed available.`], + ["3 · Replay", `${exact(result.replay_succeeded)} item recovered; queue returned to ${exact(result.dlq_after_replay)}.`], + ["4 · Prove safe repeat", `Duplicate replay processed ${exact(result.second_replay_attempted)} items. ${unchanged}`], + ]); +} + +function renderValidationEvidence(result) { + const status = byId("validation-result"); + const accepted = result.valid_payloads_accepted ?? 0; + const rejected = result.invalid_payloads_rejected ?? 0; + const written = result.persistent_rows_written ?? 0; + status.className = "demo-status success"; + status.textContent = `Passed: ${accepted} valid accepted · ${rejected} invalid rejected · ${written} evidence rows written.`; + const checks = result.checks || [ + { case: "Valid post", result: "accepted", rule: "Complete production-shaped payload" }, + { case: "Invalid cases", result: "rejected", rule: `${rejected} malformed synthetic payloads` }, + ]; + renderDemoSteps(byId("validation-evidence"), checks.map((check) => [ + `${check.result === "accepted" ? "Accepted" : "Rejected"} · ${String(check.case).replaceAll("_", " ")}`, + check.rule, + ])); +} + +function renderDemoSteps(container, definitions) { + container.replaceChildren(); + definitions.forEach(([heading, body]) => { + const step = node("div", "demo-step"); + step.append(node("strong", "", heading), node("p", "", body)); + container.append(step); + }); +} + +function openSchemaInspector() { + if (!state.warehouse) return; + const select = byId("schema-table-select"); + select.replaceChildren(); + state.warehouse.table_schemas.forEach((table) => select.append(option(table.name, table.name))); + if (state.warehouse.table_schemas.some((table) => table.name === "posts_raw")) select.value = "posts_raw"; + renderSelectedSchema(); + byId("schema-dialog").showModal(); +} + +function renderSelectedSchema() { + if (!state.warehouse) return; + const table = state.warehouse.table_schemas.find((item) => item.name === byId("schema-table-select").value); + if (!table) return; + byId("schema-table-summary").textContent = `${exact(table.row_count)} rows · ${exact(table.columns.length)} columns`; + renderTable(byId("schema-columns-table"), [ + ["Column", "name"], ["Type", "data_type"], ["Required", "required"], ["Primary key", "primary_key"], + ], table.columns.map((column) => ({ ...column, required: column.not_null ? "yes" : "no", primary_key: column.primary_key_position ? `position ${column.primary_key_position}` : "no" })), null); + const relationships = byId("schema-relationships"); + relationships.replaceChildren(); + relationships.className = "schema-relationships"; + if (!table.foreign_keys.length) { + relationships.append(node("div", "demo-status", "No outbound foreign-key relationships.")); + return; + } + table.foreign_keys.forEach((foreignKey) => { + const row = node("div", "relationship-row"); + row.append(node("span", "mono", foreignKey.from_column), node("span", "mono", `${foreignKey.target_table}.${foreignKey.target_column || "primary key"}`), node("span", "mono muted", foreignKey.on_delete)); + relationships.append(row); + }); +} + function installFilterOptions(options) { fillSelect(filtersForm.elements.source, options.sources); fillSelect(filtersForm.elements.evidence_type, options.evidence_types); @@ -419,11 +520,15 @@ function renderTable(table, columns, rows, onSelect) { const body = document.createElement("tbody"); rows.forEach((row) => { const tr = document.createElement("tr"); - tr.tabIndex = 0; - tr.addEventListener("click", () => onSelect(row)); - tr.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelect(row); }); + if (onSelect) { + tr.tabIndex = 0; + tr.addEventListener("click", () => onSelect(row)); + tr.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelect(row); }); + } else { + tr.classList.add("noninteractive"); + } columns.forEach(([, key, alignment]) => { - const cell = tableCell("td", displayValue(key, row[key]), alignment); + const cell = tableCell("td", displayValue(key, row[key], row), alignment); if ((key === "score" || key === "max_score") && row[key] !== null) cell.classList.add("score-cell", `score-${row[key]}`); if (key === "status" || key === "level" || key === "review_status") cell.classList.add("status-cell", statusClass(row[key])); tr.append(cell); @@ -487,13 +592,18 @@ function tableCell(tag, value, alignment = "") { return cell; } -function displayValue(key, value) { - if (value === null || value === undefined || value === "") return "—"; +function displayValue(key, value, row = {}) { + if (value === null || value === undefined || value === "") { + if (key === "risk_level") return "Not run"; + if (["score", "models", "evidence_type", "behaviour_summary"].includes(key) && row.score === null) return "Not scored"; + return "—"; + } if (Array.isArray(value)) return value.join(", ") || "—"; if (key.endsWith("_at")) return formatUtc(value); if (key === "duration_ms") return duration(value); if (key === "cost_usd") return money(value); if (typeof value === "number") return exact(value); + if (key === "name") return String(value); return String(value).replaceAll("_", " "); } diff --git a/src/loc_observatory/dashboard/static/index.html b/src/loc_observatory/dashboard/static/index.html index 40cf8b7..5279d29 100644 --- a/src/loc_observatory/dashboard/static/index.html +++ b/src/loc_observatory/dashboard/static/index.html @@ -41,7 +41,7 @@

Loss of Control Observatory

Checking Last ingest Freshness - DLQ + Recovery queue Data as of @@ -83,10 +83,10 @@

Evidence overview

-

Reports over time

Daily collection in grey; seven-day average in blue.

+

Source posts by publication date

Grey bars group original Bluesky post timestamps (UTC); the blue line is the seven-day average.

- +
@@ -108,6 +108,8 @@

Evidence overview

Credible report
Score 5–9 under the evidence rubric.
Incident
One or more credible real-world reports after deduplication.
+
Prescreen not run
Outside the bounded 1,000-report model cohort. This is missing processing, not a low result.
+
Publication date
The original source-post time, not when this system collected it.
Not established
Authenticity, prevalence, model propensity, or causal mechanism.
@@ -132,12 +134,29 @@

Evidence overview