feat(docs): localize structured metadata and gate coverage

This commit is contained in:
2026-08-24 10:48:21 +02:00
parent d6560b343a
commit 92dc91885b
11 changed files with 372 additions and 21 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.18"
__version__ = "0.1.21"
+22 -2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
localized_documentation_metadata,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.registry import PlatformRegistry
@@ -1432,6 +1433,10 @@ def _documentation_topic_payload(
) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id
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)
payload = {
"id": topic.id,
@@ -1459,6 +1464,8 @@ def _documentation_topic_payload(
"i18n_key": topic.i18n_key or topic.id,
"locale": locale,
"translation_locale": translation_locale,
"structured_translation_locale": structured_translation_locale,
"structured_translation_version": topic.structured_translation_version,
"version": {
"resolved": resolved_version,
"minimum": topic.version_min,
@@ -1483,7 +1490,7 @@ def _documentation_topic_payload(
_documentation_configuration_payload(configuration[key])
for key in sorted(configuration)
],
"metadata": dict(topic.metadata),
"metadata": localized_metadata,
}
if documentation_type == "admin":
return payload
@@ -1511,6 +1518,12 @@ def _documentation_topic_payload(
"i18n_key": "",
"locale": locale,
"translation_locale": payload["translation_locale"],
"structured_translation_locale": payload[
"structured_translation_locale"
],
"structured_translation_version": payload[
"structured_translation_version"
],
"conditions": [],
"links": [
_documentation_link_payload(link)
@@ -1521,7 +1534,7 @@ def _documentation_topic_payload(
"unlocks": list(topic.unlocks),
"configuration_keys": [],
"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", {}
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, ...]:
normalized = _normalize_locale(locale)
base = normalized.split("-", 1)[0]
+33 -3
View File
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
manifest = ModuleManifest(
id="docs",
name="Docs",
version="0.1.20",
version="0.1.21",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -458,7 +458,8 @@ manifest = ModuleManifest(
"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 "
"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",
documentation_types=("admin", "user"),
@@ -472,7 +473,8 @@ manifest = ModuleManifest(
"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 "
"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."
),
}
},
@@ -573,6 +575,23 @@ manifest = ModuleManifest(
"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(
id="docs.pattern.contextual-help",
@@ -725,6 +744,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=(
+177 -8
View File
@@ -15,10 +15,12 @@ from typing import Any
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import (
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
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.",
"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.",
"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 | 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}
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 "-"
lines.append(
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.", ""))
return "\n".join(lines)
@@ -238,6 +242,7 @@ def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
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(
manifest: ModuleManifest,
topics: Sequence[Mapping[str, Any]],
@@ -270,6 +305,21 @@ def _module_coverage(
for topic in topics
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] = []
if not has_user:
missing.append("user documentation")
@@ -291,6 +341,15 @@ def _module_coverage(
),
"missing_german_topic_count": len(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),
"missing": missing,
}
@@ -314,6 +373,30 @@ def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"module_count": len(modules),
"topic_count": topic_count,
"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(
int(module.get("runtime_documentation_provider_count", 0)) > 0
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:
raw = topic.metadata.get("kind")
if isinstance(raw, str):
@@ -394,6 +550,7 @@ def _parser() -> argparse.ArgumentParser:
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--coverage-output", type=Path)
parser.add_argument("--workspace-root", type=Path)
parser.add_argument("--coverage-baseline", type=Path)
parser.add_argument(
"--check",
action="store_true",
@@ -408,10 +565,22 @@ def main(argv: Iterable[str] | None = None) -> int:
collect_manifest_sources(workspace_root=args.workspace_root)
)
if args.check:
if catalog_matches_sources(args.output, catalog):
return 0
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
return 1
failed = False
if not catalog_matches_sources(args.output, catalog):
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
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(
args.output,
catalog,