From 91fddcfece265e929544189b1d0335d40c76d60a Mon Sep 17 00:00:00 2001 From: Alp Date: Mon, 29 Jun 2026 18:20:28 -0700 Subject: [PATCH 1/3] Build deterministic anonymization API --- .github/workflows/ci.yml | 11 +-- .gitignore | 7 ++ README.md | 40 +++++++++++ examples/request.json | 13 ++++ pyproject.toml | 32 +++++++++ src/makeaivisible_anonymizer/__init__.py | 3 + src/makeaivisible_anonymizer/main.py | 46 ++++++++++++ src/makeaivisible_anonymizer/models.py | 51 ++++++++++++++ src/makeaivisible_anonymizer/redaction.py | 82 +++++++++++++++++++++ tests/test_api.py | 86 +++++++++++++++++++++++ tests/test_redaction.py | 41 +++++++++++ 11 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 examples/request.json create mode 100644 pyproject.toml create mode 100644 src/makeaivisible_anonymizer/__init__.py create mode 100644 src/makeaivisible_anonymizer/main.py create mode 100644 src/makeaivisible_anonymizer/models.py create mode 100644 src/makeaivisible_anonymizer/redaction.py create mode 100644 tests/test_api.py create mode 100644 tests/test_redaction.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c8b11..ecc7527 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Placeholder - run: | - python --version - echo "Add linting and tests with the first implementation PR." + cache: pip + - name: Install + run: python -m pip install --upgrade pip && pip install -e '.[dev]' + - name: Lint + run: ruff check . + - name: Test + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3a6398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv*/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md index e92016c..724eb4d 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,43 @@ Raw conversations are considered highly sensitive. The MVP must treat raw text a ## First Milestone Expose a local `/anonymize` endpoint with deterministic redaction tests and a documented anonymized output schema. + +## Current Baseline + +The repository now includes a runnable deterministic baseline. It detects emails, phone +numbers, URLs, usernames, and explicitly labeled account IDs. It processes requests in +memory and does not include a database or file persistence layer. + +This baseline is not sufficient for production or real contributor data. Names, schools, +free-form addresses, indirect identifiers, and context-dependent identifiers require a +layered detector and expert privacy review. + +### Run locally + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e '.[dev]' +uvicorn makeaivisible_anonymizer.main:app --reload +``` + +Open `http://127.0.0.1:8000/docs` for the generated API documentation, or submit the +synthetic example directly: + +```bash +curl -s http://127.0.0.1:8000/anonymize \ + -H 'content-type: application/json' \ + --data @examples/request.json +``` + +### Contract + +`POST /anonymize` accepts a conversation identifier and a non-empty list of messages. +The response contains: + +- `schema_version`: version of the anonymized record contract. +- `conversation_id`: caller-provided identifier; callers must not use a personal identifier. +- `messages`: roles and redacted message content. +- `redaction_report`: counts and source spans without the original sensitive values. + +The generated OpenAPI schema is the canonical machine-readable contract for this MVP. diff --git a/examples/request.json b/examples/request.json new file mode 100644 index 0000000..d9fcd3c --- /dev/null +++ b/examples/request.json @@ -0,0 +1,13 @@ +{ + "conversation_id": "synthetic-demo-1", + "messages": [ + { + "role": "user", + "content": "My email is maya@example.org and my username is @maya_student." + }, + { + "role": "assistant", + "content": "I will not contact you outside this conversation." + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..381ecfd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "makeaivisible-anonymizer" +version = "0.1.0" +description = "Privacy-preserving conversation anonymization service" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1", + "pydantic>=2.10,<3", + "uvicorn[standard]>=0.34,<1", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.28,<1", + "pytest>=8.3,<9", + "ruff>=0.11,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/makeaivisible_anonymizer"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/src/makeaivisible_anonymizer/__init__.py b/src/makeaivisible_anonymizer/__init__.py new file mode 100644 index 0000000..966d71f --- /dev/null +++ b/src/makeaivisible_anonymizer/__init__.py @@ -0,0 +1,3 @@ +"""Make AI Visible anonymization service.""" + +__version__ = "0.1.0" diff --git a/src/makeaivisible_anonymizer/main.py b/src/makeaivisible_anonymizer/main.py new file mode 100644 index 0000000..7cc4b5e --- /dev/null +++ b/src/makeaivisible_anonymizer/main.py @@ -0,0 +1,46 @@ +from collections import defaultdict + +from fastapi import FastAPI + +from . import __version__ +from .models import ( + AnonymizeRequest, + AnonymizeResponse, + ConversationMessage, + RedactionSummary, +) +from .redaction import count_by_type, redact_text + +app = FastAPI( + title="Make AI Visible Anonymization Service", + description="Deterministic baseline for removing direct identifiers from conversations.", + version=__version__, +) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok", "version": __version__} + + +@app.post("/anonymize", response_model=AnonymizeResponse) +def anonymize(request: AnonymizeRequest) -> AnonymizeResponse: + counters: defaultdict[str, int] = defaultdict(int) + messages: list[ConversationMessage] = [] + redactions = [] + + for index, message in enumerate(request.messages): + content, message_redactions = redact_text(message.content, index, counters) + messages.append(ConversationMessage(role=message.role, content=content)) + redactions.extend(message_redactions) + + return AnonymizeResponse( + schema_version="0.1.0", + conversation_id=request.conversation_id, + messages=messages, + redaction_report=RedactionSummary( + total=len(redactions), + by_type=count_by_type(redactions), + redactions=redactions, + ), + ) diff --git a/src/makeaivisible_anonymizer/models.py b/src/makeaivisible_anonymizer/models.py new file mode 100644 index 0000000..33ddb6a --- /dev/null +++ b/src/makeaivisible_anonymizer/models.py @@ -0,0 +1,51 @@ +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class Role(StrEnum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class ConversationMessage(BaseModel): + model_config = ConfigDict(extra="forbid") + + role: Role + content: str = Field(min_length=1, max_length=50_000) + + @field_validator("content") + @classmethod + def content_must_not_be_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("content must not be blank") + return value + + +class AnonymizeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + conversation_id: str = Field(min_length=1, max_length=128) + messages: list[ConversationMessage] = Field(min_length=1, max_length=1_000) + + +class Redaction(BaseModel): + message_index: int + entity_type: str + start: int + end: int + replacement: str + + +class RedactionSummary(BaseModel): + total: int + by_type: dict[str, int] + redactions: list[Redaction] + + +class AnonymizeResponse(BaseModel): + schema_version: str + conversation_id: str + messages: list[ConversationMessage] + redaction_report: RedactionSummary diff --git a/src/makeaivisible_anonymizer/redaction.py b/src/makeaivisible_anonymizer/redaction.py new file mode 100644 index 0000000..3a11224 --- /dev/null +++ b/src/makeaivisible_anonymizer/redaction.py @@ -0,0 +1,82 @@ +import re +from collections import Counter, defaultdict +from dataclasses import dataclass + +from .models import Redaction + + +@dataclass(frozen=True) +class Detector: + entity_type: str + pattern: re.Pattern[str] + + +DETECTORS = ( + Detector("EMAIL", re.compile(r"(?]+", re.I)), + Detector( + "PHONE", + re.compile(r"(? list[Match]: + candidates = [ + Match(detector.entity_type, match.start(), match.end()) + for detector in DETECTORS + for match in detector.pattern.finditer(text) + ] + candidates.sort(key=lambda item: (item.start, -(item.end - item.start))) + + accepted: list[Match] = [] + for candidate in candidates: + if any(candidate.start < item.end and item.start < candidate.end for item in accepted): + continue + accepted.append(candidate) + return accepted + + +def redact_text( + text: str, + message_index: int, + counters: defaultdict[str, int], +) -> tuple[str, list[Redaction]]: + matches = _matches(text) + output: list[str] = [] + redactions: list[Redaction] = [] + cursor = 0 + + for match in matches: + counters[match.entity_type] += 1 + replacement = f"[{match.entity_type}_{counters[match.entity_type]}]" + output.extend((text[cursor : match.start], replacement)) + redactions.append( + Redaction( + message_index=message_index, + entity_type=match.entity_type, + start=match.start, + end=match.end, + replacement=replacement, + ) + ) + cursor = match.end + + output.append(text[cursor:]) + return "".join(output), redactions + + +def count_by_type(redactions: list[Redaction]) -> dict[str, int]: + return dict(sorted(Counter(item.entity_type for item in redactions).items())) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..75dd987 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,86 @@ +from fastapi.testclient import TestClient + +from makeaivisible_anonymizer.main import app + + +client = TestClient(app) + + +def test_health() -> None: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok", "version": "0.1.0"} + + +def test_anonymizes_synthetic_conversation_without_echoing_values() -> None: + sensitive_values = ( + "maya@example.org", + "+1 604-555-0199", + "https://example.org/profile/maya", + "@maya_student", + "user_id: abc_12345", + ) + response = client.post( + "/anonymize", + json={ + "conversation_id": "synthetic-demo-1", + "messages": [ + {"role": "user", "content": f"Email me at {sensitive_values[0]}."}, + { + "role": "assistant", + "content": "Contact " + ", ".join(sensitive_values[1:]), + }, + ], + }, + ) + + assert response.status_code == 200 + payload = response.json() + serialized = response.text + assert payload["schema_version"] == "0.1.0" + assert payload["redaction_report"]["total"] == 5 + assert payload["redaction_report"]["by_type"] == { + "ACCOUNT_ID": 1, + "EMAIL": 1, + "PHONE": 1, + "URL": 1, + "USERNAME": 1, + } + for value in sensitive_values: + assert value not in serialized + + +def test_replacements_are_stable_within_a_request() -> None: + response = client.post( + "/anonymize", + json={ + "conversation_id": "synthetic-demo-2", + "messages": [ + {"role": "user", "content": "First: a@example.org"}, + {"role": "user", "content": "Second: b@example.org"}, + ], + }, + ) + + assert response.status_code == 200 + messages = response.json()["messages"] + assert messages[0]["content"] == "First: [EMAIL_1]" + assert messages[1]["content"] == "Second: [EMAIL_2]" + + +def test_rejects_blank_messages_and_unknown_fields() -> None: + blank = client.post( + "/anonymize", + json={"conversation_id": "bad", "messages": [{"role": "user", "content": " "}]}, + ) + unknown = client.post( + "/anonymize", + json={ + "conversation_id": "bad", + "messages": [{"role": "user", "content": "hello", "raw_name": "not allowed"}], + }, + ) + + assert blank.status_code == 422 + assert unknown.status_code == 422 diff --git a/tests/test_redaction.py b/tests/test_redaction.py new file mode 100644 index 0000000..487db1e --- /dev/null +++ b/tests/test_redaction.py @@ -0,0 +1,41 @@ +from collections import defaultdict + +import pytest + +from makeaivisible_anonymizer.redaction import redact_text + + +@pytest.mark.parametrize( + ("source", "expected_type"), + [ + ("student+test@example.co.uk", "EMAIL"), + ("604-555-0100", "PHONE"), + ("(604) 555-0100", "PHONE"), + ("www.example.ca/path?q=1", "URL"), + ("@student_01", "USERNAME"), + ("account-id: ABCD_1234", "ACCOUNT_ID"), + ], +) +def test_supported_identifier_types(source: str, expected_type: str) -> None: + result, redactions = redact_text(source, 0, defaultdict(int)) + + assert source not in result + assert len(redactions) == 1 + assert redactions[0].entity_type == expected_type + assert result == f"[{expected_type}_1]" + + +@pytest.mark.parametrize( + "source", + [ + "Version 1.2.3 is available", + "The score was 604 points", + "Use the word at without a handle", + "example dot org", + ], +) +def test_common_near_misses_are_not_redacted(source: str) -> None: + result, redactions = redact_text(source, 0, defaultdict(int)) + + assert result == source + assert redactions == [] From 846cc10f85a8d73dcfcb921daee08e6b04ce9293 Mon Sep 17 00:00:00 2001 From: Alp Date: Mon, 29 Jun 2026 18:46:02 -0700 Subject: [PATCH 2/3] Harden anonymization request boundaries --- README.md | 10 ++-- examples/request.json | 1 - src/makeaivisible_anonymizer/limits.py | 53 ++++++++++++++++++++++ src/makeaivisible_anonymizer/main.py | 3 +- src/makeaivisible_anonymizer/models.py | 18 ++++++-- tests/test_api.py | 63 ++++++++++++++++++++++---- 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 src/makeaivisible_anonymizer/limits.py diff --git a/README.md b/README.md index 724eb4d..6ca5ae8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ Expose a local `/anonymize` endpoint with deterministic redaction tests and a do The repository now includes a runnable deterministic baseline. It detects emails, phone numbers, URLs, usernames, and explicitly labeled account IDs. It processes requests in -memory and does not include a database or file persistence layer. +memory and does not include a database or file persistence layer. The service generates +an opaque conversation UUID rather than accepting an external identifier, and rejects +requests containing more than one million message characters. HTTP request bodies are +also capped at five megabytes before JSON parsing. This baseline is not sufficient for production or real contributor data. Names, schools, free-form addresses, indirect identifiers, and context-dependent identifiers require a @@ -58,11 +61,12 @@ curl -s http://127.0.0.1:8000/anonymize \ ### Contract -`POST /anonymize` accepts a conversation identifier and a non-empty list of messages. +`POST /anonymize` accepts a non-empty list of messages. Caller-supplied conversation or +account identifiers are rejected. The response contains: - `schema_version`: version of the anonymized record contract. -- `conversation_id`: caller-provided identifier; callers must not use a personal identifier. +- `conversation_id`: an opaque UUID generated independently for every request. - `messages`: roles and redacted message content. - `redaction_report`: counts and source spans without the original sensitive values. diff --git a/examples/request.json b/examples/request.json index d9fcd3c..dc11812 100644 --- a/examples/request.json +++ b/examples/request.json @@ -1,5 +1,4 @@ { - "conversation_id": "synthetic-demo-1", "messages": [ { "role": "user", diff --git a/src/makeaivisible_anonymizer/limits.py b/src/makeaivisible_anonymizer/limits.py new file mode 100644 index 0000000..c36f92e --- /dev/null +++ b/src/makeaivisible_anonymizer/limits.py @@ -0,0 +1,53 @@ +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +MAX_REQUEST_BYTES = 5_000_000 + + +class RequestSizeLimitMiddleware: + def __init__(self, app: ASGIApp, max_bytes: int = MAX_REQUEST_BYTES) -> None: + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + content_length = dict(scope.get("headers", [])).get(b"content-length") + if content_length is not None: + try: + if int(content_length) > self.max_bytes: + await self._reject(scope, receive, send) + return + except ValueError: + pass + + body = bytearray() + more_body = True + while more_body: + message = await receive() + if message["type"] == "http.disconnect": + return + body.extend(message.get("body", b"")) + if len(body) > self.max_bytes: + await self._reject(scope, receive, send) + return + more_body = message.get("more_body", False) + + replayed = False + + async def replay_receive() -> Message: + nonlocal replayed + if replayed: + return {"type": "http.request", "body": b"", "more_body": False} + replayed = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + + await self.app(scope, replay_receive, send) + + @staticmethod + async def _reject(scope: Scope, receive: Receive, send: Send) -> None: + response = JSONResponse(status_code=413, content={"detail": "request body too large"}) + await response(scope, receive, send) diff --git a/src/makeaivisible_anonymizer/main.py b/src/makeaivisible_anonymizer/main.py index 7cc4b5e..b48d6cb 100644 --- a/src/makeaivisible_anonymizer/main.py +++ b/src/makeaivisible_anonymizer/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from . import __version__ +from .limits import RequestSizeLimitMiddleware from .models import ( AnonymizeRequest, AnonymizeResponse, @@ -16,6 +17,7 @@ description="Deterministic baseline for removing direct identifiers from conversations.", version=__version__, ) +app.add_middleware(RequestSizeLimitMiddleware) @app.get("/health") @@ -36,7 +38,6 @@ def anonymize(request: AnonymizeRequest) -> AnonymizeResponse: return AnonymizeResponse( schema_version="0.1.0", - conversation_id=request.conversation_id, messages=messages, redaction_report=RedactionSummary( total=len(redactions), diff --git a/src/makeaivisible_anonymizer/models.py b/src/makeaivisible_anonymizer/models.py index 33ddb6a..6b95c8d 100644 --- a/src/makeaivisible_anonymizer/models.py +++ b/src/makeaivisible_anonymizer/models.py @@ -1,6 +1,10 @@ from enum import StrEnum +from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +MAX_TOTAL_CONTENT_CHARS = 1_000_000 class Role(StrEnum): @@ -26,9 +30,17 @@ def content_must_not_be_blank(cls, value: str) -> str: class AnonymizeRequest(BaseModel): model_config = ConfigDict(extra="forbid") - conversation_id: str = Field(min_length=1, max_length=128) messages: list[ConversationMessage] = Field(min_length=1, max_length=1_000) + @model_validator(mode="after") + def total_content_must_fit_limit(self) -> "AnonymizeRequest": + total = sum(len(message.content) for message in self.messages) + if total > MAX_TOTAL_CONTENT_CHARS: + raise ValueError( + f"total message content must not exceed {MAX_TOTAL_CONTENT_CHARS} characters" + ) + return self + class Redaction(BaseModel): message_index: int @@ -46,6 +58,6 @@ class RedactionSummary(BaseModel): class AnonymizeResponse(BaseModel): schema_version: str - conversation_id: str + conversation_id: UUID = Field(default_factory=uuid4) messages: list[ConversationMessage] redaction_report: RedactionSummary diff --git a/tests/test_api.py b/tests/test_api.py index 75dd987..a7b5cca 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,5 @@ +from uuid import UUID + from fastapi.testclient import TestClient from makeaivisible_anonymizer.main import app @@ -24,7 +26,6 @@ def test_anonymizes_synthetic_conversation_without_echoing_values() -> None: response = client.post( "/anonymize", json={ - "conversation_id": "synthetic-demo-1", "messages": [ {"role": "user", "content": f"Email me at {sensitive_values[0]}."}, { @@ -39,6 +40,7 @@ def test_anonymizes_synthetic_conversation_without_echoing_values() -> None: payload = response.json() serialized = response.text assert payload["schema_version"] == "0.1.0" + UUID(payload["conversation_id"]) assert payload["redaction_report"]["total"] == 5 assert payload["redaction_report"]["by_type"] == { "ACCOUNT_ID": 1, @@ -55,7 +57,6 @@ def test_replacements_are_stable_within_a_request() -> None: response = client.post( "/anonymize", json={ - "conversation_id": "synthetic-demo-2", "messages": [ {"role": "user", "content": "First: a@example.org"}, {"role": "user", "content": "Second: b@example.org"}, @@ -69,18 +70,64 @@ def test_replacements_are_stable_within_a_request() -> None: assert messages[1]["content"] == "Second: [EMAIL_2]" -def test_rejects_blank_messages_and_unknown_fields() -> None: +def test_generates_a_new_opaque_id_for_each_request() -> None: + request = {"messages": [{"role": "user", "content": "hello"}]} + + first = client.post("/anonymize", json=request) + second = client.post("/anonymize", json=request) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["conversation_id"] != second.json()["conversation_id"] + + +def test_rejects_blank_messages_and_caller_supplied_identifiers() -> None: blank = client.post( "/anonymize", - json={"conversation_id": "bad", "messages": [{"role": "user", "content": " "}]}, + json={"messages": [{"role": "user", "content": " "}]}, ) - unknown = client.post( + supplied_identifier = client.post( "/anonymize", json={ - "conversation_id": "bad", - "messages": [{"role": "user", "content": "hello", "raw_name": "not allowed"}], + "conversation_id": "provider-account-123", + "messages": [{"role": "user", "content": "hello"}], }, ) assert blank.status_code == 422 - assert unknown.status_code == 422 + assert supplied_identifier.status_code == 422 + + +def test_rejects_requests_over_the_aggregate_content_limit() -> None: + response = client.post( + "/anonymize", + json={"messages": [{"role": "user", "content": "x" * 50_000} for _ in range(21)]}, + ) + + assert response.status_code == 422 + assert "total message content must not exceed 1000000 characters" in response.text + + +def test_rejects_oversized_http_body_before_parsing() -> None: + response = client.post( + "/anonymize", + content=b"x" * 5_000_001, + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json() == {"detail": "request body too large"} + + +def test_rejects_oversized_stream_without_content_length() -> None: + def chunks(): + for _ in range(6): + yield b"x" * 1_000_000 + + response = client.post( + "/anonymize", + content=chunks(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 From c67b092e67f772fa57b7232af0f9acdf9fa88cbd Mon Sep 17 00:00:00 2001 From: Alp Date: Sun, 12 Jul 2026 10:28:45 -0700 Subject: [PATCH 3/3] Add contributor challenge slots --- CONTRIBUTING.md | 2 + README.md | 5 ++ docs/mvp-challenges.md | 32 ++++++++++ docs/pii-detector-playbook.md | 33 ++++++++++ examples/challenge_backlog.json | 74 +++++++++++++++++++++++ src/makeaivisible_anonymizer/detectors.py | 66 ++++++++++++++++++++ src/makeaivisible_anonymizer/redaction.py | 23 +------ tests/test_redaction.py | 9 +++ 8 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 docs/mvp-challenges.md create mode 100644 docs/pii-detector-playbook.md create mode 100644 examples/challenge_backlog.json create mode 100644 src/makeaivisible_anonymizer/detectors.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82f95b4..75cbb72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,8 @@ Thank you for helping Make AI Visible. - Use synthetic or anonymized data in examples, tests, screenshots, and issues. - Keep pull requests focused and explain the privacy impact of the change. - Mark legal, IRB, security, or expert-validation assumptions clearly. +- Pick a challenge from `docs/mvp-challenges.md` and keep edits tied to that challenge. +- Add detector examples in `tests/test_redaction.py` before changing redaction behavior. ## Pull Request Checklist diff --git a/README.md b/README.md index 6ca5ae8..4bb6979 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ This baseline is not sufficient for production or real contributor data. Names, free-form addresses, indirect identifiers, and context-dependent identifiers require a layered detector and expert privacy review. +Contributor challenge slots are tracked in [docs/mvp-challenges.md](docs/mvp-challenges.md). +Detector-specific extension notes live in +[docs/pii-detector-playbook.md](docs/pii-detector-playbook.md), and editable backlog +seed data is available at [examples/challenge_backlog.json](examples/challenge_backlog.json). + ### Run locally ```bash diff --git a/docs/mvp-challenges.md b/docs/mvp-challenges.md new file mode 100644 index 0000000..888cf60 --- /dev/null +++ b/docs/mvp-challenges.md @@ -0,0 +1,32 @@ +# MVP Challenges + +This repository implements challenge 3 and starts challenge 4. The full Make AI Visible +MVP is split into 12 contributor-sized challenges so people can find a useful place to +edit without needing to understand every repository first. + +## Challenge Map + +| # | Repository | Challenge | Best first edit | +| --- | --- | --- | --- | +| 1 | Portal-Frontend | Build mobile-first consent and upload prototype | Copy the anonymization API contract from this repo into a mock upload flow. | +| 2 | Portal-Frontend | Add platform export guidance | Draft teen- and caregiver-friendly export instructions with no secrets or credentials. | +| 3 | Anonymization-Service | Implement anonymized conversation schema and `/anonymize` endpoint | Extend `src/makeaivisible_anonymizer/models.py` or endpoint tests. | +| 4 | Anonymization-Service | Add PII detector test suite | Add synthetic detector cases in `tests/test_redaction.py`. | +| 5 | NLP-Scoring-Engine | Create baseline scoring schema and deterministic rubric scorer | Use anonymized output examples from this service as scoring input fixtures. | +| 6 | NLP-Scoring-Engine | Build evaluation harness for human-label comparison | Define synthetic label fixtures before model-backed scoring. | +| 7 | Dashboard | Build synthetic aggregate dashboard | Consume aggregate-only sample outputs, never individual messages. | +| 8 | Dashboard | Add privacy threshold and suppression rules | Add small-cell suppression examples and tests. | +| 9 | Dataset-Publishing-Pipeline | Create release manifest and aggregate export builder | Require anonymized, reviewed input metadata. | +| 10 | Dataset-Publishing-Pipeline | Draft dataset card and release checklist generator | Generate a review-ready dataset card from release metadata. | +| 11 | Governance-Documentation | Draft data lifecycle and consent documents | Keep unresolved legal, IRB, and expert-review questions visible. | +| 12 | Governance-Documentation | Draft human review and scoring validation protocol | Separate draft tooling from validated research claims. | + +## Current Editable Areas + +- `src/makeaivisible_anonymizer/detectors.py`: add or review detector definitions. +- `tests/test_redaction.py`: add synthetic positive and near-miss examples. +- `examples/challenge_backlog.json`: turn challenge notes into issues or project cards. +- `docs/pii-detector-playbook.md`: document detector rules, assumptions, and known gaps. + +Use only synthetic examples. Do not add real contributor conversations, real student names, +real schools, addresses, screenshots, or exports. diff --git a/docs/pii-detector-playbook.md b/docs/pii-detector-playbook.md new file mode 100644 index 0000000..15eb415 --- /dev/null +++ b/docs/pii-detector-playbook.md @@ -0,0 +1,33 @@ +# PII Detector Playbook + +The MVP detector is intentionally conservative. It is good enough for synthetic demos and +local integration work, not for real contributor data. + +## How To Add A Detector + +1. Add synthetic positive examples and near misses in `tests/test_redaction.py`. +2. Add the detector in `src/makeaivisible_anonymizer/detectors.py`. +3. Confirm the redaction report includes type, span, and replacement metadata. +4. Update this playbook with limitations and review notes. + +## Active Detectors + +| Entity type | Current approach | Known limitation | +| --- | --- | --- | +| `EMAIL` | Regex for common email formats | Does not detect obfuscated emails like `name at example dot org`. | +| `URL` | Regex for `http`, `https`, and `www` links | May include trailing punctuation in unusual prose. | +| `PHONE` | Regex for North American synthetic examples | Not international or locale-aware. | +| `USERNAME` | Regex for `@handle` patterns | Can miss usernames without `@`. | +| `ACCOUNT_ID` | Regex for explicitly labeled account/user/member IDs | Does not infer unlabeled IDs. | + +## Open Detector Gaps + +These are visible in `DETECTOR_GAPS` so contributors can pick them up intentionally: + +- `PERSON_NAME` +- `SCHOOL` +- `ADDRESS` +- `INDIRECT_IDENTIFIER` + +Each gap needs examples, false-positive checks, and privacy review before it should be +enabled for real data. diff --git a/examples/challenge_backlog.json b/examples/challenge_backlog.json new file mode 100644 index 0000000..9e85a10 --- /dev/null +++ b/examples/challenge_backlog.json @@ -0,0 +1,74 @@ +[ + { + "id": 1, + "repository": "Portal-Frontend", + "title": "Build mobile-first consent and upload prototype", + "editable_area": "Create a mock upload flow that calls this service contract." + }, + { + "id": 2, + "repository": "Portal-Frontend", + "title": "Add platform export guidance", + "editable_area": "Draft export instructions for ChatGPT, Claude, Gemini, and Copilot." + }, + { + "id": 3, + "repository": "Anonymization-Service", + "title": "Implement anonymized conversation schema and /anonymize endpoint", + "editable_area": "Extend models, endpoint behavior, and schema tests." + }, + { + "id": 4, + "repository": "Anonymization-Service", + "title": "Add PII detector test suite", + "editable_area": "Add synthetic detector fixtures and near-miss tests." + }, + { + "id": 5, + "repository": "NLP-Scoring-Engine", + "title": "Create baseline scoring schema and deterministic rubric scorer", + "editable_area": "Use anonymized messages as scoring fixtures." + }, + { + "id": 6, + "repository": "NLP-Scoring-Engine", + "title": "Build evaluation harness for human-label comparison", + "editable_area": "Define synthetic human-label fixtures and agreement reports." + }, + { + "id": 7, + "repository": "Dashboard", + "title": "Build synthetic aggregate dashboard", + "editable_area": "Render aggregate-only counts and score distributions." + }, + { + "id": 8, + "repository": "Dashboard", + "title": "Add privacy threshold and suppression rules", + "editable_area": "Implement small-cell suppression fixtures." + }, + { + "id": 9, + "repository": "Dataset-Publishing-Pipeline", + "title": "Create release manifest and aggregate export builder", + "editable_area": "Validate reviewed anonymized records before release." + }, + { + "id": 10, + "repository": "Dataset-Publishing-Pipeline", + "title": "Draft dataset card and release checklist generator", + "editable_area": "Generate review-ready dataset-card drafts." + }, + { + "id": 11, + "repository": "Governance-Documentation", + "title": "Draft data lifecycle and consent documents", + "editable_area": "Document lifecycle, retention, deletion, and consent assumptions." + }, + { + "id": 12, + "repository": "Governance-Documentation", + "title": "Draft human review and scoring validation protocol", + "editable_area": "Define reviewer rules, calibration, and validation metrics." + } +] diff --git a/src/makeaivisible_anonymizer/detectors.py b/src/makeaivisible_anonymizer/detectors.py new file mode 100644 index 0000000..5321653 --- /dev/null +++ b/src/makeaivisible_anonymizer/detectors.py @@ -0,0 +1,66 @@ +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Detector: + entity_type: str + pattern: re.Pattern[str] + notes: str + + +DETECTORS = ( + Detector( + "EMAIL", + re.compile(r"(?]+", re.I), + "Web links that may expose profiles, schools, or accounts.", + ), + Detector( + "PHONE", + re.compile(r"(?]+", re.I)), - Detector( - "PHONE", - re.compile(r"(? None: + active_types = {detector.entity_type for detector in DETECTORS} + gap_types = {gap["entity_type"] for gap in DETECTOR_GAPS} + + assert active_types == {"ACCOUNT_ID", "EMAIL", "PHONE", "URL", "USERNAME"} + assert {"ADDRESS", "INDIRECT_IDENTIFIER", "PERSON_NAME", "SCHOOL"} <= gap_types + + @pytest.mark.parametrize( ("source", "expected_type"), [