All notable changes to this project are documented here.
-
DETECT-013 Optional label-anchored job-title and education-qualification detection. Two new opt-in regex rules (
JOB_TITLE,EDUCATION) insrc/anonymizer/detect/regex_rules.pycapture the free-text role / qualification value listed after a Russian résumé label («Должность», «Занимаемая должность», «Роль» forJOB_TITLE; «Образование», «Квалификация», «Специальность», «Учёная степень» forEDUCATION) under a new umbrellaCategory.OCCUPATION(src/anonymizer/categories.py). The separator anchor requires at least one of:,—, or-between label and value (a pure-whitespace separator is rejected on purpose) so a free-text usage like «должность позволяет ему» — no colon, common-noun follow-on — does not match even with the flag on. The capture body[^\n,;.]+terminates on end-of-line, comma, semicolon, or period, soДолжность: Главный инженер.tokenises the role text without the trailing period, and a two-clause lineДолжность: Главный инженер. Образование: магистр информатикиsplits into two distinct OCCUPATION captures. Off by default; opt-in viaOptions.occupation: bool = False(src/anonymizer/api.py) plus a CLI flag--occupation(src/anonymizer/cli.py). Gating uses the existingRule.option_namefield —regex_rules.detect()skips the gated rule when itsOptionsattribute is falsy. The rule-ordering snapshot (tests/test_rule_ordering.py) is updated to addJOB_TITLE,EDUCATIONafterFAMILY_RELATION; the architecture-test category list (tests/test_architecture.py::_CATEGORY_NAMES) addsOCCUPATION;Categorymember count intests/test_categories.pyis bumped from 23 to 24. QA-001 corpus precision/recall is bit-identical when the flag is off (the new rules are gated behindoption_name="occupation"); round-tripanonymize → restorereproduces the input exactly. No new dependencies; no network; no document content in logs. -
DETECT-012 Optional label-anchored family-relation detection. A new opt-in regex rule (
FAMILY_RELATION) insrc/anonymizer/detect/regex_rules.pycaptures the capitalised names of family members listed after a Russian relation label («Супруга», «дети», «сын», «дочь», «родители», «отец», «мать», «брат», «сестра» — including the «супруга»/«супруги»/«супругой» inflections) asCategory.PERSON. The relation noun itself stays in the document — only the captured name becomes the span. Off by default; opt-in viaOptions.family_relations: bool = False(src/anonymizer/api.py) plus a CLI flag--family-relations(src/anonymizer/cli.py). The label part is case-insensitive via inline(?i:…)so a mid-sentenceдети:after a semicolon matches as well as a sentence-leadingСупруга:; the captured-name[А-ЯЁ]first-character anchor stays case-sensitive so a lowercase common-noun follow-on (Дети программистов учатся быстрее) does not match even with the flag on. A companion_detect_family_continuation_nameshelper walks comma-separated trailing names from the same label scope and emits each as its own PERSON span, soСупруга: Иванова Анна Петровна; дети: Иванов Пётр, Иванова Марияyields three distinct{{PERSON_N}}tokens. The relation noun list lives inline in the rule (no JSON dictionary, per the task spec). The rule-ordering snapshot (tests/test_rule_ordering.py) is updated to addFAMILY_RELATIONafterBIOMETRIC_MENTION; QA-001 corpus precision/recall is bit-identical when the flag is off (the new rule is gated behindoption_name="family_relations", just like the DETECT-009 biometric-mention precedent); round-tripanonymize → restorereproduces the input exactly. No new dependencies; no network; the new helper logs nothing. -
DETECT-011 Structural validator for the БИК (Bank Identification Code) rule. A new
_bik_ok(value)helper insrc/anonymizer/detect/regex_rules.pyrejects a 9-digit run that follows aБИКlabel but is not a real BIK: it enforces a 9-digit length, a country prefix in{"04", "00"}(Russia / CIS participants of the national payment system per the Central Bank of Russia), and a non-zero combined region-and-territorial sub-field (digits 3-6). The validator is attached to the existing label-anchoredBIKrule via theRule.validatorfield — the same wiring_inn_ok/_ogrn_ok/_ogrnip_ok/_snils_okuse. There is no published mathematical control-digit for BIK in the public CBR spec, so the validation is structural (length + country-prefix + non-zero sub-field) — documented explicitly in the_bik_okdocstring. No bare-BIK rule is added: without a mathematical checksum the false-positive rate on bare 9-digit runs (КПП looks identical, postal codes, internal IDs) would be unacceptable. CIS-country BIKs that share the format are kept detectable via the00prefix entry. Existing BIK coverage stays green — the corpus value044599001(tests/corpus/02-company.json) and the inline sample044525225(Sberbank Moscow,tests/test_detect.py) both pass the new validator; QA-001 baseline metrics are bit-identical. New unit tests intests/test_detect.pycover the four cases: valid Russia-prefix BIK detected, invalid country prefix rejected, all-zero territorial sub-field rejected, corpus value still green, and a round-tripanonymize → restoreis exact. -
DETECT-010 Label-anchored medical-data detection. Four new always-on regex rules in
src/anonymizer/detect/regex_rules.pycover ФЗ-152 medical PII:OMSforПолис ОМС: <16 digits>,DMSforПолис ДМС: <token>,DIAGNOSISfor(?:диагноз|анамнез|история болезни): <line>, andICD10for МКБ-10 codes (A00.0…Z99.9) appearing immediately after a medical label (диагноз,анамнез,МКБ,МКБ-10,код МКБ). All four share a single umbrellaCategory.MEDICAL(src/anonymizer/categories.py) — the audit row lumps them together and the downstream replacement layer only cares about the category. The rules are precision-safe (every match requires an explicit Russian medical label), so they ship always-on like the existing label-anchored INN/OGRN/etc rules — noOptionsflag is introduced. The ICD-10 rule is compiled withoutre.IGNORECASEso the uppercase-Latin code (I10.0) is enforced; the label part carries its own inline(?i:…)group soДиагноз/ДИАГНОЗ/диагнозall match. A bare 16-digit non-Luhn number outside aПолис ОМСlabel stays untokenised (the existing CARD rule misses real OMS numbers because they are not Luhn-valid), and an ICD-10-shaped token likeРаздел A10.1in a table of contents is not matched (no preceding medical label). The rule-ordering snapshot (tests/test_rule_ordering.py) is updated to includeOMS,DMS,DIAGNOSIS,ICD10betweenSECRET_HIGH_ENTROPYandBIOMETRIC_MENTION; the architecture-test category list addsMEDICAL. QA-001 corpus precision/recall is bit-identical (no medical descriptors in the corpus); round-tripanonymize → restorereproduces the input exactly. -
DETECT-009 Optional surface-text biometric-mention detection. A new opt-in regex rule (
BIOMETRIC_MENTION) insrc/anonymizer/detect/regex_rules.pytokenises Russian biometric-data descriptors — «отпечаток пальца», «голосовой отпечаток», «голосовая модель», «ДНК», «генетические данные», «радужная оболочка [глаза]», «биометрические данные» — under a new narrowCategory.BIOMETRIC(src/anonymizer/categories.py). The rule is anchored on Cyrillic word boundaries (so a stem cannot match inside a longer word) and is case-insensitive. Default behaviour stays off — the mention itself is descriptive, not identifying — opt-in via a newOptions.biometric_mentionsflag (src/anonymizer/api.py) and a matching CLI flag--biometric-mentions. Gating is done by a newRule.option_namefield;regex_rules.detect()skips any rule whose gating attribute is falsy on the passedOptions, so the existing rule list and detection pipeline stay unchanged when the flag is off. The rule-ordering snapshot (tests/test_rule_ordering.py) is updated to includeBIOMETRIC_MENTIONat the end ofRULES, and the architecture-test category list is extended. With the flag off the QA-001 corpus precision/recall is bit-identical to the previous baseline (no biometric descriptors in the corpus); round-tripanonymize → restorereproduces the input exactly. Image / binary biometric payloads remain out-of-scope — this is text-only by construction. -
DETECT-008 Document-declared alias propagation. A new
_ALIAS_REregex insrc/anonymizer/detect/regex_rules.pymatches the canonical Russian contract-alias shapes —(далее — Х),(далее именуемый Х),(далее по тексту — Х). After regex and NER detection (and brand propagation), a new_alias_propagatepass insrc/anonymizer/detect/__init__.pyfinds each alias declaration, locates the detected PERSON / ORG / LOC span immediately preceding the parenthesis (within ~10 non-whitespace characters), and emits a new span at the alias text site with the parent's category andvalue=alias_text. The downstream consistency sweep then propagates that span to every later word-bounded occurrence of the alias, soООО «Альфа-Бета» (далее — Альфа). … Альфа поставляет товар.tokenises everyАльфаmention as ORG, andИванов И.И. (далее именуемый Заказчик)tokenises everyЗаказчикmention as PERSON — overriding Natasha's ORG tag for that word, because the alias declaration is the authoritative category for that text. Filters: alias text shorter than 3 characters, alias text containing{{…}}placeholders, and stoplisted alias text (country names, generic nouns — same NER stoplist used by brand propagation) are all skipped. Aliases with no detected parent span are left untokenized by the alias pass (Natasha may still tag them independently). QA-001 corpus precision/recall unchanged (alias-declaration shape is not exercised in the current labelled corpus); round-tripanonymize → restorereproduces the input exactly. -
DETECT-007 Brand short-name propagation across the document. A new propagation pass in
src/anonymizer/detect/__init__.py(_brand_propagate) narrows every legal-form ORG span (ООО «Альфа-Бета»,ООО АСТРАЛ-СОФТ, full-spelling forms likeОбщество с ограниченной ответственностью «Тест») to its inner company short-name and then scans the document for every word-bounded bare repeat of that short-name, adding it as an ORG span with the inner-name value.replace_spansreuses one token across the legal-form line and every later mention, so a document with oneООО «Альфа-Бета»followed by three bareАльфа-Бетаmentions tokenizes all four occurrences to the same{{ORG_1}}and round-tripanonymize → restorereproduces the input exactly (the literalООО «...»framing stays in the output). The reusable extractor lives insrc/anonymizer/detect/regex_rules.pyasextract_inner_org_name(value)— it strips the leading legal form, surrounding quotes, and a trailing legal-form short word. A minimum inner-name length of 3 characters and a reuse of the NER stoplist (_lemma_keyagainst country names, generic nouns, …) gate propagation soИТfromООО «ИТ-Сервис»does not match insideлитерandРоссияfromООО «Россия»is not propagated to its bare country mentions. NewOptions.brand_propagation: bool = Trueplus a CLI--brand-propagation/--no-brand-propagationflag let callers opt out; with propagation off the legal-form ORG span keeps its full value (previous behaviour).tests/corpus/02-company.jsonupdated to expect the inner-name ORG value (Рога и Копыта); QA-001 ORG precision/recall stay at 1.0/1.0 on the labelled corpus. -
DETECT-006 ФЗ-152 PII coverage audit against the canonical category list in
../pdf c привт/TZ.md:80-146. Triaged checklist now lives indocs/detect-006-fz152-coverage.md, with every row marked covered / partial / missing / out-of-scope plus file:line evidence. Five spawned per-gap tasks added toBACKLOG.mdPhase 7: DETECT-009 (biometric surface-text mentions, opt-in), DETECT-010 (label-anchored ОМС / ДМС / МКБ-10 / диагноз / история болезни), DETECT-011 (БИК structural validator), DETECT-012 (optional family-relation labels), DETECT-013 (optional job-title and education-qualification labels). Out-of-scope items (image / binary biometric data, photographs) recorded with one-line rationale. No detection code changed in this ticket — implementation lives in the spawned tasks. -
DETECT-005 Context-anchored single-surname detector for electronic-signature blocks. A new
PERSON_SIGNATURErule insrc/anonymizer/detect/regex_rules.pyfires when a signature-context label (УКЭП,подписант,подписал,подписавший,подписывающий,ФИО,фамилия,инициалы,подпись) is followed by:/-/—and a capitalised Cyrillic token of ≥3 letters (e.g.УКЭП: КРУГЛОВ,Подписант: Иванов,Подпись — Петрова). Only the surname is captured as the span; the label stays verbatim. The label alternation is case-insensitive via an inline(?i:…)group, while the surname class is case-sensitive so a lowercase common noun does not match. Ordered after the CORE-005 patronymic-triple rule so a full ФИО after a signature label (подписант: ИВАНОВ ИВАН ИВАНОВИЧ) is still captured as the three-word person (the longer span wins overlap resolution in_resolve), not just the surname. Rule-ordering snapshot intests/test_rule_ordering.pyrefreshed. Unit tests intests/test_detect.pycover the three positive separators, the full-ФИО overlap, two negative cases (Город: МоскваandПодпись: АБ), and a round-tripanonymize → restore. QA-001 corpus precision/recall on PERSON is unchanged. -
UI-023 GitHub repository link in the main-window header. A small icon button anchored at the trailing edge of a new header row opens the project page in the user's default browser via
webbrowser.open. The URL is read from PEP 621 metadata (importlib.metadata.metadata("pdf-md-anonymizer"),Project-URL: Homepage, …, with aHome-pagefallback), so changing[project.urls] Homepageinpyproject.tomland reinstalling redirects the button without source-code edits. A monochrome GitHub Invertocat PNG is shipped undersrc/anonymizer_ui/resources/(1× and 2× variants); the button silently falls back to a "GitHub" text label if the icon cannot load. Unit tests intests/test_ui.pycover the metadata resolver's Project-URL / Home-page / missing-package branches and assert the click handler callswebbrowser.openwith the resolved URL. -
UI-022 Main-window title now includes the running app version (e.g.
"PDF-MD Anonymizer 0.1.0") so users with multiple builds installed can tell at a glance which one is running. Version is sourced from Toga/Briefcase'sself.version(already populated frompyproject.toml) with a fallback toimportlib.metadata.version("pdf-md-anonymizer")and a final fallback to the bare formal name. The macOS application-menu name (Briefcase-controlled) is unchanged. Unit tests intests/test_ui.pycover the three branches of_window_title()and assert the title matches the installed package version.
- UI-021 Stage-progress log lines in the result view no longer concatenate
onto a single row.
BatchRunController._on_stage_updatenow appends\nwhen forwarding stage text to theon_logcallback, matching the existing file-result lines (✓ file.pdf (Xs)\n/✗ file.pdf: error\n). The internal_stage_textused byprogress_text()stays newline-free so the single-line status label is unaffected. Unit testtest_batch_controller_stage_log_is_newline_terminateddrives the controller through CONVERTING → DETECTING and asserts every log line ends with\nand that consecutive stage updates do not glue together.
-
Complexity cleanup Reduced cyclomatic complexity across the codebase (radon / lizard / xenon). Two functions at radon rank C were refactored via extract-function:
pdf_to_markdown(CCN 11 → 4, split into_spawn_pdf2md/_wait_for_pdf2md) and_find_site_packages(CCN 12 → 8, split out_resolve_via_shebang).AnonymizerApp.startupwas decomposed from 137 lines / CCN 6 into five focused helpers (_build_main_layout,_register_global_commands,_apply_initial_window_prefs,_install_macos_hooks,_setup_pdf_controller);on_runwas flattened by extracting_ensure_pdf_backend_ready. -
appkit_bridge package Split the 831-line
appkit_bridge.pyinto theappkit_bridge/package:_common,activation,pasteboard,notifications,file_picker,status_icon,drag_drop,global_hotkey,reopen_handler,accessibility,login_item. The public API is re-exported from__init__.py, so callers keep usingfrom anonymizer_ui import appkit_bridgeandappkit_bridge.X. The UI-009 architectural rule ("ObjCClass calls confined to one place") is preserved at the package boundary;test_objcclass_only_in_appkit_bridgeupdated accordingly. Test monkeypatches of private helpers (_macos_version_tuple,_register_via_swift, …) now target the submodule that owns them. -
pdf_backend package Split the 525-line
pdf_backend.pyinto thepdf_backend/package:_smoke(smoke-test runner + minimal-PDF generator),_script_patch(pdf2mdshebang rewriter), and the rest in__init__.py. Site-packages resolution stays in__init__so thepdf_backend._python_lib_tagmonkeypatch in tests keeps working. -
ner_helpers split Extracted span-shape helpers and POS predicates from
detect/ner.py(500 LOC) intodetect/ner_helpers.py. The filter chain (FILTERStuple +SpanFiltersubclasses) and theNerPipelineclass stay inner.pyper the Phase-8 architectural invariant.ner.pyre-exports the helpers so test imports such asfrom anonymizer.detect.ner import _merge_hyphenatedkeep working. -
clipboard_ops + main_window_layout Extracted the clipboard/notify flow (
run_anonymize_to_clipboard,read_clipboard,write_clipboard,send_notification) and the Toga widget construction (build_main_layoutreturning aMainWindowWidgetsdataclass) fromapp.py. Three regression tests for the threaded anonymize path were updated to callclipboard_ops.run_anonymize_to_clipboarddirectly with explicit callbacks instead of monkeypatching an unbound method.
- Briefcase create — docopt install Vendored
vendor/docopt-0.6.2-py2.py3-none-any.whland referenced it from[tool.briefcase.app.anonymizer-ui].requires. pymorphy2 (transitive dep of natasha) requiresdocopt>=0.6, but upstream docopt 0.6.2 ships sdist-only on PyPI, which Briefcase's--only-binary :all:pip install cannot fetch. The vendored wheel resolves the requirement from a local path before pip queries PyPI. Pre-existing failure unrelated to the complexity cleanup (same error inlogs/briefcase.2026_05_18-*.log).
-
PDF-004 Batched
pdf2mdinvocation:convert.pdf_batch_to_markdownspawns one subprocess for a group of PDFs and streams per-file results out through anon_filecallback as each conversion finishes. The Docling model loads once per group instead of once per file, so a 3-file PDF batch costs roughlyinit + N × per_file_convertwall-clock instead ofN × (init + per_file_convert).batch.process_filesnow groups consecutive.pdfinputs into onepdf_batch_to_markdowncall; a.mdinput or end-of-list flushes the group. Per-file conversion failures are reported viaon_file(path, exception)and do not abort siblings;cancel()between files kills the subprocess and keeps already-written outputs. ADR-0002's subprocess boundary is preserved — the import of thepdf-to-markdownpackage lives in a-cdriver string executed in the child process, never insrc/anonymizer/. Apdf2mdbuild with no Python shebang or older builds withoutcreate_docling_converterfall back to one subprocess per file. Publicpdf_to_markdown(path, …)signature unchanged. New ADR-0010. -
Complexity tooling
radon,lizard,xenon,wilyadded to thedevextra. Newxenonpre-commit hook (--max-absolute B --max-modules B --max-average A src/) fails the commit when any function lands at rank C or worse. -
DETECT-004 Regex detector for
@-style nickname mentions. A newNICKNAMEcategory and rule detect handles such as@vasily,@john_doe, and@user-name. The negative lookbehind(?<!\w)prevents matching the local-part of an email address (vasily@example.com), and the EMAIL rule (ordered first) wins any overlap. A bare@or@123(no leading letter) is not matched.Category.NICKNAMEadded to theCategoryStrEnum. Rule ordering snapshot updated. -
UI-020 "Launch at Login" toggle in the status-bar menu, placed in the same section as the Quit item. When enabled, the app registers itself as a macOS login item with
--backgroundviaSMAppService.mainAppService(macOS 13+) or a userLaunchAgentplist (older macOS), and forces the "Stay in menu bar" switch ON and disables it (menu-bar mode is a prerequisite for a background-only start). When disabled, the login item is unregistered and the Stay switch is restored to its prior state. The--backgroundflag is consumed in__main__.pybeforetoga.App.main_loop(); when present,main_window.show()is skipped so only the menu-bar icon appears at login.launch_at_login: boolis persisted inprefs.json. On startup the pref is reconciled with the actualSMAppServiceregistration status. No new runtime dependencies. Newappkit_bridgehelpers:register_login_item(args),unregister_login_item(),is_login_item_registered(). NewMenuBarControllermethods:_apply_stay_lock,_cmd_toggle_launch_at_login,_reconcile_launch_at_login,stay_lockedproperty.
- UI-019 Drag-and-drop was silently rejected on macOS 12+ because Finder
switched its primary file-drag pasteboard type from
NSFilenamesPboardTypetopublic.file-url(NSPasteboardTypeFileURL), but the overlay registered only the legacy type sodraggingEntered_was never called — no drop cursor appeared and files were never added to the list. Fix inappkit_bridge.setup_drag_drop: (1) the overlay now registers for both"NSFilenamesPboardType"and"public.file-url"so AppKit invokes the drag-enter callback on all macOS versions; (2)performDragOperation_tries the legacypropertyListForType_("NSFilenamesPboardType")path first and falls back toreadObjectsForClasses_options_(NSArray<NSURL>, None), extracting paths viaNSURL.path, when the legacy type returns nothing. The.pdf/.mdextension filter indrag_drop_overlay.setupis unchanged. No new dependencies.
- UI-018 Global system keyboard shortcut ⌃⌥⌘C for "Anonymize Clipboard"
while menu-bar mode is active. Uses
NSEvent.addGlobalMonitorForEventsMatchingMask:handler:(Input Monitoring) so the action works from any application without switching focus. The handler fires on an AppKit thread and schedules work on the asyncio event loop vialoop.call_soon_threadsafe, producing identical UX to the menu-bar "Anonymize Clipboard" item (same notification, same busy icon). Aglobal_hotkey_enabled: boolpref (defaulttrue) inprefs.jsonsuppresses registration without disabling menu-bar mode. A one-time guidance notification fires on first menu-bar activation to direct the user to System Settings › Privacy & Security › Input Monitoring; thehotkey_permission_prompted: boolpref prevents repeat prompts. The shortcut is registered when the status item is created and unregistered when it is removed — active/inactive within one toggle cycle, no restart needed.appkit_bridge.register_global_hotkey/appkit_bridge.remove_global_hotkeyare independently unit-tested with a stubbedNSEventclass.
NerPipeline.detectnow recovers PERSON (and ORG/LOC) spans that Natasha's CRF model silently drops when the immediately surrounding lines provide a confusing token sequence (observed: a bare/on the line after a name, or a navigation word likeАватарon the line before it). The fix adds a per-line fallback pass: after the full-document NER run,_detect_inneris also called for each individual line and any non-overlapping spans are added to the result with adjusted offsets. Skill-section suppression is re-applied post-hoc using full-document ranges so ORG/LOC entities inside skills sections are not incorrectly resurrected. Trade-off: ~2× NER inference time on the total text; not a concern for clipboard or typical résumé lengths.
- pdf-backend pip runner unified to a single
SubprocessPipRunner. The in-process variant (InProcessPipRunner,_find_pip_wheel,_LineWriter) was dead defensive code: in.appwe already used the subprocess runner with the system python, and in a dev venv the bundled-wheelsys.pathinjection was redundant because pip is importable from the venv anyway. The remaining runner binds to_find_build_python() or sys.executable, keeping the same behaviour for both deployment modes while removing ~60 lines and the side effects of importingpip._internalinto our own process.
_find_site_packagescould pick a stalelib/pythonX.Y/site-packages/from a previous install when several Python-version subtrees coexisted (e.g. an old.appinstall put packages underpython3.14and a later dev-venv install put them underpython3.12). The old name-sort heuristic returned the highest-version one even though pip had freshly installed to the lower version dir matching its actual Python. Consequence:_clean_orphan_python_dirsthen deleted the FRESH install (treating it as orphan) and_patch_pdf2md_scriptpointedsys.pathat the stale tree — the shebang Python then tried to load a.sobuilt for a different CPython ABI and the smoke test failed withModuleNotFoundError: pydantic_core._pydantic_core. Now_find_site_packagesqueries the shebang ofbin/pdf2md(the Python pip actually used) for itspython{X}.{Y}tag and returns the matching subtree.- File open dialog could appear behind another frontmost app on first menu-bar
invocation:
appkit_bridge.pick_filewas using the samens_app.activate()(macOS 14+) →activateIgnoringOtherApps_fallback chain that caused the "Show Window" two-click bug. Right after the Accessory → Regular activation policy flip,activate()no-ops and the NSOpenPanel lands behind whichever app was previously frontmost. Switched to the legacy aggressive form to match the fix inactivate_app. - "Anonymize File" felt dead for tens of seconds before showing the start alert
on PDF input:
_anonymize_in_threadwas callingsource()(which for the PDF path isread_input(path)→pdf2mdsubprocess, 30–90 s) on the UI thread before firing the "Anonymizing…" notification and starting the busy icon. Movedsource()inside the worker thread so the notification and busy icon fire immediately on click; added a regression test (test_anonymize_in_thread_notifies_before_slow_source) that asserts the notification and_begin_busyprecede thesource()call. - "Show Window" required two clicks:
appkit_bridge.activate_app()was using the macOS 14+NSApplication.activate()API which respects the currently frontmost app and silently no-ops right after an Accessory → Regular activation-policy transition. The first click changed policy but did not yank focus; the second click then worked because the app was already Regular. Reverted to the legacyactivateIgnoringOtherApps_(True)(the same call the pre-refactor code used), which works on the first click. - Cancel during PDF conversion:
pdf_to_markdown()now accepts acancel: Callable[[], bool]parameter. Internally it usesPopen+communicate(timeout=0.2)polling; whencancel()returnsTruethe subprocess is killed andConversionCancelledis raised.read_input()andanonymize_file()thread theCancelTokendown to the subprocess, so Cancel now interrupts an in-progress PDF conversion within ~200 ms instead of only taking effect between files. - PDF backend after Update: install / update now run a post-install
_finalize_install()that (a) sweeps orphanlib/pythonX.Y/subtrees left by previous installs against now-uninstalled Python versions (e.g. after a Homebrew bump ofpython3), (b) writes the minimalsys.path.insertpatch intopdf2md, (c) runs a smoke conversion on a tiny in-code-generated PDF, and (d) only if that smoke test fails on the docling MPS+float64 limitation does it rewrite the script withtorch.backends.mps.is_available = lambda: False. When upstream docling fixes the float64 use, the next Update's smoke test passes and the shim is left out automatically — restoring GPU acceleration without a code change._patch_pdf2md_script()is now parameterised (with_mps_shim) and fully idempotent (sentinel-tagged lines plus the older non-sentinelsys.path.insertform are both stripped before re-injection)._find_site_packages()prefers the directory that containspdf_to_markdown*files so an update that installs to a different Python version subdirectory picks the right path.
- CLI-003: replaced the
sys.argvrewrite incli.main()with an argparse-native routing strategy. Subcommand names are now derived from the parser itself via the subparsers action'schoicesdict, so adding a newsub.add_parser(...)call automatically extends routing with no inclusion-list edit. When no subcommand is given,main()builds a standalone anonymize parser (prog="anon") and parses directly.anon doc.md,anon restore out.md --map m.json, andanon --helpall behave as before. ADR not required — CLI contract unchanged; pure refactor. - QA-002: added
tests/corpus/baseline_metrics.jsonstoring per-category precision/recall (3 decimal places) and tp/fp/fn counts. Addedtest_metrics_match_baselinetotest_corpus_eval.pyasserting each metric stays within ±0.01 of the baseline. Updatedload_corpus()to only load numbered corpus files (NN-*.json) so the baseline JSON is not treated as a corpus document. Baseline must be updated manually when detection intentionally changes. ADR not required — no detection logic change. - NER-011: moved
_ensure_pkg_resources()call to module import time in awarnings.catch_warnings()block that suppresses thepkg_resources is deprecatedUserWarning once for the whole process. Removed the per-buildwith warnings.catch_warnings()block and_ensure_pkg_resources()call from_build_pipeline. The stub is installed before any natasha-side import that needs it. ADR not required — no behaviour change. - NER-010: created
src/anonymizer/detect/structural.pywithskill_section_ranges(text)andis_in_any_range(start, ranges). Moved_HEADER_RE,RESUME_FIELD_LABELS,RESUME_SECTION_HEADERS,RESUME_SKILL_SECTION_STEMSfromner.pytostructural.py.ner.pyimportsstructuraland delegates;_skill_section_rangeskept as a backward-compat alias._SkillSection.keep()updated to useis_in_any_range()._STOPLIST_TERMSreferencesstructural.RESUME_*constants. Added 11 tests intests/test_structural.py. ADR not required — pure refactor, no detection change. - NER-009: introduced
NerPipelineclass insrc/anonymizer/detect/ner.py. The class builds the natasha components in__init__, exposeslemma_key(text)as an instance-levellru_cached method, owns the stoplist as a lazy property, and providesdetect(text, options) -> list[Span]using theFILTERSchain. Module-level globals_pipelineand_stoplist_keysremoved; replaced by_default: NerPipeline | Noneandget_default()factory with double-checked locking.warmup(on_step=...)and module-leveldetect()delegate to the singleton. ADR not required — backward-compat API unchanged; pure refactor. - NER-008: extracted
SpanFilter(Protocol)chain fromner.detect(). AddedNerContextdataclass (text,skill_ranges,stoplist) and six concrete filters —_CategoryRouting,_PersonPropn,_OrgLocPropn,_SkillSection,_TechStoplist,_LemmaStoplist— collected in a module-levelFILTERStuple.detect()body is now 19 lines (was 53). Ordering locked intests/test_ner_filters.pysnapshot; 21 new unit tests cover each filter independently. ADR not required — pure refactor, no detection output change. - DETECT-003: converted
Ruleinsrc/anonymizer/detect/regex_rules.pyfrom aNamedTupleto a@dataclass(frozen=True)with a newname: strfield as the first argument. Every entry inRULESnow carries a stable identifier (e.g."INN_LABELED","INN_BARE","SECRET_PEM","SECRET_HIGH_ENTROPY"). Addedtests/test_rule_ordering.pywith a snapshot test that locks the current rule sequence. Architecture test updated to allowRule(...)name strings that coincide with category names. ADR not required — purely internal refactor with no API changes. - CORE-009:
detect/__init__.pynow importsnerat module scope (no more lazytry/except ImportError).getattr(options, "consistency_sweep", True)replaced withoptions.consistency_sweep(the field has always been a dataclass default). A missingnatashainstall now fails loudly at import rather than silently skipping NER detection. - CORE-008: replaced
OutputDirArgUnion and_resolve_dirinbatch.pywithclass OutputPolicyprovidingOutputPolicy.into_directory(d)andOutputPolicy.next_to_input()classmethods and aresolve(input_path) -> Pathmethod.process_filesnow acceptsoutput_policy/map_policyparameters.cli.py,app.py, andbatch_controller.pyupdated accordingly. Tests updated. - CORE-007: added
FileStatus(DONE/FAILED/CANCELLED) andStage(CONVERTING/DETECTING)StrEnumclasses insrc/anonymizer/batch.py.FileResult.statusnarrowed toFileStatus; allon_stagecallbacks useStagevalues. Raw string literals replaced inbatch.py,cli.py,app.py, andbatch_controller.py. Architecture test added totest_architecture.pyverifying no bare status/stage strings remain. - CORE-006: introduced
Category(StrEnum) insrc/anonymizer/categories.pywith all 20 detection category members.Span.categorynarrowed fromstrtoCategory; allRule(...)entries inregex_rules.pyuseCategory.<NAME>;_TYPE_TO_CATEGORYand everyif category == ...check inner.pyuseCategoryenum values. BecauseCategoryis aStrEnum, the{{CATEGORY_N}}wire format and all string comparisons are unchanged. Architecture test added totests/test_architecture.py; newtests/test_categories.pyverifies equality, f-string formatting, and member count. - UI-015: replaced
_run_pip/_run_pip_subprocess/_pip_install(upgrade=…)inpdf_backend.pywithPipRunnerProtocol,InProcessPipRunner, andSubprocessPipRunner. Boolean flag replaced by separateinstall_backend/update_backendfunctions. Addedresolve_cert()free function.sys.path.insertfor the pip wheel now happens at most once perInProcessPipRunnerinstance. Two new tests verify the exit-code-to-RuntimeError mapping and the single-insertion invariant. - UI-014: created
src/anonymizer_ui/drag_drop_overlay.pywith asetup()function that wrapsappkit_bridge.setup_drag_dropand adds pure-Python.pdf/.mdextension filtering.app.py._setup_drag_dropnow callsdrag_drop_overlay.setup(...). Two unit tests verify extension filtering without requiring a real ObjC runtime. - UI-013: extracted
_anonymize_in_threadhelper onAnonymizerApp, collapsing the duplicate thread-and-notify boilerplate in_do_anon_file_to_clipboardand_anon_clipboard_to_clipboard. Both are now one-line delegating wrappers. Empty-source (empty clipboard) early-exit is now inside the helper. - UI-012: extracted
MenuBarControllerintosrc/anonymizer_ui/menubar_controller.py. It owns_status_icon,_status_commands_added,_busy_count,_saved_status_icon_image, and all menu-bar lifecycle methods (create_status_item,remove_status_item,hide_window_to_menubar,show_main_window,begin_busy,end_busy).app.pyforwards to the controller;app.pyno longer referencestoga.MenuStatusIcondirectly. Alltest_on_window_close_*,test_on_menubar_pref_change_*, andtest_status_bar_has_two_clipboard_commandstests rewritten to drive the controller directly. - REF-001: added
tests/test_architecture.pywith_files_matchingand_grep_counthelpers and a smoke test asserting the core package does not import Toga. This file is the canonical home for Phase 8 module-boundary checks. - UI-008: two status-bar quick actions.
"Anonymize to Clipboard…" is replaced by two distinct menu items:
"Anonymize File to Clipboard…" opens a file picker each time it is
pressed; the picker is shown via
NSOpenPanel.beginWithCompletionHandler_(rubicon-objc Block) without attaching to the main window, so the hidden main window stays hidden when the app is in menu-bar-resident mode. "Anonymize Clipboard" reads plain text fromNSPasteboard, anonymizes it in a background thread, and writes the result back to the clipboard — no file picker, no file written to disk. A macOS notification fires on success or when the clipboard holds no text. Added_read_clipboard()static method and_pick_file_standalone()async helper. Three unit tests added: anonymize-text path, empty-clipboard notification, and menu-command registry check. - UI-004: menu-bar resident mode with "Anonymize to Clipboard" quick action.
A "Stay in menu bar when window is closed" toggle (default off) persists in
~/Library/Application Support/pdf-md-anonymizer/prefs.json. When on, closing the main window hides it viaNSWindow.orderOut:and shows a status-bar icon with a menu: "Show Window" (restores the window and brings the app to the front), "Anonymize to Clipboard…" (opens a single-file picker, anonymizes in a background thread, writes result toNSPasteboard, fires anNSUserNotificationon success or failure), and — after a separator — "Quit". Toggling the switch off removes the status icon immediately. The status icon uses Toga's nativetoga.MenuStatusIconso menu command dispatch goes through Toga's own command machinery (ADR-0009) — no new runtime dependency. Four unit tests added. - UI-006: "Save replacement map" switch in the desktop UI (default off).
A
toga.Switchlabelled "Save replacement map" appears below the output folder label and above the file table. When checked, a.map.jsonis written alongside each anonymized output (into the chosen output folder or next to each input file when no output folder is set). When unchecked (the default), no map is written. The switch is disabled while a run is in progress and re-enabled when the run completes. Depends on CLI-002. - CLI-002: map export is now opt-in across CLI, batch layer, and UI.
anon doc.mdwrites onlydoc.anon.md; add--map <file>or--map <dir>to also write the replacement map. Batch mode similarly writes no maps unless--mapis given. The UI writes no maps unless the new "Save replacement map" switch is on.anon restoreis unchanged. Tests updated: two auto-map assertions replaced with explicit no-map assertions; two new tests verify opt-in behavior. - UI-005: drag-and-drop and Finder-clipboard paste to add input files.
Dropping one or more
.pdf/.mdfiles from Finder onto the app window appends them to the input list; non-.pdf/.md files are silently ignored; the status bar shows "Drop files here…" while a drag hovers over the window. Copying files in Finder (Cmd+C) and pressing Cmd+V in the app reads file paths fromNSFilenamesPboardTypevia NSPasteboard and appends them too (no-op when the clipboard holds no file URLs). Both paths reuse the new_add_inputshelper (extracted from the file-dialog handler), which deduplicates and filters by extension; the drag-and-drop overlay is a transparent rubicon-objcNSViewsubclass (_AnonymDropOverlay) withhitTest:returning nil so normal click/keyboard events pass through. Three unit tests added for_add_inputs(filtering, string coercion, no-refresh-when-empty). - UI-003: bundled
pdf_to_markdown-*.whlinanonymizer_ui/resources/wheelhouse/so the first-use PDF backend install no longer requires a system Python interpreter.pdf_backend.install()passes--find-links <wheelhouse>to in-process pip; pip installspdf-to-markdownfrom the local wheel and downloads all heavy deps (docling, torch, …) as binary wheels from PyPI — no source compilation, nosys.executablebuild-isolation subprocesses. Addedpdf_backend.installed_version()to read the installed version fromdist-info. Addedpdf_backend.update()to upgrade from the latest git revision (still requires a system Python because pip must build from source). The desktop UI now shows the current backend version and an «Update PDF backend» button; the button is gated behind a check for system Python and shows an informative dialog when none is found. - UI-002: first-use PDF backend install in the desktop
.app. When the user opens a PDF and nopdf2mdis found, the app asks once whether to install the docling-backedpdf-to-markdownbackend. The install runs off the UI thread; pip output streams live to the result view so the user can see progress. The backend lands in a Python venv at~/Library/Application Support/pdf-md-anonymizer/pdf-backend/; later launches detect it and skip the install. On network failure the install fails gracefully — a clear error is shown and Markdown files continue to work. The CLI[pdf]extra andpdf2md-on-PATHbehaviours are unchanged.convert.pynow checks the user backend venv as the second resolution step (after env-local, before PATH).anonymizer_ui.pdf_backendmodule added withbackend_dir,pdf2md_path,is_installed, andinstallhelpers. Realizes ADR-0008. - CORE-001: detection span model (
Span),Options,AnonResult, and right-to-left token replacement (replace.py,api.py). Tokens use a per-category 1-based counter; identical values reuse their token; pre-existing{{...}}placeholders never collide with generated tokens. - CORE-002: regex/dictionary detectors for EMAIL, PHONE, INN, SNILS, OGRN, OGRNIP, KPP, BIK, ACCOUNT, CARD (Luhn-validated), DOB, POSTAL_CODE, ADDRESS, PASSPORT, IP (v4/v6), URL, SECRET, and legal entities (ORG) — abbreviated and full forms. Overlap resolution: earlier match wins.
- CORE-003: replacement-map serialization (sorted-key UTF-8 JSON),
restore(), and theanon restoresubcommand. - PDF-001:
convert.pysubprocess wrapper over the externalpdf2mdCLI, with--pdf2md-cmdoverride and clear errors for missing/failed commands. - PDF-002: opt-in
[pdf]extra bundling thepdf-to-markdownconverter from git into the anonymizer's own environment ([tool.hatch.metadata] allow-direct-referencesenabled for the git dependency).convert.pynow prefers the env-localpdf2md(sys.prefix/bin) overPATH;--pdf2md-cmdstill overrides. (The desktop.appno longer bundles it — see ADR-0008.) - CLI-001: batch input — multiple files and glob wildcards, extension-preserving
output names, observable per-file results (
batch.process_files), and a cooperativeCancelToken. - NER-001: Natasha NER detection (
detect/ner.py) for PERSON, ORG, and LOC, all tokenized by default;--no-ner-org/--no-ner-locopt out. Regex matches keep priority over NER. Adds thenatasharuntime dependency. - NER-002: NER post-filter in
detect/ner.py— each span is clipped at its first newline and stripped of edge whitespace/markdown (so spans no longer leak into adjacent blocks), and spans whose value matches a non-sensitive stoplist (country names, document grifs, generic nouns, bare legal forms) are dropped. The stoplist is matched on word-by-word Natasha lemma keys, so any declension is caught (e.g. "Российской Федерации", a genitive bare "Общества с ограниченной ответственностью") — a morph tagger is added to the NER pipeline; no new dependency. - CORE-004: post-detection consistency sweep — after regex + NER detection,
each detected value is propagated to its remaining word-bounded occurrences
so partially-detected entities get fully tokenized (closes NER recall gaps).
On by default;
Options.consistency_sweep/ CLI--no-consistency-sweepdisable it. - DETECT-002: bare INN detection — a label-free 10- or 12-digit run is now
tokenized as
INNwhen it passes the_inn_okchecksum (from DETECT-001), so an INN in free text no longer leaks. Ordered after the label-anchored INN and the bare OGRN/OGRNIP rules; a 13/15-digit OGRN/OGRNIP run cannot match. - CORE-005: two regex detectors for PII leaking in electronic-signature blocks.
A
PERSONrule matches three-word ФИО anchored by a Russian patronymic suffix (case-insensitive, so it catches all-caps signatures Natasha misses and also complements NER on normal-case names); a capitalised word triple without a patronymic is not matched. ASECRETrule matches№-prefixed uppercase-hex certificate serials of 20+ characters (the high-entropy rule needs mixed case and misses them). No new dependencies. - NER-003: case normalization before NER.
detect/ner.pynow feeds Natasha a copy of the text where ALL-CAPS Cyrillic words are Title-cased (legal-form abbreviations kept uppercase), so ALL-CAPS ФИО and unquoted company names (ООО АСТРАЛ-СОФТ) are tagged. The rewrite is length-preserving, so span offsets index the original text and each value is sliced from the original casing —restorestays exact. No new dependencies. - PDF-003:
convert.pynow rejoins hyphenated words wrapped across a line bypdf2md(Санкт-\nПетербург→Санкт-Петербург) before the Markdown is returned, so one entity is no longer split into two tokens. Markdown list markers and em-dash bullets are left untouched. - NER-004: NER post-filter extended — a
PERSONspan with noPROPNtoken is dropped (common nouns likeПрограммистmislabeled as a name), and the stoplist gained the twelve Russian month names and résumé section headers. - NER-005: curated developer-tool stoplist in
detect/ner.py—ORG/PERSONspans matching common tools/technologies (Docker,Jenkins,Gitlab CI, …) are dropped, while real Latin-script company names stay detected. The stoplist also covers common engineering practice terms (Code Review,Mentoring,Production, …) and generic nouns (Банк,Вэб) that surface as false positives in résumé skill sections. - NER-006: structural NER false-positive suppression —
detect/ner.pynow detects skills/technologies/competencies sections by their Markdown header and dropsORG/LOCspans starting inside them, so résumé tool names are suppressed wholesale instead of one curated stoplist entry at a time. Real employer names (in the experience section) stay detected; the NER-004/005 stoplists remain as a precision backstop. No new dependencies. - NER-007: the NER POS filter now also covers
ORG/LOCspans — a span built purely from common-noun-class words (noisy-transcript false positives likeВокруг/Канцелярия) is dropped, while a real org/place keeps itsPROPNtoken and a Latin-script company (Deutsche Bank, taggedXby the morph tagger) is kept. Gated byOptions.ner_pos_filter/ CLI--no-ner-pos-filter(default on). A noisy-transcript document was added to the QA-001 corpus; the evaluation harness shows theВокругLOCfalse positive eliminated with the filter on and no recall regression on real entities. - UI-001: Toga desktop UI (
anonymizer_uipackage) over the in-process core — multi-file pick, options, off-thread batch run with live per-file status, overall progress, elapsed/ETA, and a Cancel control. Briefcase packaging config added under[tool.briefcase]. UI deps live in theuioptional group, not the core runtime.briefcase create/buildproduce a runnablepdf-md-anonymizer.app; a localwheelhouse/provides adocoptwheel because Briefcase installs bundle requirements with--only-binary.
- Briefcase config: the app table key was
pdf-md-anonymizerwhile the UI package isanonymizer_ui; Briefcase requires the app's package to be insourcesand named after the app key (hyphens → underscores), sobriefcase buildaborted with asourcesconfiguration error. The key is now[tool.briefcase.app.anonymizer-ui]→ packageanonymizer_ui. The bundled app stayspdf-md-anonymizer.app(fromformal_name). - DETECT-001: INN, OGRN, OGRNIP, and SNILS detectors now verify the official
control digit (
_inn_ok,_ogrn_ok,_ogrnip_ok,_snils_okattached as rule validators, likeCARD's Luhn check). A long non-identifier digit run (cost-estimate amounts, order codes) that fails the checksum is no longer mis-tokenized as OGRN/OGRNIP. - NER-004 POS filter: the proper-noun check now only inspects tokens inside
the trimmed span, so a heading like
Желательноеis no longer kept alive by a real name that the untrimmed span pulled in across a newline. - DOB detection now also fires on the
родился/родиласьanchor and on a date written with a Russian month name (16 января 1983), not only numeric formats next toдата рождения/д.р.— it stays label-anchored. - URL detection now catches scheme-less links: a
www.host and a bare domain on a known TLD (unicum.ru). This covers markdown link text ([www.site.com/x](…)), whose visible half carries nohttps://prefix. Dotted dev-tool names (Node.js,Socket.io) are not mistaken for URLs. - The C-family languages (
C,C++,C#) are now in the developer-tool stoplist — Natasha taggedC++asORG, and they sit in inline prose / aLanguages:list, not under a skills-section header, so NER-006's structural suppression does not reach them. - The résumé field label
Желательное("Желательное время в пути") is no longer tokenized asPERSON— Natasha's morph tagger tags itPROPN, so the POS filter cannot drop it; it is now in the NER stoplist. - Split city names from
pdf2md: it space-pads punctuation, soСанкт-Петербургarrives asСанкт - Петербург(and, inconsistently,Санкт -Петербург, where Natasha missed the second half entirely).convert.pynow normalizes every space-padded hyphen to" - ", anddetect/ner.pymerges two adjacent same-category NER spans bridged only by a spaced hyphen — so the city is oneLOCtoken andПетербургno longer leaks. A realРусский - Роднойseparator is unaffected (its halves are not the same entity category); genuine joined compounds (кто-то) are untouched.
- The desktop
.appno longer bundlespdf2md:pdf-to-markdownwas removed from the Briefcaserequires. Briefcase installs bundle deps cross-target with--only-binaryfor macOS 11.0 / cp312, and the docling stack (docling-parse,deepsearch-glm) has no wheels for that target, sobriefcase createfailed; bundling docling + torch would also bloat the distributable. The.appnow ships lean; PDF support is installed on first use instead (ADR-0008 / UI-002). The CLI[pdf]extra is unchanged. - QA-001: synthetic labelled corpus under
tests/corpus/(fictional data only — «Рога и Копыта», invented checksum-valid IDs, reserved-TLD emails) and a precision/recall evaluation harness (tests/evaluate_corpus.py, run withPYTHONPATH=src python -m tests.evaluate_corpus). The harness scoresdetect_allper category and is a dev tool, not shipped in the wheel. - DOC-001:
README.mdgained a "How it works" section listing the detection pipeline stages in order (input, conversion cleanup, regex, NER, consistency sweep, overlap resolution, replacement). Docs only. - Dictionary content expansion (data-only, no code change):
dict_country_namesexpanded from 3 entries to ~110 world countries (soИндия,Германия, … are no longer mistagged asLOC);dict_tech_termssubstantially enlarged — languages, markup/formats, build/CI, IDEs, testing, frameworks, data/ML, databases, infra, OS, protocols, API tools and amethodologiesgroup (Kanban,Scrum, …), with well-known standalone company names deliberately excluded so real employers stay detected;dict_org_forms/dict_org_abbreviationsgained ГУП/МУП/ФГУП/ФГБУ/ГБУ/МБУ/АНО/ТСЖ/ЖСК/СНТ/КФХ and their full spellings;dict_document_grifs,dict_generic_nouns,dict_resume_section_headersanddict_url_tldstopped up. - New
dict_colloquial_wordsstoplist — conversational filler words (interjections, discourse markers, slang:Угу,Точно,Короче,Топ, …) that Natasha NER mistags as PERSON/ORG on speech-to-text transcripts. Folded into_STOPLIST_TERMS; complements the NER-007 POS filter by also catching fillers the morph tagger labelsPROPN. Does not cover ASR garbage (non-existent mis-transcribed words). - DICT-001: every hand-curated exclusion dictionary and stoplist moved out of
the detection modules into a dedicated
detect/dictionaries/package — onedict_<name>.jsonresource per list (dict_org_forms,dict_org_abbreviations,dict_url_tlds,dict_country_names,dict_document_grifs,dict_generic_nouns,dict_tech_terms, and the document-type-nameddict_resume_section_headers,dict_resume_field_labels,dict_resume_skill_section_stems). Each JSON file carries adescriptionfield (purpose + how to extend) and anitemsarray (or groupedgroupsfor the tech terms); the package'sload()reads and caches them.regex_rules.pyandner.pyload the data; composition logic (_STOPLIST_TERMS, the URL-TLD alternation) and all regex patterns stay in code._MONTH_NAMESstays inline (a fixed universal list, not an open-ended exclusion dictionary). Pure refactor, no behaviour change. - The deprecated-
pkg_resourceswarning that pymorphy2 emits when the NER model loads is now suppressed, keeping the CLI's stderr output clean. - ADDRESS label detection now requires a colon after the
адресlabel (word-boundary anchored, optional qualifier words allowed), so plural nouns like "адреса средств коммуникации" are no longer mistaken for an address. The structuredг. Город, ул. …rule is unchanged. - DOB detection is now label-anchored only: a date is tokenized as
DOBonly next to "дата рождения" / "д.р.". Bare dates (e.g. contract dates) are no longer caught — the unconditional any-date rule was removed. The anchored rule now also accepts the ISOГГГГ-ММ-ДДformat. - A single input without
-onow writes<name>.anon.mdand<name>.anon.map.jsonnext to the input file instead of printing to stdout. stdin (-) still writes to stdout. - The CLI prints stage progress to stderr as work begins —
converting PDF…thendetecting…— with[i/total]numbering in batch mode.batch.process_files/anonymize_filegained anon_stagecallback.
- ADR-0001: token-based reversible replacement with a side map file.
- ADR-0002: PDF support via the external
pdf2mdsubprocess. - ADR-0003: detection ordering — regex before NER.
- ADR-0004: desktop UI toolkit — Toga + Briefcase.
- ADR-0005: bundle
pdf2mdas an opt-in[pdf]extra — revises ADR-0002. - ADR-0006: case normalization before NER — Title-case ALL-CAPS words so Natasha tags ALL-CAPS entities, instead of a regex rule per shape.
- ADR-0007: structural NER suppression for résumé skill sections — drop
ORG/LOCinside skills/technologies/competencies sections instead of enumerating tool names in a stoplist. - ADR-0008: the desktop
.appdoes not bundlepdf2md— the docling backend is installed on first PDF use into a user-writable dir — revises ADR-0005. - ADR-0010:
pdf2mdbatch driver — one subprocess per PDF group, JSON manifest stream over stdout, per-file Markdown in a temp directory.