Developer guide¶
Day-to-day work in this repository. Architecture is in
Architecture; the exhaustive conventions live in
AGENTS.md.
Commands¶
# Backend
uv run uvicorn backend.src.main:app --reload --host 0.0.0.0 --port 8000
uv run ruff check backend/ && uv run ruff format backend/
uv run pytest
# Frontend
npm run dev
npm run check # prettier + eslint + vue-tsc + i18n catalogs
npm test # vitest
npm run build
# End to end
npm run test:e2e
npm run screenshots
# Docs
uv run --only-group docs mkdocs serve
Adding a feature¶
- Schema —
schemas/entities.pyfor domain vocabulary,schemas/anonymize.pyfor the request/response contract. - Domain logic — a module under
utils/, pure where possible. An external call belongs inservices/with its own*Errorcarrying astatus_code. - Endpoint — a module under
routers/v1/endpoints/, registered inrouters/v1/api.py. - Config — a field on
Settingsplus a documented block in.env.example(and a row in Configuration if users need it). - Frontend — mirror the type in
frontend/types/anonymizer.ts, add the call to aservices/*Api.tsmodule, put state on the active document instores/session.ts, build the UI fromcomponents/common/. - Text — every user-visible string is a message key in
frontend/locales/de.json(the source catalog) plus the same key inen/fr/es.json. See Translations. - Tests — unit for the logic, integration for the route, Vitest for a new frontend helper, e2e if the user-visible workflow changed.
- Docs + changelog — the affected page, plus
CHANGELOG.mdwhen it affects users or setup. Re-runnpm run screenshotsfor a documented screen.
Translations¶
The UI ships in German, English, French and Spanish. frontend/locales/de.json
is the source of truth (the app is authored in German) and the fallback for
every other locale; the rest are lazy-loaded when the user switches.
npm run i18n:check # catalogs in sync with de.json, placeholders intact, messages compile
npm run i18n:usage # every literal $t('key') exists in de.json
Both run inside npm run check, so a forgotten translation fails the gate
rather than rendering a raw key in production.
Rules worth knowing:
- No literal strings in components. Use
useI18n()in a component and the exportedtfrom@/i18nin stores/utils. - Backend messages are codes, not prose. Warnings and notices carry a
stable
codeplusparams(backend/src/utils/notices.py) which the frontend renders fromwarnings.codes.*; the backend's Englishmessagestays the fallback for a code the catalogs do not know yet. Adding a backend warning therefore means adding its key to all four catalogs —backend/tests/unit/test_notices.pyenforces exactly that. - Escape
@and|in messages ("a{'@'}b.de"): vue-i18n reads them as linked-message and plural syntax.npm run i18n:checkcatches it. - Numbers go through
Intl. UseformatPercent/formatDecimalfrom@/utils/format— the separators and the space before%differ per language. - Two languages, not one. The interface language is a UI preference; the
output language decides what a run writes into the document (placeholders
plus the AI re-check's notes). It is captured at submit and travels with
every request of that run, so a finished document never changes language.
Labels live in
PLACEHOLDERS(backend/src/utils/policy.py), mirrored into the catalogs underplaceholders.*for the policy editor's previews and pinned bybackend/tests/unit/test_placeholders.py.
Adding a detector¶
Implement the SpanDetector protocol — name, version,
async detect(text) -> DetectionOutcome — then:
- register it in
build_detectors()and teachdetector_ready()what "configured" means for it, - raise
DetectorErrorwhen it is enabled but cannot run. Never return fewer spans instead: a document that was not fully checked must not look like one that passed, - return spans whose
textmatches the source exactly;validate_spans()will reject anything else and warn, - if it produces mention strings rather than offsets, route them through
utils/grounding.pyinstead of writing new locating logic, - add it to
DETECTORSin.env.exampleand to Configuration.
Adding an OCR engine¶
Add a service class in services/ with its own *Result and *Error, then a
branch in extract_pdf(). Requirements:
- fail closed — a page that cannot be transcribed fails the document rather than yielding a partial transcript,
- return
source_type="pdf-ocr"and a recognition-error warning, - emit
LayoutLineentries if you can: they are what makes the reconstructed redacted PDF possible, - respect a global concurrency cap via
utils/concurrency.py, - document it in OCR engines.
Things that will bite you¶
Offsets. Backend offsets are Unicode code points. Frontend code that maps
offsets to DOM text must go through Array.from() like
utils/textSegments.ts — String.prototype.slice silently breaks on astral
characters.
Policy drift. frontend/utils/policy.ts mirrors the backend
DEFAULT_POLICY. Only deviations are sent, so a drifted mirror sends nothing
and displays a transformation the backend never applied. A Vitest spec pins
the key defaults; update both sides together.
Logging. get_safe_logger(__name__), always. The filter works on field
names: logger.info("done", chars=len(text)) is right,
logger.info(f"text: {text}") defeats it entirely.
The cache is not persistence. A 410 is normal and handled. Do not extend the TTL indefinitely or write it to disk to "fix" it.
Fixtures. Synthetic only, marked as such. They end up in the repository, in test output, and — via the screenshot harness — in the public docs site.
Configuration in tests¶
backend/tests/conftest.py sets ENV_PATH to a nonexistent file before any
backend import, so a developer's .env can never leak into a test
run. Keep that invariant when you add fixtures. The Playwright harness does the
same with an explicit ENV_PATH=backend/.env.e2e.
get_settings() is lru_cached — clear it (get_settings.cache_clear()) when
a test needs different settings.
CI¶
.github/workflows/ holds tests.yml, docs.yml, security.yml, and
docker-publish.yml. All four are workflow_dispatch-only: the repository
is private and Actions minutes are billed. The real push/pull_request
triggers sit commented out at the top of each file, ready to be uncommented
when the repository goes public.
Practical consequence: nothing gates your commit. Run the full local gate from Contributing, and trigger the workflows manually from the Actions tab before a release.
Dependabot (.github/dependabot.yml) is active — it does not consume Actions
minutes.
Releasing¶
- Bump the version in
package.json,pyproject.toml,CITATION.cff, and theAPP_VERSIONdefault incore/config.py(it is what/api/v1/statusreports). uv lockif dependencies changed.- Move the
[Unreleased]entries inCHANGELOG.mdinto a dated section. - Run the full local gate, plus
npm run test:e2e. - Tag:
git tag v0.2.0 && git push origin v0.2.0. Publishing a release is what triggersdocker-publish.yml— which currently needs a manual run.