Real project
Observatory — Privacy-first AI triage for citizen feedback
A white-label platform that anonymizes citizen feedback, uses AI only as a recommendation, keeps people in charge of the final call and turns it all into indicators without exposing anyone.

- original texts sent to the AI or stored in the database
- 0
- minimum AI confidence to classify; below that, a person decides
- 70%
- public collection sources, processed in the background
- 3
- automated tests across API and interface
- 41
- Year
- 2026
- Role
- Full-stack & architecture
- Client
- Prova de conceito
- Stack
- Python
- FastAPI
- PostgreSQL
- SQLAlchemy
- Presidio
- spaCy
- Gemini
- Playwright
- Next.js
- React
- TanStack Query
- Docker
Ombudsman offices receive complaints, praise, suggestions and reports every day by phone, email and web portals. These texts are usually full of personal data — names, tax IDs, phone numbers — and someone has to read each one to decide the right department and urgency. It's slow, and managers get no big picture.
The Observatory is a proof of concept for that workflow. It protects the text before anything else, asks an AI for a classification only as a suggestion, sends uncertain cases to human review, collects feedback from authorized public sources in the background and shows aggregated indicators — never individual content.
I built both ends: a Python API with a collection worker and a Next.js front end. The product is white-label: name, tagline and description come from environment variables, with no component changes. Below is a real operator journey, with screenshots taken from the running system and fictitious data.
1. Restricted access
There is no public sign-up: only an admin creates users. There are two roles — operators handle feedback and view collections and indicators; admins also manage users, catalogs, sources and collections.
Login goes through a BFF (back-end for front-end) inside Next.js itself. It exchanges email and password for a JWT from the API and keeps the token in an HttpOnly cookie. Browser JavaScript never sees the token: every call goes to the Next.js proxy, which adds the authorization header when talking to the API.
- Passwords hashed with Argon2; tokens are short-lived and the API checks signature, user and role on every call.
- The proxy also blocks admin routes for non-admins, as an extra layer on top of the API's own checks.
- The API refuses to delete your own user or the last remaining admin.

2. Triage: privacy before AI
The operator pastes the feedback, picks the source channel and chooses whether to get AI help. The rule behind everything else: the text is protected on the server before any other step, and only the protected version moves on.
Anonymization combines two techniques. Microsoft Presidio, with spaCy's Portuguese model, recognizes entities such as people's names. Custom rules catch Brazilian formats: CPF (tax ID), RG (ID card, including the Minas Gerais format), phone and email. Then the same patterns run again over the result, as defense in depth. Each match becomes a placeholder: <CPF>, <EMAIL>, <TELEFONE>, <PESSOA>.
To avoid logging the same feedback twice, the API computes an HMAC of the normalized text and rejects repeats from the same source. The original text is never even stored: the field stays null until formal encryption and retention rules exist.
def anonymize(text: str) -> str:
results = get_analyzer().analyze(text=text, language="pt")
anonymized = get_anonymizer().anonymize(
text=text, analyzer_results=results, operators=operators,
).text
# Defesa em profundidade para os formatos brasileiros.
anonymized = re.sub(CPF_PATTERN, "<CPF>", anonymized)
anonymized = re.sub(RG_PATTERN, "<RG>", anonymized)
anonymized = re.sub(EMAIL_PATTERN, "<EMAIL>", anonymized)
return re.sub(PHONE_PATTERN, "<TELEFONE>", anonymized)

