Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv*/
__pycache__/
.pytest_cache/
.ruff_cache/
*.egg-info/
dist/
build/
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,52 @@ 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. 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
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
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 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`: 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.

The generated OpenAPI schema is the canonical machine-readable contract for this MVP.
32 changes: 32 additions & 0 deletions docs/mvp-challenges.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions docs/pii-detector-playbook.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions examples/challenge_backlog.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
12 changes: 12 additions & 0 deletions examples/request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"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."
}
]
}
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions src/makeaivisible_anonymizer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Make AI Visible anonymization service."""

__version__ = "0.1.0"
66 changes: 66 additions & 0 deletions src/makeaivisible_anonymizer/detectors.py
Original file line number Diff line number Diff line change
@@ -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"(?<![\w.+-])[\w.+-]+@[\w-]+(?:\.[\w-]+)+", re.I),
"Direct email addresses.",
),
Detector(
"URL",
re.compile(r"\b(?:https?://|www\.)[^\s<>]+", re.I),
"Web links that may expose profiles, schools, or accounts.",
),
Detector(
"PHONE",
re.compile(r"(?<!\w)(?:\+?1[ .-]?)?(?:\(\d{3}\)|\d{3})[ .-]\d{3}[ .-]\d{4}(?!\w)"),
"North American phone-number formats used in synthetic fixtures.",
),
Detector(
"USERNAME",
re.compile(r"(?<![\w@])@[A-Za-z0-9_]{2,32}\b"),
"Social or platform handles.",
),
Detector(
"ACCOUNT_ID",
re.compile(r"\b(?:account|user|member)[ _-]?id\s*[:=#]\s*[A-Za-z0-9_-]{4,64}\b", re.I),
"Explicitly labeled account, user, or member identifiers.",
),
)


DETECTOR_GAPS = (
{
"entity_type": "PERSON_NAME",
"challenge": 4,
"status": "needs layered detection and expert review",
"edit_hint": "Add synthetic fixtures in tests/test_redaction.py before enabling a detector.",
},
{
"entity_type": "SCHOOL",
"challenge": 4,
"status": "needs curated examples and false-positive review",
"edit_hint": "Start with examples/challenge_backlog.json and avoid real student data.",
},
{
"entity_type": "ADDRESS",
"challenge": 4,
"status": "needs locale-aware detection",
"edit_hint": "Cover street, city, postal-code, and near-miss examples.",
},
{
"entity_type": "INDIRECT_IDENTIFIER",
"challenge": 4,
"status": "needs policy decision",
"edit_hint": "Document risky context combinations before writing automated rules.",
},
)
53 changes: 53 additions & 0 deletions src/makeaivisible_anonymizer/limits.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading