3 Commits
Author SHA1 Message Date
zemion 9055f3437f feat(docs): bind semantic publication to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:39 +02:00
zemion 92dc91885b feat(docs): localize structured metadata and gate coverage 2026-08-24 10:48:21 +02:00
zemion d6560b343a docs(docs): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 06:12:27 +02:00
12 changed files with 470 additions and 26 deletions
+11 -1
View File
@@ -82,7 +82,8 @@ module, or every module checkout in a workspace, with:
govoplan-docs-export-public \ govoplan-docs-export-public \
--workspace-root /mnt/DATA/git \ --workspace-root /mnt/DATA/git \
--output public/docs/v1/catalog.json \ --output public/docs/v1/catalog.json \
--coverage-output docs/DOCUMENTATION_COVERAGE.md --coverage-output docs/DOCUMENTATION_COVERAGE.md \
--coverage-baseline docs/DOCUMENTATION_COVERAGE_BASELINE.json
``` ```
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
@@ -90,6 +91,15 @@ Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
on permissions, policy, configuration, and live provider state; the export on permissions, policy, configuration, and live provider state; the export
records which modules have such additional documentation. records which modules have such additional documentation.
Static topics localize title, summary, and body through `translations`.
Rendered metadata such as steps, fields, limitations, consequences, and
verification uses Core's opt-in `structured_translation_version="1"` plus
`structured_translations` contract. The registry validates exact shape before
Docs overlays the requested locale. The public catalog reports structured
adoption separately. A reviewed coverage-baseline file sets monotonic minima
and maxima so publication CI also rejects localization or coverage regressions
after generated output is refreshed.
Pressing `F1` resolves the focused field or action first, then its containing Pressing `F1` resolves the focused field or action first, then its containing
dialog or section, current page, and owning module. The shell sends the focused dialog or section, current page, and owning module. The shell sends the focused
context together with `fallback_context` and `module`; Docs selects the first context together with `fallback_context` and `module`; Docs selects the first
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.18", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.19" version = "0.1.22"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.37",
"govoplan-access>=0.1.18", "govoplan-access>=0.1.18",
] ]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.18" __version__ = "0.1.22"
+22 -2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
localized_documentation_metadata,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
) )
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
@@ -1432,6 +1433,10 @@ def _documentation_topic_payload(
) -> dict[str, Any]: ) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale) translation_locale, translation = _translation_for_locale(topic, locale)
structured_translation_locale = _structured_translation_locale(topic, locale)
localized_metadata = localized_documentation_metadata(
topic, structured_translation_locale
)
kind = _documentation_topic_kind(topic) kind = _documentation_topic_kind(topic)
payload = { payload = {
"id": topic.id, "id": topic.id,
@@ -1459,6 +1464,8 @@ def _documentation_topic_payload(
"i18n_key": topic.i18n_key or topic.id, "i18n_key": topic.i18n_key or topic.id,
"locale": locale, "locale": locale,
"translation_locale": translation_locale, "translation_locale": translation_locale,
"structured_translation_locale": structured_translation_locale,
"structured_translation_version": topic.structured_translation_version,
"version": { "version": {
"resolved": resolved_version, "resolved": resolved_version,
"minimum": topic.version_min, "minimum": topic.version_min,
@@ -1483,7 +1490,7 @@ def _documentation_topic_payload(
_documentation_configuration_payload(configuration[key]) _documentation_configuration_payload(configuration[key])
for key in sorted(configuration) for key in sorted(configuration)
], ],
"metadata": dict(topic.metadata), "metadata": localized_metadata,
} }
if documentation_type == "admin": if documentation_type == "admin":
return payload return payload
@@ -1511,6 +1518,12 @@ def _documentation_topic_payload(
"i18n_key": "", "i18n_key": "",
"locale": locale, "locale": locale,
"translation_locale": payload["translation_locale"], "translation_locale": payload["translation_locale"],
"structured_translation_locale": payload[
"structured_translation_locale"
],
"structured_translation_version": payload[
"structured_translation_version"
],
"conditions": [], "conditions": [],
"links": [ "links": [
_documentation_link_payload(link) _documentation_link_payload(link)
@@ -1521,7 +1534,7 @@ def _documentation_topic_payload(
"unlocks": list(topic.unlocks), "unlocks": list(topic.unlocks),
"configuration_keys": [], "configuration_keys": [],
"configuration_states": [], "configuration_states": [],
"metadata": _user_topic_metadata(kind, topic.metadata), "metadata": _user_topic_metadata(kind, localized_metadata),
} }
@@ -1662,6 +1675,13 @@ def _translation_for_locale(topic: DocumentationTopic, locale: str) -> tuple[str
return "source", {} return "source", {}
def _structured_translation_locale(topic: DocumentationTopic, locale: str) -> str:
for candidate in _locale_candidates(locale):
if candidate in topic.structured_translations:
return candidate
return "source"
def _locale_candidates(locale: str) -> tuple[str, ...]: def _locale_candidates(locale: str) -> tuple[str, ...]:
normalized = _normalize_locale(locale) normalized = _normalize_locale(locale)
base = normalized.split("-", 1)[0] base = normalized.split("-", 1)[0]
+119 -7
View File
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.19", version="0.1.22",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -339,7 +339,26 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
metadata={"kind": "workflow"}, translations={
"de": {
"title": "Semantische Dokumentation des Mandanten",
"summary": "Erläutern, was konfigurierte Formulare, Felder, Workflows, Schritte und andere stabile Fachobjekte in diesem Mandanten bedeuten.",
"body": (
"Autorinnen und Autoren wählen ein berechtigtes Fachobjekt aus, das sein besitzendes Modul bereitstellt, "
"und verfassen sprachspezifische Hinweise als Klartext. Jeder Speichervorgang erzeugt eine unveränderliche Revision. "
"Die Mandantenrichtlinie legt direkte Veröffentlichung oder eine unabhängige Prüfung fest. Veröffentlichte Inhalte "
"unterliegen weiterhin der aktuellen Berechtigung für das Fachobjekt, der Zielgruppe und Klassifizierung der Dokumentation, "
"der Mandantentrennung und der Sprachauswahl. Geänderte, fehlende, abgelöste oder vorübergehend nicht verfügbare Fachobjekte "
"werden ausdrücklich gekennzeichnet; Direktlinks, Kontexthilfe, Suche, Zwischenspeicher und Mandantenexporte wenden dieselbe "
"Berechtigungsprüfung beim Lesen an. Stilllegung und Ablösung bewahren die Historie. Allgemeine öffentliche Dokumentationsexporte "
"enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["docs.semantic-documentation.publish"],
},
), ),
DocumentationTopic( DocumentationTopic(
id="docs.data-subject-requests", id="docs.data-subject-requests",
@@ -378,6 +397,20 @@ manifest = ModuleManifest(
), ),
), ),
related_modules=("access", "audit", "policy"), related_modules=("access", "audit", "policy"),
translations={
"de": {
"title": "Semantische Docs-Zuordnungen in einer Betroffenenanfrage prüfen",
"summary": "Minimierte Verweise auf Autorenschaft, Prüfung, Eigentümerschaft und fachliche Zuständigkeit exportieren, ohne unbeteiligte mandanteneigene Hinweise offenzulegen.",
"body": (
"Docs gleicht exakte Konto- und namensraumgebundene Verweise auf semantische Einträge innerhalb des aktiven Mandanten ab. "
"Die Projektion nennt Eintrag, Revision, Fachobjekt, Sprache, Lebenszyklusstatus und die übereinstimmenden Felder, schließt den "
"verfassten Inhalt jedoch aus. Zuordnungen veröffentlichter, abgelöster und stillgelegter Inhalte sind unveränderliche Nachweise "
"der Konfigurationssteuerung und werden mit Begründung aufbewahrt. Zuordnungen aus Entwürfen erfordern eine manuelle fachliche "
"Prüfung, damit Eigentümerschaft oder Zuständigkeit vor einer möglichen Anonymisierung neu zugewiesen werden können; Docs führt "
"keine automatische Löschung aus."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests", "route": "/admin?section=tenant-data-subject-requests",
@@ -397,8 +430,8 @@ manifest = ModuleManifest(
translations={ translations={
"de": { "de": {
"title": "Dokumentation dieses Systems", "title": "Dokumentation dieses Systems",
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die fuer diese Rolle sichtbar sind.", "summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die für diese Rolle sichtbar sind.",
"body": "Module koennen feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Tenant-Regeln, installierten Integrationen oder Betriebsoptionen abhaengen, kann ein Modul laufzeitbasierte Dokumentation registrieren.", "body": "Module können feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Mandantenregeln, installierten Integrationen oder Betriebsoptionen abhängen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
}, },
}, },
links=( links=(
@@ -428,7 +461,8 @@ manifest = ModuleManifest(
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, " "The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated " "projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. " "Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
"Publication CI should run the export check and reject a stale source digest." "Rendered steps, fields, limitations, consequences, and verification use Core's versioned same-shape structured-translation contract. "
"Publication CI runs both the source-digest check and a reviewed monotonic coverage baseline, so regenerating the catalog cannot conceal a regression."
), ),
layer="always", layer="always",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -442,7 +476,8 @@ manifest = ModuleManifest(
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, " "Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten " "projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. " "Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
"Die Veröffentlichungs-CI soll den Quelldigest prüfen und veraltete Exporte ablehnen." "Gerenderte Schritte, Felder, Einschränkungen, Folgen und Prüfhinweise verwenden den versionierten, formgleichen Vertrag für strukturierte Übersetzungen in Core. "
"Die Veröffentlichungs-CI prüft sowohl den Quelldigest als auch einen freigegebenen monotonen Abdeckungsstand, damit das Neuerzeugen des Katalogs keine Verschlechterung verdecken kann."
), ),
} }
}, },
@@ -472,7 +507,7 @@ manifest = ModuleManifest(
"de": { "de": {
"title": "Architektur der institutionellen Steuerung", "title": "Architektur der institutionellen Steuerung",
"summary": "GovOPlaN modelliert institutionelle Verantwortung, gesteuerte Arbeit, formale Ergebnisse, Nachweise und die Datenhoheit externer Systeme, ohne alle Begriffe in den Kern oder eine monolithische Anwendung zu ziehen.", "summary": "GovOPlaN modelliert institutionelle Verantwortung, gesteuerte Arbeit, formale Ergebnisse, Nachweise und die Datenhoheit externer Systeme, ohne alle Begriffe in den Kern oder eine monolithische Anwendung zu ziehen.",
"body": "Organisationen, Identitaeten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Vertraege und werden erst bei nachgewiesenem eigenstaendigem Lebenszyklus zu Modulen. Integrationen erklaeren technische Reife und Datenhoheit getrennt.", "body": "Organisationen, Identitäten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Verträge und werden erst bei nachgewiesenem eigenständigem Lebenszyklus zu Modulen. Integrationen erklären technische Reife und Datenhoheit getrennt.",
}, },
}, },
links=( links=(
@@ -508,6 +543,17 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator", "module_admin"), audience=("user", "tenant_admin", "operator", "module_admin"),
order=20, order=20,
i18n_key="docs.topic.pattern.field_help", i18n_key="docs.topic.pattern.field_help",
translations={
"de": {
"title": "Hinweis am Feld",
"summary": "Eine kleine Hilfemarkierung neben einer Beschriftung gibt lokalen Kontext, ohne dichte Formulare in Handbücher zu verwandeln.",
"body": (
"Verwenden Sie die Markierung für kurze Erläuterungen zu einem Feld, einer Option oder einem kompakten Begriff. "
"Verweisen Sie auf ein Ablauf- oder Referenzthema, wenn Schritte, API-Zuordnung, Richtlinienherkunft oder betriebliche "
"Einzelheiten benötigt werden."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Documentation experience concept", label="Documentation experience concept",
@@ -532,6 +578,23 @@ manifest = ModuleManifest(
"access.workflow.grant-user-access", "access.workflow.grant-user-access",
], ],
}, },
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Beschriftungen bleiben schnell erfassbar, während kurze Erläuterungen bei Bedarf verfügbar sind."
),
"when_used": (
"Formular- und Umschalterbeschriftungen, Zeilen mit wirksamen Werten und kompakte Verwaltungsbegriffe."
),
"user_explanation": (
"Öffnen Sie die Markierung, wenn eine Beschriftung unklar ist. Sie erläutert die lokale Auswahl in ein oder zwei Sätzen."
),
"admin_explanation": (
"Feldhilfe bleibt am Feld. Längere Verfahrens-, API- oder Richtlinienerläuterungen gehören in verknüpfte Ablauf- oder Referenzthemen."
),
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="docs.pattern.contextual-help", id="docs.pattern.contextual-help",
@@ -549,6 +612,18 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator", "module_admin"), audience=("user", "tenant_admin", "operator", "module_admin"),
order=21, order=21,
i18n_key="docs.topic.pattern.contextual_help", i18n_key="docs.topic.pattern.contextual_help",
translations={
"de": {
"title": "Kontextsensitive Hilfe",
"summary": "Mit F1 Hilfe zum aktuellen Seiten-, Feld-, Aktions- oder Dialogkontext öffnen.",
"body": (
"GovOPlaN löst Hilfe zuerst für das fokussierte Feld oder die fokussierte Aktion auf, danach für den Dialog oder Abschnitt, "
"die aktuelle Seite und schließlich das besitzende Modul. Ist eine genaue Dokumentation vorhanden, wird sie angezeigt; andernfalls "
"dient die Seiten- oder Moduldokumentation als Rückfall. Die Hilfe-Schaltfläche in der Titelleiste öffnet den aktuellen Seitenkontext. "
"Die Dokumentation bleibt nach Zielgruppe, Berechtigungen und konfigurierten Modulen des aktuellen Kontos gefiltert."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Contextual help contract", label="Contextual help contract",
@@ -584,6 +659,19 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator", "module_admin"), audience=("user", "tenant_admin", "operator", "module_admin"),
order=22, order=22,
i18n_key="docs.topic.reference.temporal_data_context", i18n_key="docs.topic.reference.temporal_data_context",
translations={
"de": {
"title": "Zeitlicher Datenkontext",
"summary": "Festlegen, ob Seiten aktuell gültige Datensätze, zu einem gewählten Zeitpunkt gültige Datensätze oder alle Gültigkeitszustände zeigen.",
"body": (
"Die Kalendersteuerung in der Titelleiste setzt die Gültigkeitszeit für beteiligte Module. Aktuell ist die neutrale "
"Voreinstellung. Zeitpunkt zeigt Datensätze, die zum gewählten Moment gültig sind; Alle umfasst historische und zukünftige "
"Gültigkeitszustände. Die Aufzeichnungszeit bleibt davon getrennt und beschreibt, wann die Plattform eine Tatsache erfahren "
"oder gespeichert hat. Berechtigungen werden stets gegenwärtig ausgewertet; eine Zeitauswahl stellt daher niemals frühere "
"Zugriffsrechte wieder her."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Temporal data read contract", label="Temporal data read contract",
@@ -617,6 +705,19 @@ manifest = ModuleManifest(
required_modules=("organizations", "identity", "idm", "access"), required_modules=("organizations", "identity", "idm", "access"),
), ),
), ),
translations={
"de": {
"title": "Abgrenzung von Organisationen, Identität, IDM und Zugriff",
"summary": "Organizations definiert Strukturen und Funktionen, Identity Personen und Konten, IDM die Zuordnung von Identitäten zu Funktionen und Access die daraus entstehenden Rollen und Rechte.",
"body": (
"Verwenden Sie Organizations für Einheitstypen, Strukturen, Beziehungen, Organisationseinheiten und Funktionsdefinitionen. "
"Identity verwaltet normalisierte Identitäten und Kontoverknüpfungen. IDM ordnet eine Identität oder ein Konto einer Funktion "
"in einer Organisationseinheit zu und bildet dabei auch Delegation oder Handeln für andere ab. Access überführt anerkannte "
"Funktionsmerkmale in Rollen und Berechtigungen. Diese Aufteilung trennt das Organisationsmodell vom Identitätslebenszyklus "
"und hält Autorisierungsentscheidungen ausdrücklich nachvollziehbar."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Organizations", href="/organizations", kind="runtime" label="Organizations", href="/organizations", kind="runtime"
@@ -646,6 +747,17 @@ manifest = ModuleManifest(
}, },
], ],
}, },
structured_translation_version="1",
structured_translations={
"de": {
"admin_explanation": (
"Auswirkungen von Funktionen auf Rollen gehören Access. Änderungen an IDM-Zuordnungen können unabhängig von Änderungen am Organisationsmodell gesteuert werden."
),
"user_explanation": (
"Eine Person kann eine Funktion innehaben, weil IDM ihre Identität mit der Organisationsfunktion verknüpft. Access entscheidet, welche Anwendungsberechtigungen diese Funktion gewährt."
),
}
},
), ),
), ),
documentation_sources=( documentation_sources=(
+175 -6
View File
@@ -15,10 +15,12 @@ from typing import Any
from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
DocumentationCondition, DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
ModuleManifest, ModuleManifest,
localizable_documentation_metadata_keys,
) )
@@ -102,12 +104,12 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.", "2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.", "3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.", "4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
"5. Add a versioned localization contract for structured metadata such as steps, fields, limitations, and verification; these values currently retain their manifest source language.", "5. Adopt the versioned structured-localization contract for steps, fields, limitations, consequences, and verification; the coverage column tracks this migration independently from title/body completeness.",
"", "",
"## Module gaps", "## Module gaps",
"", "",
"| Module | Topics | Missing German | Missing coverage |", "| Module | Topics | Missing German | Structured German | Missing coverage |",
"| --- | ---: | ---: | --- |", "| --- | ---: | ---: | ---: | --- |",
] ]
gaps_by_id = {str(item.get("module_id")): item for item in gaps} gaps_by_id = {str(item.get("module_id")): item for item in gaps}
for module in modules: for module in modules:
@@ -117,7 +119,9 @@ def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-" missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
lines.append( lines.append(
f"| `{module_id}` | {coverage.get('topic_count', 0)} | " f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
f"{coverage.get('missing_german_topic_count', 0)} | {missing} |" f"{coverage.get('missing_german_topic_count', 0)} | "
f"{coverage.get('german_structured_complete_topic_count', 0)}/"
f"{coverage.get('structured_localizable_topic_count', 0)} | {missing} |"
) )
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", "")) lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
return "\n".join(lines) return "\n".join(lines)
@@ -238,6 +242,7 @@ def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
if key != "kind" if key != "kind"
} }
), ),
**_structured_content_payload(topic),
} }
@@ -258,6 +263,36 @@ def _localized_topic(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
} }
def _localized_content(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
localizable_keys = localizable_documentation_metadata_keys(topic)
translation = topic.structured_translations.get(locale, {})
translated_fields = sorted(set(localizable_keys).intersection(translation))
complete = locale == "en" or not localizable_keys or (
topic.structured_translation_version
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
and len(translated_fields) == len(localizable_keys)
)
return {
"content": _public_value(dict(translation)),
"source_locale": locale if translated_fields else "en",
"translated_fields": translated_fields,
"complete": complete,
}
def _structured_content_payload(topic: DocumentationTopic) -> dict[str, Any]:
if not localizable_documentation_metadata_keys(topic):
return {}
return {
"structured_translation_version": topic.structured_translation_version,
"content_localizations": {
locale: _localized_content(topic, locale)
for locale in SUPPORTED_LOCALES
if locale != "en"
},
}
def _module_coverage( def _module_coverage(
manifest: ModuleManifest, manifest: ModuleManifest,
topics: Sequence[Mapping[str, Any]], topics: Sequence[Mapping[str, Any]],
@@ -270,6 +305,21 @@ def _module_coverage(
for topic in topics for topic in topics
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete") if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
] ]
structured_localizable = [
topic
for topic in manifest.documentation
if localizable_documentation_metadata_keys(topic)
]
german_structured_complete = [
topic
for topic in structured_localizable
if _localized_content(topic, "de")["complete"]
]
missing_german_structured = [
topic.id
for topic in structured_localizable
if not _localized_content(topic, "de")["complete"]
]
missing: list[str] = [] missing: list[str] = []
if not has_user: if not has_user:
missing.append("user documentation") missing.append("user documentation")
@@ -291,6 +341,15 @@ def _module_coverage(
), ),
"missing_german_topic_count": len(missing_german), "missing_german_topic_count": len(missing_german),
"missing_german_topic_ids": missing_german, "missing_german_topic_ids": missing_german,
"structured_localizable_topic_count": len(structured_localizable),
"structured_translation_contract_topic_count": sum(
topic.structured_translation_version
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
for topic in structured_localizable
),
"german_structured_complete_topic_count": len(german_structured_complete),
"missing_german_structured_topic_count": len(missing_german_structured),
"missing_german_structured_topic_ids": missing_german_structured,
"kinds": sorted(kinds), "kinds": sorted(kinds),
"missing": missing, "missing": missing,
} }
@@ -314,6 +373,30 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"module_count": len(modules), "module_count": len(modules),
"topic_count": topic_count, "topic_count": topic_count,
"german_complete_topic_count": topic_count - missing_german, "german_complete_topic_count": topic_count - missing_german,
"structured_localizable_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"structured_localizable_topic_count", 0
)
)
for module in modules
),
"structured_translation_contract_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"structured_translation_contract_topic_count", 0
)
)
for module in modules
),
"german_structured_complete_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"german_structured_complete_topic_count", 0
)
)
for module in modules
),
"runtime_provider_module_count": sum( "runtime_provider_module_count": sum(
int(module.get("runtime_documentation_provider_count", 0)) > 0 int(module.get("runtime_documentation_provider_count", 0)) > 0
for module in modules for module in modules
@@ -323,6 +406,79 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
} }
def coverage_baseline(catalog: Mapping[str, Any]) -> dict[str, Any]:
"""Build the monotonic public-documentation coverage baseline."""
summary = _mapping(catalog.get("summary"))
topic_count = int(summary.get("topic_count", 0))
german_complete = int(summary.get("german_complete_topic_count", 0))
structured_localizable = int(
summary.get("structured_localizable_topic_count", 0)
)
structured_complete = int(
summary.get("german_structured_complete_topic_count", 0)
)
return {
"schema_version": "1",
"minimum": {
"german_complete_topic_count": german_complete,
"structured_translation_contract_topic_count": int(
summary.get("structured_translation_contract_topic_count", 0)
),
"german_structured_complete_topic_count": structured_complete,
},
"maximum": {
"missing_german_topic_count": topic_count - german_complete,
"gap_module_count": int(summary.get("gap_module_count", 0)),
"missing_german_structured_topic_count": (
structured_localizable - structured_complete
),
},
}
def coverage_regression_issues(
catalog: Mapping[str, Any], baseline: Mapping[str, Any]
) -> tuple[str, ...]:
"""Return monotonic coverage failures against a reviewed baseline."""
if str(baseline.get("schema_version")) != "1":
return ("unsupported coverage baseline schema_version",)
summary = _mapping(catalog.get("summary"))
minimum = _mapping(baseline.get("minimum"))
maximum = _mapping(baseline.get("maximum"))
actual = {
"german_complete_topic_count": int(
summary.get("german_complete_topic_count", 0)
),
"structured_translation_contract_topic_count": int(
summary.get("structured_translation_contract_topic_count", 0)
),
"german_structured_complete_topic_count": int(
summary.get("german_structured_complete_topic_count", 0)
),
"missing_german_topic_count": int(summary.get("topic_count", 0))
- int(summary.get("german_complete_topic_count", 0)),
"gap_module_count": int(summary.get("gap_module_count", 0)),
"missing_german_structured_topic_count": int(
summary.get("structured_localizable_topic_count", 0)
)
- int(summary.get("german_structured_complete_topic_count", 0)),
}
issues: list[str] = []
for key, expected in minimum.items():
if key in actual and actual[key] < int(expected):
issues.append(
f"coverage {key} regressed: {actual[key]} is below {int(expected)}"
)
for key, expected in maximum.items():
if key in actual and actual[key] > int(expected):
issues.append(
f"coverage {key} regressed: {actual[key]} exceeds {int(expected)}"
)
return tuple(issues)
def _topic_kind(topic: DocumentationTopic) -> str: def _topic_kind(topic: DocumentationTopic) -> str:
raw = topic.metadata.get("kind") raw = topic.metadata.get("kind")
if isinstance(raw, str): if isinstance(raw, str):
@@ -394,6 +550,7 @@ def _parser() -> argparse.ArgumentParser:
parser.add_argument("--output", type=Path, required=True) parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--coverage-output", type=Path) parser.add_argument("--coverage-output", type=Path)
parser.add_argument("--workspace-root", type=Path) parser.add_argument("--workspace-root", type=Path)
parser.add_argument("--coverage-baseline", type=Path)
parser.add_argument( parser.add_argument(
"--check", "--check",
action="store_true", action="store_true",
@@ -408,10 +565,22 @@ def main(argv: Iterable[str] | None = None) -> int:
collect_manifest_sources(workspace_root=args.workspace_root) collect_manifest_sources(workspace_root=args.workspace_root)
) )
if args.check: if args.check:
if catalog_matches_sources(args.output, catalog): failed = False
return 0 if not catalog_matches_sources(args.output, catalog):
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr) print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
failed = True
if args.coverage_baseline is not None:
try:
baseline = json.loads(
args.coverage_baseline.read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as exc:
print(f"Invalid coverage baseline: {exc}", file=sys.stderr)
return 1 return 1
for issue in coverage_regression_issues(catalog, _mapping(baseline)):
print(f"Public documentation {issue}", file=sys.stderr)
failed = True
return int(failed)
write_public_catalog( write_public_catalog(
args.output, args.output,
catalog, catalog,
+44
View File
@@ -52,6 +52,50 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase): class DocsContextTests(unittest.TestCase):
def test_structured_topic_metadata_uses_requested_locale(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.reference.localized",
title="Reference",
summary="Reference summary.",
documentation_types=("admin",),
metadata={
"kind": "reference",
"limitations": ["English limitation."],
},
structured_translation_version="1",
structured_translations={
"de": {
"limitations": ["Deutsche Einschränkung."]
}
},
),
),
)
)
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="admin",
locale="de-DE",
)
topic = layers["configured"][0]
self.assertEqual("de", topic["structured_translation_locale"])
self.assertEqual("1", topic["structured_translation_version"])
self.assertEqual(
["Deutsche Einschränkung."], topic["metadata"]["limitations"]
)
def test_user_provider_state_omits_binding_details(self) -> None: def test_user_provider_state_omits_binding_details(self) -> None:
state = { state = {
"configured": True, "configured": True,
+89 -2
View File
@@ -6,16 +6,36 @@ import unittest
from pathlib import Path from pathlib import Path
from govoplan_core.core.modules import DocumentationTopic, ModuleManifest from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
from govoplan_docs.public_export import ( from govoplan_docs.public_export import (
ManifestSource, ManifestSource,
build_public_catalog, build_public_catalog,
catalog_matches_sources, catalog_matches_sources,
coverage_baseline,
coverage_regression_issues,
documentation_coverage_markdown, documentation_coverage_markdown,
write_public_catalog, write_public_catalog,
) )
class PublicDocumentationExportTests(unittest.TestCase): class PublicDocumentationExportTests(unittest.TestCase):
def test_docs_manifest_has_complete_german_public_coverage(self) -> None:
module = build_public_catalog(
(ManifestSource(get_docs_manifest()),)
)["modules"][0]
self.assertEqual(9, module["coverage"]["topic_count"])
self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"])
self.assertEqual(
2, module["coverage"]["german_structured_complete_topic_count"]
)
self.assertEqual(
[], module["coverage"]["missing_german_structured_topic_ids"]
)
self.assertEqual([], module["coverage"]["missing"])
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None: def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
manifest = ModuleManifest( manifest = ModuleManifest(
id="example", id="example",
@@ -28,7 +48,11 @@ class PublicDocumentationExportTests(unittest.TestCase):
summary="Perform the example workflow.", summary="Perform the example workflow.",
body="Open and finish it.", body="Open and finish it.",
documentation_types=("user", "admin"), documentation_types=("user", "admin"),
metadata={"kind": "workflow"}, metadata={
"kind": "workflow",
"steps": ["Review", "Execute"],
"verification": "Confirm completion.",
},
translations={ translations={
"de": { "de": {
"title": "Beispiel verwenden", "title": "Beispiel verwenden",
@@ -36,6 +60,13 @@ class PublicDocumentationExportTests(unittest.TestCase):
"body": "Öffnen und abschließen.", "body": "Öffnen und abschließen.",
} }
}, },
structured_translation_version="1",
structured_translations={
"de": {
"steps": ["Prüfen", "Ausführen"],
"verification": "Den Abschluss bestätigen.",
}
},
), ),
), ),
) )
@@ -48,7 +79,12 @@ class PublicDocumentationExportTests(unittest.TestCase):
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"]) self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
self.assertTrue(topic["localizations"]["de"]["complete"]) self.assertTrue(topic["localizations"]["de"]["complete"])
self.assertEqual("workflow", topic["kind"]) self.assertEqual("workflow", topic["kind"])
self.assertEqual({}, topic["content"]) self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
self.assertEqual(
["Prüfen", "Ausführen"],
topic["content_localizations"]["de"]["content"]["steps"],
)
self.assertTrue(topic["content_localizations"]["de"]["complete"])
self.assertIn( self.assertIn(
"field/consequence reference", "field/consequence reference",
catalog["modules"][0]["coverage"]["missing"], catalog["modules"][0]["coverage"]["missing"],
@@ -148,6 +184,57 @@ class PublicDocumentationExportTests(unittest.TestCase):
self.assertEqual("operator-workflow", topic["kind"]) self.assertEqual("operator-workflow", topic["kind"])
def test_coverage_baseline_rejects_text_and_structured_regressions(self) -> None:
healthy = build_public_catalog(
(
ManifestSource(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.reference",
title="Reference",
summary="Reference summary.",
body="Reference body.",
translations={
"de": {
"title": "Referenz",
"summary": "Referenzzusammenfassung.",
"body": "Referenzinhalt.",
}
},
metadata={
"kind": "reference",
"limitations": ["Source limitation."],
},
structured_translation_version="1",
structured_translations={
"de": {
"limitations": [
"Einschränkung der Quelle."
]
}
},
),
),
)
),
)
)
baseline = coverage_baseline(healthy)
self.assertEqual((), coverage_regression_issues(healthy, baseline))
regressed = json.loads(json.dumps(healthy))
regressed["summary"]["german_complete_topic_count"] = 0
regressed["summary"]["german_structured_complete_topic_count"] = 0
issues = coverage_regression_issues(regressed, baseline)
self.assertTrue(any("german_complete_topic_count" in issue for issue in issues))
self.assertTrue(
any("german_structured_complete_topic_count" in issue for issue in issues)
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.19", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+2
View File
@@ -166,6 +166,8 @@ export type DocsDocumentationTopic = {
i18n_key: string; i18n_key: string;
locale: string; locale: string;
translation_locale: string; translation_locale: string;
structured_translation_locale: string;
structured_translation_version?: string | null;
version: { version: {
resolved: string; resolved: string;
minimum?: string | null; minimum?: string | null;
@@ -235,7 +235,7 @@ export default function SemanticDocumentationPage({ settings }: { settings: ApiS
refreshable refreshable
reloadAction={{ onReload: () => void load(), loading }} reloadAction={{ onReload: () => void load(), loading }}
primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? ( primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? (
<Button onClick={() => void transition("publish")}> <Button helpContextId="docs.semantic-documentation.publish" helpModuleId="docs" onClick={() => void transition("publish")}>
<Check size={16} /> Publish <Check size={16} /> Publish
</Button> </Button>
) : null} ) : null}