3. AI as a recommendation, not a decision
When AI is on, Gemini receives only the anonymized text and the active catalog of areas and subjects, with their IDs. The answer must be structured JSON — type (praise, complaint, suggestion or report), area, subject, urgency, suggested destination, justification and a confidence score — validated by a Pydantic schema.
Well-formed JSON isn't enough. The API checks that the area and subject exist, are active and belong together. If confidence falls below 70%, the provider fails, the answer is invalid or there is no catalog, the feedback isn't force-classified: it stays in human review.
Every classification stores the model used and the prompt version, so it's possible to audit later why a piece of feedback ended up in a given area.
classification, model = classify_text(anonymized_text, catalog)
if not is_valid_classification_pair(db, classification.area_id, classification.subject_id):
raise LlmUnavailableError("Área e assunto fora do catálogo ativo.")
manifestation.requires_human_review = (
classification.confidence < settings.llm_confidence_threshold # 0.7
)
manifestation.status = (
"em_revisao" if manifestation.requires_human_review else "classificado"
)4. Human review and deadlines
Anything the AI couldn't settle safely lands in a review queue. The operator opens the record, sees only the protected text and confirms area, subject, type, urgency, destination and a justification. The API validates the area–subject pair again and records who reviewed it and when.
Urgency sets the deadline (SLA), counted in business days: 3 for high, 10 for medium and 20 for low, with configurable holidays. Operators review pending items; admins can also correct an item that's already classified — for operators, that attempt returns 403.
def sla_due_at(urgency: str, registered_at: datetime) -> datetime:
days = {"alta": 3, "media": 10, "baixa": 20}.get(urgency, 10)
due_at, added = registered_at, 0
while added < days:
due_at += timedelta(days=1)
if due_at.weekday() < 5 and due_at.date().isoformat() not in holidays:
added += 1
return due_at


5. Background collection
Besides manual entry, the system fetches feedback from public sources: the Fala.BR open-data file (a ZIP with a CSV), the Minas Gerais Open Data portal (through the portal's catalog API) and Reclame Aqui, read with a Playwright-controlled browser.
Collecting takes minutes, so it can't hold an HTTP request open. The API only records an execution as pending and answers 202 Accepted. A separate worker claims the next pending item with SELECT … FOR UPDATE SKIP LOCKED — so multiple workers never grab the same task —, runs it with a timeout and a maximum number of attempts, and checks for a cancel request every second.
Every collected item goes through exactly the same path as manual triage: anonymization, deduplication and classification. Execution logs keep only metadata and counters, never the content.
- A source is only available when it's active, approved and has its terms of use and robots.txt reviewed — all recorded on the source itself.
- The collector doesn't try to get around logins, CAPTCHAs or access blocks.
- If the worker crashes mid-collection, it puts interrupted executions back in the queue on restart.
execution = db.scalar(
select(CollectionExecution)
.where(CollectionExecution.state == PENDING)
.order_by(CollectionExecution.created_at)
.with_for_update(skip_locked=True) # nunca dois workers na mesma tarefa
.limit(1)
)
execution.state = RUNNING
execution.started_at = datetime.now(timezone.utc)
db.commit()

6. The observatory
The manager's screen answers management questions: how much feedback came in, how much is within the deadline, how long it takes to close, what's still waiting for review, and how volume splits by area, channel and weekday.
The key point is what the indicators endpoint does not return: any text. It sends only counts, percentages and series, computed in the database, along with provenance — which filters were applied, which sources were counted and when it was updated. Filters live in the URL, so a view can be shared with a link.



Behind the screens
The front end is a Turborepo monorepo: the Next.js 16 / React 19 app and a shared component package (shadcn/Base UI). TanStack Query handles server state — a review, for example, invalidates the list and the indicators. Forms use React Hook Form with Zod, and adapters validate every API response before it reaches components.
The API is FastAPI with Pydantic, SQLAlchemy 2 and Alembic: PostgreSQL in production, SQLite for local runs. Schemas accept field names in both Portuguese and English. A middleware caps body size (100 KB) and triage rate (30 per minute, counted in the database) and attaches a correlation ID to every request. The API and worker are separate processes sharing the database; everything runs in Docker and is deployed with Dokploy.
I also wrote a public documentation page inside the app, explaining architecture, decisions and limits for whoever reviews the project. There are 35 Pytest tests — anonymization, security, feedback, catalogs, collection, worker and functional requirements — and 6 Vitest tests on the front end.

What I learned
Testing with realistic texts showed the cost of erring on the safe side. The language model sometimes mistakes common words for entities: in “Liguei para a secretaria” (“I called the office”), “Liguei” became <LOCATION>, and a person's name came out labeled as a place. No personal data ever leaked — but the text gets harder to read for the reviewer.
That was a deliberate choice: in a system handling citizens' data, masking too much is far better than masking too little. The next step is calibration — narrowing the entities Presidio considers and tuning confidence thresholds — while keeping the custom rules that catch tax IDs, phones and emails deterministically.
To take it past proof of concept, I'd prioritize encryption and a retention policy for any authorized sensitive data, a persistent audit trail, distributed rate limiting, institutional SSO and a legal review of every source.