Curation¶
This page is for the archivist who works in the curator SPA. It says, screen by screen, which decision is taken there, what the click writes, and whether it can be undone — and it names what has no way back at all.
The SPA is served by the API itself (one origin, no CORS), and every screen reads and writes through
the client generated from the OpenAPI contract. The screens are declared in
apps/curator/src/router.tsx, and what each one is called in
apps/curator/src/lib/screens.ts: one record per screen holds the label the menu, the settings card
and the page's own heading all read, so the three cannot disagree (One name per screen, one verb per
action, below). The menu has 16 entries: the screens nobody opens in the middle of cataloguing —
the arrangement plan, the catalogues, the worker panel, the run ledger, the diagnostics and the
accounts — are cards of /configuracoes rather than menu entries (Configurações, below). One route
is not reached from the menu either: the description's own dossier, /acervo/$descriptionId, comes
from the list and from the tree. The router therefore declares 26 screens. The menu itself can be
collapsed to icons, which is a presentation choice and not a permission one — The menu collapses to
icons, below.
Two cross-cutting pages are worth reading with this one: Data model for what the ledgers and the tables mean, and Installation and deployment for the schema, the accounts and the deployment around the screens.
The one rule¶
Every write either has an undo, or it announces that it does not. The merge, the stopwords purge and the NER purge keep a ledger; editing a description keeps the before and after. When a screen has neither, this page says so in bold.
Read-only work declares no permission: reading is what an authenticated session is. The screens that decide carry the area they write to, and the API refuses with a 403 and a sentence when the account does not carry it.
What has no way back¶
These writes have no undo
The operations below delete or dissolve a row that nothing else rebuilds. The deletion asks you to type the reference code. Read the confirmation.
-
Excluding a description —
DELETE /api/v1/documents/{description_id}. Definitive. The API writes an ISAD(G) snapshot of the whole row into the deletion ledger first, and that snapshot is a trail, not a bin:/acervo/excluidasshows it and nothing restores it. The revision ledger does not survive (it cascades with the description), which is why the snapshot exists. A description with children cannot be excluded — the service refuses and says how many are below; exclude or move the children first. The screen asks the archivist to type the reference code (or the id, when there is no code) before the button unlocks. -
Deleting or purging entities without links —
DELETE /api/v1/taxonomy/entities/{entity_id}andPOST /api/v1/taxonomy/entities/orphans/purge. The screen offers the delete only on a row whose use count is zero, and the purge removes every entity no description carries, so no link is lost — but the row is gone and there is no ledger to restore it.
The batch that does have an undo¶
Materialising the arrangement from the plan (POST /api/v1/hierarchy/materialisation/apply) is
reversible, and the reversal is on the screen. It runs only after a preview
(POST /api/v1/hierarchy/materialisation/preview, computed by the same planner) and writes a ledger
entry per run (GET /api/v1/hierarchy/materialisation/log). The undo is
DELETE /api/v1/hierarchy/materialisation/log/{materialisation_id}, and it restores exactly what the
run changed:
- every description it moved goes back to the parent and level it had before;
- the nodes the run created are deleted — made possible by restoring first, because the
self-reference is
RESTRICT; - the plans that pointed at a deleted node go back to "not materialised", so the next apply does not attach descriptions to a row that no longer exists.
The undo refuses when a later run attached descriptions the ledger does not know about, because
reversing then would silently detach work that was never part of this decision. Reversing a finished
run is a new, recorded fact: the entry keeps undone_at and who did it.
Review, diffusion and what the AI may not rewrite¶
The review lifecycle¶
review_status is the lifecycle of the human validation over the AI's work. The five values are
PENDING_AI, AI_APPROVED, NEEDS_REVIEW, HUMAN_APPROVED and REJECTED. HUMAN_APPROVED is
the shield: the AI pipeline's write predicate excludes it, so no report worker silently rewrites a
description the archivist validated. Editing any ISAD(G) field, or the description's tags and
entities, sets HUMAN_APPROVED through PATCH /api/v1/documents/{description_id} (or the link
routes), records the before and after in archive_document_revisions, and takes the description out
of the AI's queue.
The two documented exceptions¶
worker_embeddingdeliberately does not use the AI write predicate. The embedding is a derived index of the text, not archival content: aHUMAN_APPROVEDdescription whose text changed must be re-embedded, or semantic search would serve a stale vector. It writes only theembeddingcolumn and its own stamp (the MD5 of the effective text), and it skipsREJECTEDdescriptions.- Publishing is not a lock.
is_publishedis chosen through the samePATCHas any ISAD(G) field, and it does not freeze the description against the AI. The write predicate readsreview_statusonly. Reusing the review status for diffusion would have made a typo fix equivalent to publishing, and would have locked every published description against AI improvement.
The other AI workers are excluded from a HUMAN_APPROVED description; what a human decided is not
rewritten by the pipeline.
Diffusion is not review¶
Review is a claim about the record; publication is a decision about what to expose.
is_published is its own column, edited with the "Publicar na difusão" checkbox on the dossier's
Descrição tab, and the public predicate is exactly is_published. Nothing is published by default,
so the diffusion surface is born empty.
Publishing exposes the description through the open, unauthenticated surface:
GET /api/v1/public/documentsandGET /api/v1/public/documents/{description_id}.
Two rules hold there. The published_only filter is set server-side and is not a client
parameter, so no query string can ask for an unpublished record; and an unpublished id answers 404,
never 403, because a distinct status would confirm that the description exists.
The public projection is narrower than the curator's, field by field. It carries the ISAD(G) fields
original_title, final_title, document_date, reference_code, level, scope_content,
language_name and producers; the enrichment typology, the tags (name), the entities (name and
type) and the subject categories (name and count); the thumbnail — still the stored s3:// reference,
because the public route that serves the bytes is #46 (ADR 0017); the branch (ancestors) and the
children_count. It deliberately does not carry the curation process or the unpublished
narrative: review_status, is_anomaly, anomaly_reasons, archivist_notes, provenance,
suggested_final_title, admin_bio_history, admin_archival_history and access_conditions. The
public facet set is also narrower — anomaly_reason is not published.
Who may decide what¶
Authorization is a declared level per operation, enforced by one guard. The four write permissions are areas, not buttons:
| Permission | The area |
|---|---|
CURATE |
the record and its subjects: the ISAD(G) fields, links, tags, entities, conflicts and the taxonomies that move them |
CATALOGUE |
the closed catalogues: levels, typologies, collection vocabulary, subject categories, cleaning rules and text excerpts |
OPERATE |
the AI workers: running one, changing its persisted default, and the health panel |
ADMIN |
accounts and sessions |
| Role | Carries | Reads |
|---|---|---|
VIEWER |
nothing | everything an authenticated session reaches |
CURATOR |
CURATE, CATALOGUE |
the same, plus the screens that decide the record and the catalogues |
ADMIN |
CURATE, CATALOGUE, OPERATE, ADMIN |
everything, including the workers and the accounts |
Reading is not a permission: it is what an authenticated session is, and a pure read declares nothing.
Signing in, and forgetting the password¶
The sign-in form is not a route: it renders in the shell's own place, so a deep link survives it — an
archivist who opens /acervo/lista while signed out signs in and lands on /acervo/lista. A wrong
password and an address that does not exist answer the same sentence, on purpose: the form is not an
enumeration of who has an account.
There is no e-mail recovery in this installation, and the screen does not pretend otherwise. Under the button, Esqueci minha senha opens a short explanation of the path that does exist: an administrator resets the password on the accounts screen (Usuários, below), the password is temporary and the account replaces it at the next sign-in, the reset ends every session of that account and lifts a lockout — and if the last active administrator is the one who cannot get in, the host's terminal is the way back (Operations owns that procedure). Nothing on the screen promises a message, because no message is sent.
The temporary password is a credential for the change and nothing else. While the account still
carries it, the API refuses every operation of /api/v1 — reads included — with
PASSWORD_CHANGE_REQUIRED, and the shell draws the forced password change in the place of every
screen. The three operations that forced screen stands on stay reachable: reading the session
(GET /auth/me), ending it (POST /auth/logout) and replacing the password (POST /auth/password,
which clears the flag). The refusal does not depend on the screen: a script holding the password that
was printed to a terminal, or typed by an administrator, gets the same sentence and no access to the
collection. The change opens the rest of the API in the same session.
The interface speaks two languages¶
The curatorial interface is written in Portuguese and English, and the choice belongs to the
account, not to the installation: Automático, Português and English sit in the account
footer of the menu. A fresh account starts on Automático, which is stored as "no choice" and
follows the browser — pt* opens in Portuguese, en* in English, anything else in Portuguese, the
installation's language. After sign-in the account's choice wins, because a shared workstation must
not hand one archivist the previous one's language.
The language is not a client-side preference painted over a Portuguese server: every request carries
Accept-Language, and the API composes its message — the confirmation of a write and the refusal
of one — in the language the request asked for (ADR 0020 §4). The switch does not reload the page;
the shell re-renders with the new catalogue, and dates, numbers and plurals follow it through
Intl.
What stays the same in both languages: the routes are still Portuguese (/acervo/lista), which
239 tracks, and the record's own content is never translated.¶
The menu hides; it never grants¶
The shell filters its entries by the area the screen writes (apps/curator/src/lib/permissions.ts,
a mirror of the server's role map), and an empty group is not rendered at all. The screens that only
read carry no permission and stay visible to every role: Início, the collection list, the tree, the
exclusions trail and the arrangement diagnostic. A VIEWER therefore sees the "Acervo" group and the
"Início" entry, which stands with no heading — every other group exists to decide, and is hidden.
The same rule governs the cards of /configuracoes, and it is the card catalogue
(apps/curator/src/lib/settings.ts) that both the page and the menu entry read: a card is hidden when
the account does not carry the area its screen writes, a tab whose cards are all hidden is not
rendered, and the entry is in the menu while at least one card is reachable. So a VIEWER has no
Configurações at all, and a direct URL to it reaches a page that says why it is empty instead of
pretending to be broken.
The mirror is one-way on purpose. It can make the menu shorter; it can never make the API accept a request. A direct URL to a screen the account cannot work in still reaches the API, and the API answers 403 with a sentence — that is the truth, and the hidden entry is a courtesy.
One name per screen, one verb per action¶
The interface grew screen by screen, and so did its vocabulary: the menu said Tags while the page it opened said "Vocabulário de tags", the settings card said Usuários while the page said "Contas", and two different screens were both called Diagnóstico. Two files now own those words.
apps/curator/src/lib/screens.ts holds one record per screen — its route, the label, and the hint
that says what it decides. The label is the same string in the three places that used to disagree:
the menu entry, the settings card and the page's own <h1>, which PageHeader resolves from the
screen id a route declares. A route therefore cannot pass a heading of its own; the dossier is the one
exception, and the catalogue is what flags it as the screen whose heading is the record's own title.
Under the heading the header has the same four slots on every screen: the label, one line saying what the screen decides, the status of what was read — a count, a filter, a total — and the actions. The count used to compete with the explanatory line for the same slot, which is why some screens explained themselves and others only reported totals; while the read is in flight the status slot holds a skeleton of its own height, so the header does not jump when the numbers arrive.
apps/curator/src/lib/copy.ts holds the product's name — re-exported from lib/attribution.ts, which
owns it for the license notice — and one verb per action, with the word for it while it runs. The
verbs this page uses are that file's:
| Action | The word on the screen |
|---|---|
| writing an edited row | Salvar |
| bringing a row into existence | Criar (the trigger beside it reads + Nova…) |
| taking a term off a list — a veto, an exclusion | Remover |
| destroying a record, always behind a preview or a typed confirmation | Excluir |
the is_active switch on a catalogue row — a level, a typology, a category, a cleaning rule, a collection term |
Aposentar / Reativar |
| disabling an account | Desativar / Reativar |
| launching one worker run | Rodar agora |
| unifying two vocabulary entries | Mesclar |
| committing a computed plan | Aplicar |
| computing what a destructive write would do, without writing it | Conferir impacto |
Aposentar and Desativar are deliberately two words: a catalogue row and an account leave the
system in different ways, and one word for both would say they do not. Banning a term keeps its own
verb (Banir / Desbanir), because it is a different flag and it deletes nothing.
The screen is one window tall¶
The shell is exactly one viewport and the page never scrolls: what scrolls is the content
column, so the rail — and the entry to Configurações at its foot — stays where it is on a long
screen. The screen's own header is sticky inside that column, which is what keeps the title, the
status and the actions in reach: "Propor níveis", "Rodar agora" and "Excluir órfãs" used to leave
the window with the content. Measured at 1440x900 on the entities list: the document is 900px in a
900px window, the content is 3299px in an 867px column, and the menu is 734px in a 734px box.
The column has no maximum width: it fills whatever the window gives it, so the same screen is the
same width on every monitor. Before this, each screen carried its own cap and they had drifted —
max-w-5xl on most, max-w-4xl on five, max-w-6xl on one — so two screens side by side on a wide
display stopped growing at different points. What keeps its own cap is the content: a paragraph
keeps a readable measure, a text field keeps a field's width.
The menu folds into one open section, plus the section the archivist is standing in — that one is
open by construction, because an active entry hidden inside a collapsed group is a menu that hides
where you are. Clicking a heading opens it and closes the one opened by hand; navigating closes that
one, so the menu never drifts into three. A heading carries no icon: the glyph belongs to the entry.
Início stands at the top with no heading, like the landing at the foot, because a section that
gathers one line is a line of chrome.
The identity is on screen¶
The rail is the identity's navy (#14202B), and the logomark is in it in both states: expanded it is
the full lockup — the card with its terracotta tab, then Scrinalia in PT Serif — and collapsed it
is the cropped mark alone. Collapsed, that mark is also the control that expands the rail: the
brand is the thing you click to get the words back, which is what makes it worth keeping at 64px.
A 2px terracotta rule closes the screen's header, and the two cards that come before a session — the sign-in form and the first-run setup — carry the same motif at their top, where the name used to be plain text. The two are not the same plate: the setup card centres the lockup under the tagline, and the sign-in card sets a larger lockup to the right and carries no tagline at all — there the plate is the mark alone.
The accent is still the blue it was, and the terracotta is a brand colour and not an interactive
one: the terracotta is oklch(… 31) and the danger colour is oklch(… 25), six degrees apart, so a
terracotta button and "Excluir" would have been the same colour. The navy, on the other hand, shares
the accent's hue on purpose — which is why the rail and the buttons look like one thing.
The menu collapses to icons¶
A control in the menu's own header folds it into a 64px column of icons and back, and the browser
remembers the choice: how wide a column of the screen is is presentation state, so it lives in
localStorage and not in the account — the API has no column for it.
Collapsed, every entry keeps its icon; the label stays the link's accessible name and the title
carries label and hint, so the hint is hidden and never truncated. The active entry keeps the accent
colour, the group headings become hairlines, and the accordion is off — a heading with no label in a
64px column is a click that says nothing — so all sixteen entries show at once. The session footer
folds into the account's initial plus two icon buttons.
The wordmark leaves the menu but not the page: the attribution renders at the foot of every
screen (AttributionFooter, ADR 0006), including the three that come before a session — the first-run
setup, the sign-in form and the forced password change — and it ends with the installation's version,
alone at the right. The icons come from lucide-react, one glyph per entry.
Curadoria¶
| Screen | Decision | Reversible? |
|---|---|---|
Início — / |
which pending queue to open | yes (read only) |
Início — /¶
The work list. It answers "what needs me today?" with one card per queue and the route that resolves
it, already filtered. It is a read view of existing predicates (GET /api/v1/curation/inbox):
nothing is written. A card the shell does not recognise as openable shows "tela pendente" instead of
linking.
Acervo¶
| Screen | Decision | Reversible? |
|---|---|---|
Nova Descrição — /acervo/nova |
typing one description or importing a CSV (curator) | the record enters the pipeline as PENDING_AI; it is approved (or rejected) in the dossier, and only there |
Lista e busca — /acervo/lista |
how to slice the collection | yes (read only) |
Árvore — /acervo/arvore |
where a description sits in the arrangement | yes, by moving it; removing the node means excluding the description |
Histórico de exclusões — /acervo/excluidas |
none (the record of what left) | yes (read only) |
Descrição — /acervo/$descriptionId |
the record, its subjects and its place | mostly yes; the exclusion does not |
Nova Descrição — /acervo/nova¶
The way into the collection: one description typed in full, or a CSV imported. The two are tabs in
the URL — Digitar (the default) and Importar CSV (?modo=csv) — because they are two input
shapes of the same job, not two jobs: both stage the record and enqueue the same transfer the
scraper uses, and the archive description is born PENDING_AI. The screen is not an approval:
the dossier is where the record is reviewed and approved after the AI runs. It is a CURATE write,
and it is the first entry of the Acervo menu.
Digitar is the whole ISAD(G) record: thirty controls in the norm's seven areas, the level and the
language as selects, the date as text with the language grammar, the digital object control disabled
until #198. The form generates a manual-<uuid> id, so a retried submit is the same record; a
duplicate description_id is refused before the write. Only the title is required — a description
can be born incomplete and completed in the dossier.
Pontos de acesso are chips over the tag catalogue, not a comma-separated string. The type-ahead
picks an existing spelling; typing a term and choosing Adicionar keeps the archivist's spelling. A
summary under the control names what the transfer will do with the string — create a new spelling, map
it to a synonym, or discard it because it is banned, shorter than three characters or longer than a
hundred — and points at the entries whose stored spelling differs from the typed one. The summary is
POST /api/v1/taxonomy/tags/preview, a read: an unknown spelling is NEW and is created only by the
transfer at archiving. Nothing is removed or rewritten by the preview; removing a term is the
archivist's decision. The field stays optional, and the string it sends is the same one the CSV column
and the scraper's mapped key carry.
Produtor(es) suggests names from the entity catalogue. Typing in the field asks GET
/api/v1/taxonomy/entities?term= — the same search the dossier uses to attach an entity — and picking
a suggestion copies the name into the text, never the entity_id. The copy is verbatim (the
catalogue stores entity names lowercased) and replaces only the fragment between two separators, so
the archivist's commas, spacing and other names survive; the caret lands after the copied name and
the field stays editable. It is a copy and not a link: a later merge or rename of the entity does not
reach the description, and the help under the field says so. The list is not filtered by entity type,
because the NER types the same administrative body as ORG, LOC or PER and any filter would hide
a real producer; the type travels as the suggestion's hint. A name the catalogue does not know stays
typeable, and the field is still the string the contract carries. The relation, with the filters it
would unlock, is #242.
Importar CSV is the csv origin (#191, ADR 0019). The file is parsed in the browser (comma or
semicolon, quoted fields, CRLF, UTF-8 with a Windows-1252 fallback); the screen shows the detected
headers, a preview and the local checks, and sends only a bounded list of rows as JSON — nothing is
uploaded to the object storage and there is no multipart route, so an installation whose storage is
unconfigured imports a CSV exactly the same. The map is the API's (GET /api/v1/ingestion/csv/spec),
so a header cannot be accepted by the screen and refused by the server; the API re-validates every row
with the staging contract, and the per-row answer says accepted, duplicated (the
description_id already exists) or invalid, with the field and the sentence. A partial import is
normal. The accepted rows are staged as origin_code='csv' and one transfer is enqueued after them.
The old /sistema/ingestao/nova redirects here, so a shared link still opens the typed form.
Lista e busca — /acervo/lista¶
The faceted search over the collection: the term, one of three rankings, typology, subject category, entity type, level, a branch of the arrangement and a date range. The URL is the state, so a filtered list is shareable and the back button works. Nothing is written.
The three rankings answer different questions, and the screen says which is which:
| Mode | What it orders by | When to choose it |
|---|---|---|
| Lexical (default) | ts_rank over the generated search_vector, boosted by a tag or entity that matches |
The archivist knows the term the title uses |
| Semantic | Cosine similarity between the term and the description's embedding | The archivist knows the subject but not the vocabulary of the fonds |
| Hybrid | The two fused by Reciprocal Rank Fusion (RRF_K = 60, window RRF_DEPTH = 100) |
The mode that does not require the choice, at the cost of loading the model |
The numbers behind those sentences live in docs/log.md, next to the bench that produced them
(testing/evaluation/retrieval_modes.py), and not on this page: a page that stores a number which is
expected to move lies on the next run. What the screen does state is that the evidence is a small
labelled proxy set, and that the lexical mode is preferred when the term is known — the one place
where the two arms differ by a wide margin.
Árvore — /acervo/arvore¶
The arrangement as navigation. Selecting a node shows its branch, its children and the descriptions
inside it, all read-only. The screen's one write is "Criar nó" (POST /api/v1/hierarchy/nodes,
CURATE): it declares a Fundo, Seção or Série that the origin never delivered and that the slicer
therefore cannot propose. The new description is born HUMAN_APPROVED, and the level ladder is
validated against the chosen parent by the same rule a move uses. Its placement is reversible by
moving the description; removing the node means excluding the description, which is definitive.
Arranjo is not assunto
This is the objective axis — where a description sits in the provenance. The subject badges live in the list and in the dossier; mixing them here would teach the archivist to "fix" a subject by dragging a description into another branch.
Histórico de exclusões — /acervo/excluidas¶
The trail of the deletions, read-only (GET /api/v1/documents/deletions), with a server-side search
and pagination because the trail has no ceiling. It is not a recycle bin: each entry is the
ISAD(G) snapshot the API wrote before deleting, and nothing here restores anything. It answers "did I
delete this?" — code, title, level, who excluded it, when, how many children it had, and the whole
snapshot on demand.
Descrição — /acervo/$descriptionId¶
The dossier, with four tabs in the URL. The suggested title is shown next to the field it
proposes and is never the stored value: it is derived on read from the TITLE-scoped excerpts, and
only the archivist writes final_title.
Descrição¶
The first card is the miniature, and it comes from the API, not from the browser: the read view
advertises thumbnail_url and the SPA binds it to an <img>, so the client never assembles a path
and never talks to the bucket (ADR 0017). The bytes are GET
/api/v1/documents/{description_id}/thumbnail (AUTHENTICATED); the route answers 404 when the
source had no image or the object is no longer in the bucket, and 503 when the storage itself is
unavailable. Four of the reference collection's 101 descriptions have no source image, so "Sem
imagem" is a normal state, and a stored URI whose object is gone shows "Imagem indisponível" — never
a broken-image glyph. The collection list shows the same miniature at 56 px, which is how a
photograph is recognised without opening the dossier.
The decision is the record itself, grouped in the norm's own areas — 3.1 Identidade, 3.2
Contexto, 3.3 Conteúdo e estrutura, 3.4 Acesso e uso, 3.5 Materiais relacionados, 3.6
Notas and 3.7 Controle da descrição — plus the level, the typology and the diffusion flag, with
a note on why. The whole ISAD(G) record is editable: the archive stores every field the staging
transform parsed, not the subset the transfer used to copy. The write is PATCH
/api/v1/documents/{description_id} (CURATE). It records the before and after of what changed in
archive_document_revisions, writes the author from the session, and sets HUMAN_APPROVED — which
takes the description out of the AI's queue. Reversible: another edit records a new revision; the
history is append-only and the earlier value is visible in the Histórico tab. Publishing and
withdrawing use the same command and are equally reversible.
Two values are shown and not edited, because they are evidence rather than description:
date_expression (the date as the source spelled it — Década de 1980 beside the parsed
1980-01-01, which is what the parser could make of it) and original_url (the page the record was
read from). The level and the typology are selects over their catalogues; language_name is a select
over GET /api/v1/languages, a catalogue declared in code because a language carries no rule an
institution edits. The stored value is the catalogue's name: a declared variant (Português/Brasil,
Português Brasil) is folded at the transfer, so the whole collection reads one spelling — and the
select keeps a value the catalogue does not know, marked as such, so opening the dossier never
erases a language the catalogue has never heard of. The declared parent is shown in 3.1 even while it
is pending (BR PRPMC — aguardando), because the origin is allowed to deliver the child before the
parent; the link itself is made in the Arranjo tab.
The Objeto digital card is rendered with its control disabled: the archive keeps the
description and not the file, and the digital object is #198. The sentence that says so is defined
once (apps/curator/src/lib/copy.ts) and the manual entry form shows the same one.
Assuntos¶
The decision is which tags and entities this description carries, and which category each tag belongs
to. The writes are POST/DELETE /api/v1/documents/{description_id}/tags[/{tag_id}] and the entity
pair (CURATE), plus PATCH /api/v1/taxonomy/tags/{tag_id} for the category. Linking and unlinking
are reversible: the revision stores the whole list of names on each side, and calling the same
route twice writes no revision.
Every entity shows the origin of its link: IA when the extractor wrote it, confirmada when a
person associated or confirmed it, and não rastreada for a link written before the column existed.
An unknown origin is never shown as the model's own. Confirmar associates an entity the extractor
already proposed without duplicating it: the link keeps its place and its origin becomes HUMAN —
the positive verdict the archivist could not record before (#135). Confirming a link that already
existed changes no names, so it writes no revision: the record of the confirmation is the link's
origin, and the description becomes HUMAN_APPROVED, which takes it out of the AI's queue.
The category is a decision about the vocabulary, not about this description
Changing a tag's category moves it for every description that carries the tag, and the screen says so next to the select. Choosing "sem categoria" gives the tag back to the AI classifier, which will try to file it again on the next run.
Arranjo¶
The decision is where this description sits and at which level. The write is
POST /api/v1/hierarchy/nodes/{description_id}/move (CURATE), and the route states where the node
goes: new_parent_id=null means "to the root", so there is no way to change only the level — the
screen always sends the parent it is showing. The API validates before writing (an Item cannot have
children, a Dossiê cannot be left without a parent, moving into its own subtree is refused), and
moving a unit takes the whole subtree: the path is rewritten in one statement. The change is recorded
in this description's history and is reversible by moving the unit back.
Histórico¶
The revisions of this description, read-only, field by field with the old and the new value. At the end of the history sits the exclusion, away from the page header on purpose — it is the one action that removes a record for good, and the confirmation (type the reference code) is what separates "excluir" from "excluir esta descrição". See what has no way back above.
Arranjo¶
The plan is reached from /configuracoes — deciding the rungs is setup work, not daily curation —
and the diagnostic stayed in the menu, under "Acervo", beside the tree it reads. With the plan gone,
the "Arranjo" group would have held a single line, so the group disappeared with it.
| Screen | Decision | Reversible? |
|---|---|---|
Plano de arranjo — /arranjo/plano |
approve or reject each proposed rung, and materialise the tree | yes — a decision can be reopened, and the materialisation has an undo |
Diagnóstico do arranjo — /arranjo/diagnostico |
none (the evidence; the fix is elsewhere) | yes (read only) |
Plano de arranjo — /arranjo/plano¶
The machine reads the reference codes and proposes the rungs; the decision is the archivist's. For each rung the evidence comes first — the document count, the declared levels, the samples — and the form last:
- Propor níveis —
POST /api/v1/hierarchy/plans/suggest(CURATE). It writes the questions. It is idempotent by code and never rewrites a rung that leftSUGGESTED, so the same question is not asked again. - Aprovar a rung —
PATCH /api/v1/hierarchy/plans/{plan_id}(CURATE) withAPPROVED, the chosen level, an optional title, an optional "fundir em" code and a note. Approving requires the level: it is the decision the code cannot take. - Rejeitar — the same route with
REJECTED. A rejected rung leaves its descriptions orphan on purpose: they fall to the nearest approved rung above. - Reabrir decisão — the same route with
SUGGESTED. Reversible: the decision returns to the queue and the next proposal may refresh its evidence. The decision is never overwritten by a new proposal.
The rungs are drawn as the tree their codes imply, not as one indented list. The box at the left of
a row closes that branch — the row then says how many rungs and how many pending decisions it hides
— while the card's own chevron opens the decision body; closing a branch does not reset the cards
inside it: expanding restores each one as it was. The status, flag and code filters keep a match's
ancestors and its branch, and an
ancestor of a match cannot be closed. Recolher os decididos closes the branches with no rung
still SUGGESTED. The URL records the top of each closed branch, so a colleague opens the same
view.
The side panel materialises the tree: it always offers "Conferir impacto" first, and the apply button stays disabled until the preview exists. Only approved rungs are materialised. The preview and the apply share one planner, so the approved number is the written number. What the undo restores is described under the batch that does have an undo.
Diagnóstico do arranjo — /arranjo/diagnostico¶
The structural diagnosis, one section per issue, each with its count and its evidence (a section with
zero stays visible, because knowing the check ran matters). It offers no silent correction: every
row links to the screen where the fix is a recorded decision — the plan, the tree, or the
description's arrangement tab. The counts overlap deliberately (a Dossiê at the root is both ORPHAN
and DOSSIER_WITHOUT_PARENT), so there is no grand total.
Catálogos¶
All three are reached from /configuracoes, under Arranjo e catálogos: they are the vocabulary the
work is written against, not the work, which is why the menu no longer carries them.
The two closed catalogues the archivist maintains and the collection vocabulary. None of them
deletes: a row retires with is_active=false, because the foreign keys are SET NULL and removing a
row would unclassify every description pointing at it while erasing the fact that it ever existed.
| Screen | Decision | Reversible? |
|---|---|---|
Níveis de descrição — /arranjo/niveis |
the ladder the arrangement is written against | yes (retire and reactivate) |
Tipologias — /arranjo/tipologias |
the diplomatic form the classifier proposes, and the descriptions it refused | yes (retire and reactivate; filing a description is a document revision) |
Vocabulário do acervo — /vocabulario |
what this collection's codes and terms mean | yes (retire and reactivate) |
Níveis de descrição — /arranjo/niveis¶
The catalogue of levels. Creating a rung is POST /api/v1/hierarchy/levels and editing one is
PATCH /api/v1/hierarchy/levels/{level_id} (CATALOGUE): name, description, accepted spellings,
whether it requires a parent and whether it allows children. Two rules the screen states rather than
works around:
- there is no delete.
is_active=falseis the way out, and the weight of a retired rung stays visible; - the ordinal is not editable. Changing it would renumber the tree the past was decided against; a wrong ordinal is a new rung, not a rename.
The asymmetry is deliberate: an unknown level arriving from the origin enters as unclassified and the description carries on, while the archivist cannot store a level that does not exist.
Tipologias — /arranjo/tipologias¶
The catalogue of documental typologies — the diplomatic form (ata, ofício, planta), neither
arrangement nor subject. POST /api/v1/typologies and PATCH /api/v1/typologies/{typology_id}
(CATALOGUE) maintain the name and the context description. The classifier receives only the
name: appending the context makes the model lose the entailment and collapse the collection onto
one typology (measured), so the context is documentation for the reader. Retiring a typology is the
only lever that reaches the model — it stops being proposed without a single classified description
being touched, which is why the weight of the retired ones stays on screen.
The screen also lists the descriptions the classifier refused. GET /api/v1/typologies/refusals
(AUTHENTICATED) returns one page of the descriptions whose best score stayed below the floor,
nearest miss first, with the label the run guessed and the score it saw. The list reads the same
predicate as the classifier's count and leaves out a description a curator has already filed, even
though its queue row remains. Choosing a typology there is PATCH /api/v1/documents/{id}
(CURATE): the dossier's own write, which records the revision and marks the description
HUMAN_APPROVED. It is reversible from the dossier, and the before/after stays in the trail. An
empty list means no refusal is open, not that the catalogue covers the collection — a description no
run reached has no row, and filing one removes it from the list without deleting the evidence. If no
typology fits, create or reactivate one in the catalogue on the same screen; there is no durable
verdict for "this description has no typology" yet.
Vocabulário do acervo — /vocabulario¶
What this collection declares, as opposed to what the language or the software fixes. Two lists:
- arrangement names —
POST/PATCH /api/v1/vocabulary/arrangement-terms(CATALOGUE): a token (SMU) or a whole code (BR PRADAP) mapped to the name the arrangement proposal suggests; the whole code wins over the last token; - collection terms —
POST/PATCH /api/v1/vocabulary/collection-terms(CATALOGUE): the spellings the collection carries and that are not subjects, typed so the guard recognises a place or a person name. What the name is is carried by the entity axis (LOC,PER), not by a state of the tag: the subject axis simply stops guessing at it.
What belongs to the Portuguese language itself — rua, não identificado, 303 anos, a bare year — lives in the language profile and does not appear here: changing it would change the meaning the software gives to a word. Withdrawing a term does not delete it, and a withdrawn term is treated as a subject again on the next classifier run.
Assuntos¶
| Screen | Decision | Reversible? |
|---|---|---|
Tags — /assuntos/tags |
the weight, the duplicates, the merge queue and the subject decision of each tag | the merge yes (ledger); a subject decision yes (it can be taken back) |
Categorias — /assuntos/categorias |
the subject categories the classifier reads, and what is inside each one | yes (retire and reactivate) |
Descobrir Categorias — /assuntos/descobrir |
whether a proposed theme deserves a category | yes (the proposal writes nothing) |
Não é assunto — /assuntos/excecoes |
which terms leave the subject axis and have their spelling banned from becoming a tag again | yes (the ban deletes nothing, and the undo lifts the ban this decision planted) |
Grafias bloqueadas — /assuntos/grafias |
which spellings the extraction may not recreate, and the deletion of the tags that already carry them | the ban yes (unban); the deletion yes (one ledger row per tag, restoring the tag, its links and its spellings — never the ban) |
Tags — /assuntos/tags¶
One route, four questions, one conversation: the archivist sees that alvenarias weighs little,
finds its near-duplicate and decides whether to absorb it. Three of the tabs are how they look; the
fourth, Classificar, is where the subject decision is taken, and it is where the home screen's two
subject cards land. The active tab is in the URL.
The blocked spellings used to be the fifth tab, and they are Grafias bloqueadas —
/assuntos/grafias since #232: a screen of their own, beside Não é assunto, because this page's
description never mentioned that its tab held the only write that deletes a tag. ?aba=stopwords
still answers with the new screen.
Relevância¶
Read-only: GET /api/v1/taxonomy/tags/relevance/{method}. The count shows what dominates the
collection; TF-IDF shows what is specific, punishing what appears everywhere. The two lists answer
different questions on purpose.
Similaridade¶
Pairs by trigram similarity, shown raw with the similarity score and both ids. The read is
GET /api/v1/taxonomy/tags/similar. The similarity does not say which spelling is the good one:
'alameda cabral' and 'al. alameda cabral' score 1.000.
The decision is to merge: POST /api/v1/taxonomy/tags/merge (CURATE), after a dry run
(POST /api/v1/taxonomy/tags/merge/preview, a read that computes the same plan the write executes).
The panel always opens with the impact first: documents updated, links rewritten, tags absorbed,
spellings registered and repointed, and the one warning that must be read before the click —
category_would_be_lost, when the canonical has no category and an absorbed tag does, so the merge
would erase a subject classification.
The merge is reversible: the write is logged per absorbed tag in the merge ledger before
anything changes, and the undo (DELETE /api/v1/taxonomy/tags/merge-log/{merge_id}, CURATE)
restores the row, the links, the classification and the spelling state. The undo deletes only the
links the merge created, so it cannot detach a document that already carried the canonical. Rows can
also be marked across lines and unified as a cluster, which is how one merge spans several pairs
(carlos de carvalho appears in more than one row).
A marked selection has a second destination, and it is the other kind of write: Banir N
marcadas registers the spellings in POST /api/v1/taxonomy/tags/stopwords (CURATE), on the axis
chosen beside the button. Banning deletes nothing — the tags stay in the collection, linked and
reachable by search, and the step that removes them is the exclusion of tags in Grafias bloqueadas
— /assuntos/grafias, behind its own preview and with a ledger that restores each tag. The marks
survive the ban: the merge they were made for is still possible.
Propostas de merge¶
The machine's clusters, with the decision kept apart from the write:
- Propor clusters —
POST /api/v1/taxonomy/tags/merge-proposals/suggest(CURATE) registers the question. A verdict already taken is never overwritten. - Conferir impacto —
POST /api/v1/taxonomy/tags/merge/previewwith the proposal id, the same planner. - Aprovar / Rejeitar —
PATCH /api/v1/taxonomy/tags/merge-proposals/{proposal_id}(CURATE). Approving records the intent; it does not absorb anything. The status goesSUGGESTED→APPROVED/REJECTED→APPLIED. - Aplicar em lote —
POST /api/v1/taxonomy/tags/merge/batch(CURATE) absorbs the selected clusters and writesAPPLIEDin the same savepoint. The screen refuses a batch over 200 clusters (the API refuses it too). A cluster whose members already left is reported asskipped, never as a failure. - Editar a cluster — marks the tags that do not belong out of the set, chooses the canonical and applies the revised selection through the same planner; the machine's proposal is then closed as rejected, because the question was answered another way.
The ledger below lists only applied merges, each with the absorbed name, the canonical, the document count, the author and the time. Its undo is the exact restoration described above, and the screen's "desfazer" is the same route. A merge made here also works through a proposal, so the ledger is the write's history and the status is the decision's.
Classificar¶
Where a tag's subject is decided, and the tab the home screen's cards open. The read is
GET /api/v1/taxonomy/tags/catalog (AUTHENTICATED), filtered by subject_state and optionally by
macro_category_id, paged, and ordered by weight — the tag the most descriptions carry is the
decision worth taking first. It exists because the type-ahead cannot answer it: that one requires a
term and returns a top-N by weight, so a card promising "76 tags without a subject" had no read that
could mark which rows they were.
The state is a partition, and ALL is the absence of a filter rather than a state:
subject_state |
What it means |
|---|---|
PENDING |
No drawer and no verdict: nobody has decided. It is what the home card counts |
NOT_SUBJECT |
A person declared the tag is not a subject; the classifier stops guessing at it and its spelling is banned from becoming a tag again |
LOW_CONFIDENCE |
The classifier ran, was not confident enough to file the tag, and left it undecided |
The row carries the evidence (weight and the classifier's confidence) and exactly one of two
destinations, through PATCH /api/v1/taxonomy/tags/{tag_id} (CURATE):
- a category — the drawer the tag joins. The decision is global: it changes the badge of every description that carries the tag, and the screen says so before the click;
- not a subject —
macro_category_id=null, which is the same write the Não é assunto screen makes. The tag stays in the collection and reachable by search, and the column alone would be a decision the next run overwrites, so the term is registered indomain_subject_exclusionsand its spelling is banned atTAGscope, so the next ingestion does not recreate it. The reversal isDELETE /api/v1/taxonomy/tags/subject-exclusions, and it lifts only a ban this decision planted.
A producer, a place or a person is not a third destination. What the name is belongs to the
entity axis, where it carries a type (LOC, PER, ORG) that search and the public projection
read; a spelling that is both a tag and an entity is settled on
Conflitos — /entidades/conflitos, which bans the losing spelling and keeps a ledger. Declaring
the term not a subject is what takes it out of the subject queue; the entity is where it keeps its
meaning. The measurement behind the removed state still holds — alfa reaches 2,376 descriptions and
cidade 1,865, and forcing them into a category is a measured cause of the classification defect —
but the answer is now the entity and the exclusion, not a fourth state of the tag.
The same two actions apply to a selection, one request per tag, and the batch reports how many
tags it wrote and where. Nothing on this tab deletes: banning a spelling and excluding the tags are in
Grafias bloqueadas — /assuntos/grafias, each behind its own step, and the exclusion keeps a
ledger with the undo of each tag.
Grafias bloqueadas — /assuntos/grafias¶
The spellings a ban keeps out of the vocabulary, and the one write in the taxonomy that deletes a tag. It was the fourth tab of Tags until #232, and it became a screen of its own because the page it lived under described relevance, duplicates and merge, and never said its tab held the only destructive write of the subject axis.
It is not a second view of Não é assunto, and the screen says so in its first line: the verdict
is about what a term means — and it is what stops the subject classification from guessing — while
a ban is about a spelling — and it is what stops the next ingestion from recreating the tag.
Confirming a verdict plants a TAG ban, which is why the same spelling appears in both lists: the
list marks where each ban came from, and a ban the verdict planted links back to the screen that
decided it. The two screens link to each other, and the gate
testing/unit/curator/test_curator_copy.py fails if either link or the ?aba=stopwords redirect
disappears.
The screen keeps three things apart:
- Banir —
POST /api/v1/taxonomy/tags/stopwords(CURATE) records that a term is not worth considering on one axis:TAG(the subject axis),ENTITY(NER) orALL. A word has exactly one scope — re-banning it moves it — and banning deletes nothing. A ban typed here is recorded withsource=HUMAN; the ban the Não é assunto screen plants isEXCLUSION, and the one a conflict resolution plants isCONFLICT. The list shows the scope on every term and the origin as a badge when it is notHUMAN— a term typed here is the default, and the badge marks the ones that arrived as a consequence of another decision. - Desbanir —
DELETE /api/v1/taxonomy/tags/stopwords(CURATE). Reversible. - Casamento —
PATCH /api/v1/taxonomy/tags/stopwords(CURATE) changes how an existing ban matches without re-banning it: a re-ban would overwrite the origin a conflict or the Não é assunto screen recorded.TOKENblocks the word and any name that contains it as a whole word — the merged spanIPTU do Exemplo— andEXACTblocks the spelling alone, which is what a generic word that starts a proper name needs:alamedablocksalameda, neverAlameda Cabral. Only theENTITY/ALLaxes read it. Every entry ofdomain_ner_exclusionsmatches the exact spelling for the same reason: the conflict was aboutcasa, not aboutcasa da memória(#160). - Excluir as tags —
POST /api/v1/taxonomy/tags/stopwords/purge(CURATE), afterPOST /api/v1/taxonomy/tags/stopwords/purge/preview. This is the write that deletes the tags. The preview lists the tags that would die, with their document count, their category and where the ban came from (HUMAN,CONFLICTorEXCLUSION), so a term banned with the archive in mind is told apart from one typed by hand; the apply button is disabled until the preview exists.
Each deleted tag is written to a ledger (archive_stopword_purge_log) before it disappears: the
snapshot of the row, the documents that carried it and the spellings that pointed at it. The purge
response carries the purge_ids, the screen shows them in Registro das exclusões, and
DELETE /api/v1/taxonomy/tags/stopwords/purge-log/{purge_id} (CURATE) restores the tag, its links
and its spellings; an unknown id is 404 and a second attempt is 409. The undo does not
lift the ban — the term is still a stopword, and the next exclusion lists the restored tag again.
The scope protects the other axis
The purge reads only TAG/ALL. A term banned on the ENTITY axis is a NER veto and can never
make the subject purge delete a tag the curator kept — the two are stored in different places on
purpose.
Categorias — /assuntos/categorias¶
The categories the subject classifier reads. POST /api/v1/taxonomy/macro-categories and
PATCH /api/v1/taxonomy/macro-categories/{category_id} (CATALOGUE) create, rename, describe,
retire and reactivate them; a retired category keeps its weight visible. Two honest notes the screen
gives: the classifier label is a curator override, not an improvement — measured on 44
hand-labelled tags, a phrase instead of a bare name scores 0.000 with 65% of the tags in one category
— and a new category only takes effect when the classifier runs again, because the worker's stamp
is the hash of the label set, which returns the tags to the queue on its own.
Each card opens on two tabs. Descrições lists the descriptions the category holds, ten at a time,
through the collection search (GET /api/v1/documents?macro_category_id=, AUTHENTICATED); the
card's badge counts distinct descriptions through the same join, so the number on the card and
the total of the list are one definition counted twice. A category with none says so — the retired
ones are not a defect. Editar is the form: name, classifier label and description.
The button on the row opens a read-only preview of one description — reference code, title, date,
level and typology, arrangement path, its tags with their drawers, and the review status — over the
list, so the description can be recognised without a round trip to the dossier. The panel writes
nothing: its Editar leaves for /acervo/{description_id}?aba=descricao. The open category, its
page and the previewed description live in the URL, so the back button closes the panel and the page
survives it.
Descobrir Categorias — /assuntos/descobrir¶
Runs the real clustering engine (POST /api/v1/taxonomy/tags/suggest-macro, read-only:
AUTHENTICATED) over the tags or the documents to find a theme the vocabulary does not cover yet. It
is a deliberate click and not a page load: the engine takes seconds and may run in a subprocess. The
clusters are proposals — nothing enters the vocabulary until the archivist registers it, which is
POST /api/v1/taxonomy/macro-categories (CATALOGUE). Discarding a proposal is local to the screen.
A corpus too small answers an empty suggestion with a message, not an error.
Reading the documents offers the columns the clustering reads: GET
/api/v1/taxonomy/tags/suggest-macro/columns publishes the AI text columns and the subset the run
reads when the body names none, and the screen renders one checkbox per column from that read — it
does not type the five names. final_title is in the corpus and out of the default: a column can
join what the AI reads without silently changing what the clustering reads. The body carries
columns_to_extract only after a column moves, and the route refuses an unknown name with a 400
that names it (before #174 the typo reached a getattr and answered 500), an empty list, and a
list sent with source_type=tags, where it would be ignored.
Não é assunto — /assuntos/excecoes¶
The curated half of "this is not a subject at all". The deterministic guard catches what has a shape
— a date, a placeholder, a street name, a bare number — and on the real measurement it caught 1 of 4
of the hand-labelled non-subjects; the rest are semantic calls no rule resolves (pessoas,
vista aérea, capanema). The screen shows the guard's candidates with their evidence (weight,
whether the term is a place, whether the same spelling is also an entity, the drawer that would block
it and whether its spelling is already banned), and offers to register them with source=RULE or one
at a time.
Confirming plans and applies in one call: the write below consumes the same planner the preview route
uses, so the two cannot disagree. The preview is POST
/api/v1/taxonomy/tags/subject-exclusions/preview (AUTHENTICATED); it takes the terms and nothing
else, because a plan has no reason to declare and no provenance to report, and it answers
applicable and blocked. The Vetar termos box asks it as the typing settles (250 ms) and shows
the refusal before the click: a term whose tag already sits in a drawer is named with the drawer
while the archivist can still change it, and the write's own answer only confirms it. The box writes
every term that applies, so a partial refusal does not disable the button — only a list where all
terms are blocked does, because then the write would record nothing.
The write is POST /api/v1/taxonomy/tags/subject-exclusions (CURATE), and it does two things at
once: it registers the term in domain_subject_exclusions, so the subject classifier stops guessing
at it, and it bans the spelling at TAG scope, so the next ingestion does not recreate the tag
that was just decided about. The ban deletes nothing: the tag stays in the collection, stays
linked and stays reachable by search. Removing it is a separate act — the exclusion of tags in
Grafias bloqueadas — /assuntos/grafias, behind its own preview and with a ledger that restores
each tag — and the preview says which bans were planted here (EXCLUSION) and which by hand
(HUMAN).
The screen and Grafias bloqueadas are two objects, not two filters, and each links to the other:
this one decides what a term means and is what stops the classifier; that one vetoes a
spelling and is what stops the extraction from recreating the tag. A verdict plants a TAG ban,
so the same spelling is in both lists — the blocked list says which ban came from a verdict and links
back here.
Two answers the screen has to show:
- a term whose tag already sits in a drawer is not applied: it comes back in
blockedwith the drawer that has to be emptied first, because "which drawer is this tag in?" is a question about the tag, answered on the tag catalogue, and a statement about a term must not un-file a human verdict. The code isSUBJECT_EXCLUSIONS_BLOCKEDwhen nothing was applied and something was refused,SUBJECT_EXCLUSIONS_PARTIALwhen some were applied and some refused, andcreated/banned/blockedcarry the three answers; - the route deliberately proposes nothing for the semantic half: the available signals lie, and a model that does not know how to abstain would answer confidently and wrongly.
The reversal is DELETE (CURATE, the screen's "restaurar"). It answers removed and unbanned,
and the lift is exact: only a ban this decision planted leaves with it. A spelling a person banned on
the Grafias bloqueadas screen, or one a conflict resolution planted first, survives.
Entidades¶
| Screen | Decision | Reversible? |
|---|---|---|
Entidades — /entidades/lista |
the type of a name, the spelling of a name, and whether two names are one | the type yes; the rename yes (one ledger row); the merge yes (one ledger row per absorbed entity) |
Remover de Entidades — /entidades/excecoes |
which spellings are subjects, not proper names, and the retroactive purge that follows | both yes: the veto row by its own DELETE, the purge by its ledger (archive_ner_purge_log) |
Conflitos — /entidades/conflitos |
whether a collision belongs to the subject axis or to the names, one pair at a time or a whole category at once | yes (one ledger row per pair, with the undo) |
Entidades — /entidades/lista¶
The named-entity vocabulary, by weight or by similarity. Five decisions:
- Reclassify the type —
PATCH /api/v1/taxonomy/entities/{entity_id}/reclassify(CURATE), only betweenORG,PERandLOC. This is not renaming a label: the service also writes the anchoring synonym, so the extractor returns that spelling with the new type in every future run. - Renomear —
PATCH /api/v1/taxonomy/entities/{entity_id}(CURATE): corrects the spelling of one name, and it is not a merge. The previous spelling stays anchored as a synonym unless the archivist turnskeep_old_synonymoff, which is what keeps a future extraction of the old spelling resolving to this entity — and a typo is exactly the case where it should not. The write leaves oneRENAMErow in the same ledger the merge uses, so the screen's "desfazer" restores the name and drops the spelling it planted. A spelling another entity already carries is a 409 that names the twin and points at the merge, never a silent overwrite and never a bare structural conflict. - Excluir an entity —
DELETE /api/v1/taxonomy/entities/{entity_id}(CURATE), offered only on a row no description carries, where deleting loses no link. No undo. - Purgar órfãs —
POST /api/v1/taxonomy/entities/orphans/purge(CURATE): the same decision in batch. No undo. - Mesclar —
POST /api/v1/taxonomy/entities/merge(CURATE): the absorbed entities stop existing, their links move to the canonical, their spellings become synonyms, and an optional new name for the canonical makes the old one a synonym too. The decision is preceded by the preview (POST /api/v1/taxonomy/entities/merge/preview, a read): it returns how many descriptions gain the canonical, how many links are rewritten, which entities are absorbed and the warnings to look at, computed by the same planner the write executes. Entities have no proposal catalogue, but they do have a ledger: each absorbed entity is logged before anything changes, and the undo (DELETE /api/v1/taxonomy/entities/merge-log/{merge_id}, the screen's "desfazer") restores the row, its links, its spellings and the name the canonical lost.GET /api/v1/taxonomy/entities/merge-logis the trail, searchable by either spelling of each entry. The pair list is raw evidence, with both ids, because two rows can read almost the same name and the canonical is a choice the archivist has to be able to tell apart.
The merge has three doors and one panel behind all of them: the selection on Relevância, a pair on
Similaridade, and the Mesclar pelo nome card above the tabs. The card exists because the other
two are list-driven and the archivist controls neither list — relevance is ordered by weight and
capped, so two names carried by very different numbers of descriptions are never on the same page, and
similarity is trigram, the one measure that fails on the pair they are most sure about (smu and
smcs secretaria municipal de comunicação social: 68 descriptions against 1, and 0.045 of trigram
similarity). The card takes the names through the type-ahead the dossier's Assuntos tab already uses,
so it inherits the API's two-character floor instead of inventing one; it shows each name's weight, so
the canonical is chosen with the numbers in view; and its list is its own — it does not consume the
checkboxes of Relevância, and with fewer than two names nothing is written.
Reading is the other half of this screen, and the card opens into it. A name expands into the
descriptions that carry it — the collection search (GET /api/v1/documents) with entity_id,
paged ten at a time inside the card, so the badge above and the list's total are one number counted
twice; testing/integration/api/controller/test_entity_documents.py fails when they stop agreeing.
Clicking a row opens the read-only preview (?descricao=) that the category screen already uses, and
nothing in it writes. Its "Editar" lands on the dossier's Assuntos tab, which is where a
description's entities are added and removed. One card is open at a time, and ?aberta=, ?offset=
and ?descricao= carry that state in the URL — so the back button closes the panel and a colleague
can be sent the exact view.
The card's second tab is the rename, and the Renomear button beside the name opens it. It is the row's own affordance and never merges: the merge starts from two or more names and only renames the canonical as a side effect.
Remover de Entidades — /entidades/excecoes¶
The veto "this spelling is a subject, not a proper name". Vetoing deletes nothing. The write is
POST /api/v1/taxonomy/entities/ner-exclusions (CURATE), with a reason kept for audit, and the
reversal is DELETE (the screen's "remover veto"), which re-opens the term for future extractions.
One behaviour the screen states because it is not visible in the list: the block is by token
boundary, not by exact name (spaCy merges neighbouring tokens, so a banned iptu would not stop
"IPTU do Batel" under an exact comparison).
The retroactive half is a separate act, Expurgar do passado:
POST /api/v1/taxonomy/entities/ner-exclusions/purge (CURATE), after
POST /api/v1/taxonomy/entities/ner-exclusions/purge/preview. The preview lists every entity already
extracted from a banned spelling, with the descriptions that carry it, and the write consumes the same
plan. Each deleted entity is written to a ledger (archive_ner_purge_log) before it disappears:
the snapshot of the row, the documents that carried it, the spellings that pointed at it and the
excluded_term that justified it. The response carries the purge_ids, the screen shows them in
Histórico de expurgos, and DELETE /api/v1/taxonomy/entities/ner-exclusions/purge-log/{purge_id}
restores the entity, its links and its spellings — without lifting the veto, so the next purge
lists the restored entity again. Use it to reverse the loss; use "remover veto" to re-open the term.
The conflict screen resolves the same clash with the same pair of writes, and it is reversible there
in the same shape: one ledger row per pair (archive_conflict_resolution_log), with the losing row,
its links and its spellings restored by the undo. Reaching the operation from either screen now
restores the same things.
This is the other half of the bidirectional governance: it records the curator or the judge saying "this belongs to the subject axis", and it is stored apart from the subject-axis stopwords on purpose, so a veto here can never make the subject purge delete a tag the curator kept.
Conflitos — /entidades/conflitos¶
The same spelling on both axes: "is this a subject or a proper name?". Three reads answer three questions, and reading only the first is what used to hide the judge's work:
- Pendentes — the live trigram scan (
GET /api/v1/taxonomy/conflicts/cross-domain), annotated with the judge's verdict where there is one and filtered bypair_kind: "grafias diferentes" (a spelling question) and "nomes idênticos" (a structural question) are not the same list, and mixed together neither is visible. The same read takesentity_type(ORG/PER/LOC), because "a street is an entity" is a decision about a category of collisions and not about a pair. - Decididos pelo juiz — the review queue (
GET /api/v1/taxonomy/conflicts/judged). An auto-resolution deletes the losing row, so most of the judge's decisions cannot appear in the live scan at all. - Resoluções — the ledger (
GET /api/v1/taxonomy/conflicts/resolutions), with the undo.
The decision is preceded by the preview (POST /api/v1/taxonomy/conflicts/resolve/preview, a read):
it returns both verdicts with what each one transfers, deletes and blocks, computed by the same
planner the write executes. The two verdicts are stored in different places on purpose:
- the tag wins — the entity's links move to the tag, the entity row is deleted and the term is
recorded in
domain_ner_exclusions, carrying the reason, the source and the tag that justifies it; - the entity wins — the tag's links move to the entity, the tag row is deleted and the tag name joins the tag-scoped stopwords.
Collapsing the two would let a NER veto make the subject purge delete the winning tag.
Resolving is POST /api/v1/taxonomy/conflicts/resolve (CURATE) and the undo is
DELETE /api/v1/taxonomy/conflicts/resolutions/{resolution_id} (CURATE). The undo is exact
because the ledger records created_link_ids and ban_created: both writes use
ON CONFLICT DO NOTHING, so reversing a later resolution must not lift a ban an earlier one planted,
nor delete a link that predated it. The ledger restores the losing row from its snapshot. A pair
already resolved can be decided again — the second resolution is independent, and each has its own
undo.
Deciding a category at once¶
A collision is decided one pair at a time, but the decision the archivist is actually making is
categorical — "a street is an entity" — and a collection can hold dozens of the same spelling on both
axes. The batch, POST /api/v1/taxonomy/conflicts/resolve/batch (CURATE), applies one verdict to
the whole selection the list is showing: the same pair_kind and entity_type filters, the same
threshold, and nothing else.
The unit of the decision is the selection; the unit of the write is still the pair. Every pair is
planned by the same planner the single preview uses and written inside its own savepoint, so one pair
failing does not roll back the others, and every pair gets its own ledger row and its own
resolution_id — the undo stays per pair even though the click was categorical. A pair whose loser an
earlier pair of the same batch already removed is reported in skipped with its reason, never as
a failure: it is finished work, and calling it a failure is what once turned a clean batch into a wall
of red lines.
limit bounds one request, because a click that writes thousands of ledger rows is a click nobody
reviewed. remaining is the same read the list makes, taken again with the same filters, so the
number before the click and the number after it are one measurement — the screen cannot claim a
category is finished when the next page disagrees.
A resolved pair leaves the live scan
The resolution deletes the losing row, so the trigram join cannot return it and the live list has
nothing to annotate. That is why the work survives in the judge's queue and in the ledger, and
why the Decididos and Resoluções tabs exist.
Qualidade¶
| Screen | Decision | Reversible? |
|---|---|---|
Trechos — /qualidade/trechos |
which consumer stops reading a repeated excerpt | retiring yes; removing it from the catalogue is final |
Regras — /qualidade/regras |
whether a rule rewrites the text or only flags it | yes: retiring has a route back, and the retired rule stays on the screen |
Anomalias — /qualidade/anomalias |
none (the correction is the record's review) | yes (read only) |
Trechos — /qualidade/trechos¶
The catalogue of repeated excerpts. This is the largest measured gain of the project: 53% of the
collection shared the same scope_content block, and a repeated title prefix dominated the
embeddings and degraded the search. The decision that matters is the scope: which consumer stops
reading the excerpt — EMBEDDING (the vector), NER (the names) or TITLE (the suggested title).
The same block is dropped from the consumers it damages and kept where it helps: approving
everything the machine suggested worsened the ranking (Hit@10 0.562 → 0.500), which is why there is
no "approve all" button on this screen.
- Procurar —
POST /api/v1/quality/text-templates/suggest(CATALOGUE) registers the candidates as inactive suggestions: nothing enters the AI text before an archivist approves a scope. - Escrever um trecho —
POST /api/v1/quality/text-templates(CATALOGUE). It is born approved and applied and returns the affected descriptions to the AI queue; the create button only unlocks after the impact is seen. - Aprovar / rejeitar / aposentar / salvar escopo / remover —
PATCH/DELETE /api/v1/quality/text-templates/{template_id}(CATALOGUE). Removing takes the excerpt out of the catalogue and returns the affected descriptions to the AI queue.
Regras — /qualidade/regras¶
The one place a regular expression can rewrite the collection. rule_kind is the difference between
cleaning and destroying, and the screen makes the choice explicit:
REWRITEreplaces every match in the target column on the next run — the cleaning worker filters it explicitly, so a validation rule never rewrites;VALIDATEandLLM_CHECKonly flag; the flag becomes an anomaly reason on the description.
The writes are POST /api/v1/quality/cleaning-rules (CATALOGUE) — saved only after a preview, for
a rule that rewrites — PATCH /api/v1/quality/cleaning-rules/{rule_id}/deactivate and
PATCH /api/v1/quality/cleaning-rules/{rule_id}/activate, both CATALOGUE. A rule is never
deleted, and the way back is a route rather than a new rule: the listing takes
include_inactive=true, which is what puts the retired rules at the bottom of the screen with the
button that returns them to the queue. Reactivating keeps the id and the history that re-creating
the rule would have lost. Retiring is the safe half of the pair — it is what you reach for when
a rule is wrong, so the way out of it has to exist.
An active REWRITE rule is the loudest thing on the screen, and the preview is what makes it
visible: a test rule once sat active and stamped the whole collection without matching anything.
An LLM_CHECK rule also names an engine and a preset, and both are chosen from the registry
rather than typed from memory: the two selects are fed by
GET /api/v1/quality/cleaning-rules/engines, which is the title-quality axis's own catalogue — the
same names get_engine validates against, so a model that retires stops being offered here in the
same change that retires it. The pair is checked when the rule is written, so a rule that cannot run
cannot be saved (the refusal is a 422 naming the options), and an empty engine stays legal: it
means the axis's default. A rule written before that check existed degrades instead of killing the
run — the validator logs the reason, validates deterministically, and its producer identity is the
"no model" one, so the queue and the panel's counter describe the run that actually happened.
Anomalias — /qualidade/anomalias¶
What the quality validator marked, read-only (GET /api/v1/documents filtered to NEEDS_REVIEW,
optionally by anomaly reason). The counts by reason are the search's own facet over the whole
filtered set, and clicking one narrows the list. An empty queue has two very different causes and
the screen tells them apart: the collection is clean, or no rule that flags (a VALIDATE or
LLM_CHECK rule) is active — a REWRITE rule changes the text and never flags. The correction is
the human review of the record, in the dossier.
Sistema¶
The machine, not the collection. Everything here belongs to whoever operates the installation, and
all three screens are reached from /configuracoes, under Operação: the menu kept what the
archivist consults while cataloguing, and this is not it. The two ways into the collection live in
the Acervo (/acervo/nova), and the origins page is the operator's; the read routes stay
authenticated, so a curator who follows a direct URL still sees it read-only.
| Screen | Decision | Reversible? |
|---|---|---|
Origens — /sistema/ingestao |
which source to add, its URLs and agenda, whether it is active, and whether to enqueue a run (operator) | the configuration, the agenda and the pause are reversible; the screen writes no description |
Workers e execuções — /sistema/workers |
whether to run a worker now (Painel); whether to stop an active run (Execuções) | yes for the run's own configuration; a run's effects follow what it wrote. No undo for a cancelled run: it stays cancelled, and a new run is a new ledger row |
Saúde do sistema — /sistema/diagnostico |
none (database, models, storage, process) | yes (read only) |
Origens — /sistema/ingestao¶
The stage in front of the archive, source by source (ADR 0016). Origens lists the configured sources: each card has the queue the next execution would read, the processed/failed numbers, the last and active run, the agenda and the effective configuration. The operator adds a definition from the picker, edits the adapter configuration (the reference scraper's listing and detail URLs, the politeness delay), saves a five-field cron in an IANA zone, Desativa/Ativa and Enfileira execução. Pausing keeps the row, the agenda and the runs; only the run is refused. A fresh install starts with no source configured and shows Nenhuma origem configurada; the add flow activates the chosen definition. The run is the same ledger the AI workers use; a period that fires while the source is already running is skipped, not queued.
Entradas shows the manual and CSV rows read-only, with their status and a link to the Acervo:
they are the entry paths, not sources, and the two writes that used to live here are described under
Nova descrição (#205). The card is an OPERATE one; the read routes stay authenticated, so a
curator who follows a direct URL sees the page read-only with the controls hidden. The generic
worker routes refuse ingestion — a run of it needs the origin, and the origin routes are the
selector.
Workers e execuções — /sistema/workers¶
One screen with two tabs in the URL (#207), and it answers the run-check loop that used to need two
screens: the Painel tab enqueues and shows the queue, and the Execuções tab is the ledger of
what ran. The last run of a worker is a link to the ledger filtered by that worker, with the exact
row highlighted; the old /sistema/execucoes path redirects there with the filters preserved. Only
the active tab is mounted, so only its read is polled.
The catalogue of the ten workers, in pipeline order, with the effective engine, preset and config on
the collapsed row — "with which model is this running?" is the question the screen exists to answer,
and here the answer is read only. What the installation is set to do is a screen of its own
(Configuração dos Workers, below), because the two questions are different: this one is what the
machine is doing. The row carries the queue — pending, processed, failed, last run — and the one
write is OPERATE. Expanding it opens four facts: the effective configuration, the queue, the
worker's ledger (Carimbos (descrições) when the ledger is a stamp, Processados otherwise) and
the last run. The queue and the ledger sit apart on purpose, and guides/operate.md owns what each
of the two counts:
- Rodar agora —
POST /api/v1/system/workers/{worker_name}/runs. The run uses the effective configuration, adjusted only for this execution; nothing adjusted here becomes the default. Every run leaves a row in the run ledger, and a worker with a run in flight disables the button — the guarantee is the partial unique index in the database, and the screen only avoids offering what the database would reject.
The ingestion row is the one exception to the run button: its execution belongs to an origin, so the row links to /sistema/ingestao (the origin screen) and the generic route refuses it.
The run panel's fields come from the contract, not from a text box: declared_options names the
options the worker accepts and, for a column list, the columns the AI text has
(original_title, final_title, scope_content, admin_bio_history, provenance), so the screen
renders a checkbox, a number or the columns themselves. The one free-form block is Repasses ao
engine (avançado): a raw pass-through to the engine constructor, per run and never persisted, with
the keys the axis already passes (model, device, host, ...) as a hint. It is the named
exception of #120 — it exists for whoever knows the library, and the engine validates the value.
The panel itself offers no undo for a run. The reversibility of a run is the reversibility of what it
wrote — a merge undo, a materialisation undo, a conflict undo — and a worker that stamps its queue
does not redo its work just because it is run again. The panel marks four workers as outside the
human review block — the transfer, the conflict judge, the macro-categoriser and worker_embedding —
and only worker_embedding rewrites a description: it re-embeds a changed text even on an approved
document, the documented exception. The other three work on units that are not descriptions (a
staging record, a pair, a tag).
Execuções — the ledger tab¶
Since #207 the ledger is the Execuções tab of /sistema/workers, and /sistema/execucoes
redirects to it with the search preserved. It is read-only except for the stop: one row per
execution, from the command line and from the panel, carrying the resolved configuration the run
used (so it stays readable after a preset changes in code),
the effective worker options (the columns it read, the floor, the cap — resolved when the run
started, so "which columns did run #N read?" has an answer), the duration, the outcome and who
asked. A row older than the options column says "opções não registradas nesta execução". Above it, the failures of the last 30 days grouped by root
cause (GET /api/v1/system/failures), which answer the question the ledger cannot: forty rows saying
the same sentence are one cause. Clicking a group filters the ledger below instead of opening a
second list, and a failure's reference is what is searched in the log.
The one write is Cancelar execução… on an active row (POST /api/v1/system/runs/{run_id}/cancel,
OPERATE). A queued run no runner claimed is cancelled at once; a running one is only asked, and the
row keeps saying "em execução" with a note until the worker reaches the batch boundary — the batch in
progress is committed, not interrupted, because a model pass cannot be cut in the middle. A second
click is the same request, and a run that already finished answers 409.
Saúde do sistema — /sistema/diagnostico¶
Four independent probes (GET /api/v1/system/health, OPERATE): the database with its counts, the
Ollama server with the models the presets require and which of them are missing, the thumbnail
storage, and the effective process configuration. Each probe answers on its own — a database that is
offline must not hide that Ollama is fine — and no secret value is ever shown: the process card
reports that a secret exists, never what it is. Nothing here writes.
Configurações¶
The landing that gathers what the installation is, out of the way of what the archivist does every day. It writes nothing of its own.
| Screen | Decision | Reversible? |
|---|---|---|
Configurações — /configuracoes |
which setup screen to open | yes (read only) |
Configuração dos Workers — /configuracoes/workers |
the persisted default of each worker, and its history | yes (remove the row) |
Usuários — /configuracoes/usuarios |
who exists, what they may do, and where they are signed in | yes (deactivate and reactivate) |
Configurações — /configuracoes¶
A page of cards, and every card opens a screen that already existed with the route it always had: what changed is how it is found. Three tabs, in the order of the permission ladder they walk:
| Tab | Cards |
|---|---|
| Arranjo e catálogos | Plano de arranjo (CURATE), Níveis de descrição, Tipologias and Vocabulário do acervo (CATALOGUE) |
| Operação | Workers e execuções, Configuração dos Workers and Saúde do sistema (OPERATE) |
| Acesso | Usuários (ADMIN) |
The tab is in the URL (?aba=), so "the worker cards" can be sent to somebody. A tab the account
cannot fill is not rendered, and a ?aba= naming one falls back to the first tab the account can
fill: a link that was valid for the sender still lands somewhere honest for the reader. Each card
carries the area its screen writes, and the entry that opens this page follows the same areas — the
rule is under The menu hides; it never grants above.
Configuração dos Workers — /configuracoes/workers¶
What the installation is set to do, apart from what the machine is doing (issue #53). One card per
worker, in pipeline order, each carrying the persisted row of archive_worker_settings and its
revisions — both writes OPERATE:
- Salvar padrão —
PUT /api/v1/system/workers/{worker_name}/settingswrites the default. It is partial on purpose: an empty field keeps following the code, and the precedence is unchanged —explicit argument > the archive_worker_settings row > the signature default. The form renders one control per declared option (a column list as the columns the AI text has);limitis absent because a cap belongs to one execution, andconfigis still refused as an option, because it is a runner dataclass and not a JSON value.engine_name,presetanddb_batch_sizehave fields of their own and are refused inside the options, which would otherwise be a second definition of the same field. - Conferir impacto —
POST /api/v1/system/workers/{worker_name}/queue/previewanswers how many units the configuration the form is showing would leave in the queue, and writes nothing. It exists because the queue is keyed on the producer identity: saving another engine, preset or threshold re-reads the affected units by itself, and that cost has to be readable before it is paid. The number disappears as soon as a field changes, so it never describes a configuration the form no longer shows. - Voltar ao padrão do código —
DELETE /api/v1/system/workers/{worker_name}/settingsremoves the row. Every write leaves a revision (GET /api/v1/system/workers/{worker_name}/settings/revisions), listed on the card, so the default is reversible and the person who changed it is named.
A worker whose engine lives in the active LLM_CHECK rule (quality-validator) does not offer the
engine/preset choice here: a second place to choose the same engine would be a second source of
truth, so the card says where it lives and offers only the run parameters. The screen reads
GET /api/v1/system/workers/settings, which is not the panel's GET /api/v1/system/workers — the
panel counts nine queues, and this screen shows none of them.
A row written before the option validation is not a new mistake: the read ignores the option it can no longer honour and explains it in the card's note ("Padrão salvo ignorado — …"), so the screen answers with the code default instead of failing the queue counter. Saving the form again writes only the fields it shows, which heals the row.
Usuários — /configuracoes/usuarios¶
The accounts of the installation, the ADMIN surface. The writes are POST /api/v1/users (create),
PATCH /api/v1/users/{user_id} (name, role, active), POST /api/v1/users/{user_id}/password (reset)
and the session routes GET/DELETE /api/v1/users/{user_id}/sessions[/{session_id}] — all ADMIN.
Five facts the API enforces and the screen states:
- the password created here is temporary. The administrator typed it, so the account replaces it at the first sign-in, exactly like the CLI bootstrap;
- deactivating ends every session of the account immediately, which is what makes the flag real;
- resetting the password ends every session and lifts a lockout caused by failed attempts. It is reachable from the account's own row (redefinir senha), without expanding the card, and the confirmation names both consequences;
- resetting your own account signs you out. The administrative reset has no "keep this session" the way Trocar senha does, so an administrator who targets their own row ends the session they are standing on; the screen says so before the click and points at Trocar senha in the session footer, which keeps it;
- the last active administrator cannot be deactivated or demoted (
LastAdminError, 409). It is the one lockout with no way back through the screen; the way in is the CLI on the host.
There is no delete. An account is deactivated, because the ledgers carry its name and its id, and a decision whose author no longer exists is a worse record than a closed account. Revoking a session id that belongs to another account answers 404 and not 200, because the id alone must not enumerate other people's sign-ins; the row that is the archivist's own session is marked "esta sessão".
The account on this screen is never the first one. An installation with no account at all is brought to life by the first-run screen it answers to the first visit, or by the CLI on the host, and both of those close forever once an account exists — including a deactivated one (ADR 0011). After that, this is the only surface that creates accounts.