diff --git a/docs/LOCALIZATION_AND_HELP_QUALITY.md b/docs/LOCALIZATION_AND_HELP_QUALITY.md index 5787526..c5b27c3 100644 --- a/docs/LOCALIZATION_AND_HELP_QUALITY.md +++ b/docs/LOCALIZATION_AND_HELP_QUALITY.md @@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated catalogs declared as `const de` / `const en`. Its strict mode requires both locales and reports `de` explicitly as the reference locale. +## Structured Documentation Localization + +`DocumentationTopic.translations` continues to own localized title, summary, +and body prose. Topics whose metadata contains rendered prose opt into the +separate `structured_translation_version="1"` contract and provide a complete +same-shape value for each translated metadata key in +`structured_translations`. Version 1 covers workflow prerequisites, steps, +outcome, result and verification; reference fields; limitations, constraints, +consequences and consequence classes; and the other rendered explanation +fields declared by Core. + +The registry rejects an unversioned translation, an unsupported contract +version, missing structured keys, changed object keys or list lengths, empty +translated strings, and changed non-text values. Stable field IDs, routes, +permission scopes, and other technical leaves therefore remain structurally +bound to the source metadata. The Docs module overlays only a validated locale +at response time and reports the selected structured locale separately from the +title/body locale. Missing structured translations fall back to source content +and remain visible in public coverage until the owning module adopts the +contract. + ## Help Resolution Every focusable field and action receives a stable derived F1 identity from the diff --git a/pyproject.toml b/pyproject.toml index 9531bc2..0c6dcb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.36" +version = "0.1.37" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 17cfc2a..21a30c4 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -289,6 +289,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"] CapabilityStability = Literal["experimental", "stable", "deprecated"] +DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1" +DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset( + { + "admin_explanation", + "consequence_classes", + "consequences", + "constraints", + "current_configuration", + "fields", + "limitations", + "operational_consequences", + "outcome", + "prerequisites", + "privacy_notes", + "purpose", + "result", + "steps", + "user_explanation", + "verification", + "when_used", + } +) + + @dataclass(frozen=True, slots=True) class DocumentationLink: label: str @@ -324,12 +348,142 @@ class DocumentationTopic: configuration_keys: tuple[str, ...] = () i18n_key: str | None = None translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict) + structured_translation_version: str | None = None + structured_translations: Mapping[str, Mapping[str, Any]] = field( + default_factory=dict + ) source_module_id: str | None = None version_min: str | None = None version_max_exclusive: str | None = None metadata: Mapping[str, Any] = field(default_factory=dict) +def localizable_documentation_metadata_keys( + topic: DocumentationTopic, +) -> tuple[str, ...]: + """Return structured metadata keys whose values are public prose.""" + + return tuple( + sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata)) + ) + + +def localized_documentation_metadata( + topic: DocumentationTopic, + locale: str, +) -> dict[str, Any]: + """Overlay one validated structured translation onto source metadata.""" + + localized = dict(topic.metadata) + translation = topic.structured_translations.get(locale) + if translation: + localized.update(translation) + return localized + + +def documentation_structured_translation_issues( + topic: DocumentationTopic, +) -> tuple[str, ...]: + """Validate the opt-in, versioned structured-documentation translation.""" + + version = topic.structured_translation_version + translations = topic.structured_translations + if version is None: + if translations: + return ( + "structured_translations require structured_translation_version", + ) + return () + if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION: + return ( + "unsupported structured_translation_version " + f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}", + ) + + localizable_keys = set(localizable_documentation_metadata_keys(topic)) + issues: list[str] = [] + for locale, translation in translations.items(): + if not locale.strip(): + issues.append("structured translation locale must not be empty") + continue + translated_keys = set(translation) + for key in sorted(translated_keys - localizable_keys): + issues.append( + f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}" + ) + for key in sorted(localizable_keys - translated_keys): + issues.append( + f"structured translation {locale!r} is missing metadata key {key!r}" + ) + for key in sorted(localizable_keys & translated_keys): + issues.extend( + _structured_translation_shape_issues( + topic.metadata[key], + translation[key], + path=f"{locale}.{key}", + ) + ) + return tuple(issues) + + +def _structured_translation_shape_issues( + source: object, + translated: object, + *, + path: str, +) -> tuple[str, ...]: + if isinstance(source, str): + if not isinstance(translated, str) or not translated.strip(): + return (f"structured translation {path} must be a non-empty string",) + return () + if isinstance(source, Mapping): + if not isinstance(translated, Mapping): + return (f"structured translation {path} must preserve object shape",) + issues: list[str] = [] + source_keys = {str(key) for key in source} + translated_keys = {str(key) for key in translated} + if source_keys != translated_keys: + issues.append( + f"structured translation {path} must preserve object keys" + ) + return tuple(issues) + for key, value in source.items(): + issues.extend( + _structured_translation_shape_issues( + value, + translated[key], + path=f"{path}.{key}", + ) + ) + return tuple(issues) + if isinstance(source, Sequence) and not isinstance( + source, (str, bytes, bytearray) + ): + if not isinstance(translated, Sequence) or isinstance( + translated, (str, bytes, bytearray) + ): + return (f"structured translation {path} must preserve list shape",) + if len(source) != len(translated): + return (f"structured translation {path} must preserve list length",) + issues: list[str] = [] + for index, (source_item, translated_item) in enumerate( + zip(source, translated, strict=True) + ): + issues.extend( + _structured_translation_shape_issues( + source_item, + translated_item, + path=f"{path}[{index}]", + ) + ) + return tuple(issues) + if translated != source: + return ( + f"structured translation {path} must preserve non-text value {source!r}", + ) + return () + + def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]: """Return fail-closed authoring issues for a user-facing workflow topic. diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index f8947fd..314b69a 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -26,6 +26,7 @@ from govoplan_core.core.modules import ( TenantSummaryBatchProvider, TenantSummaryProvider, user_workflow_scope_condition_issues, + documentation_structured_translation_issues, ) from govoplan_core.core.module_entitlements import ( TenantModuleEntitlementResolver, @@ -962,6 +963,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: raise RegistryError( f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}" ) + for issue in documentation_structured_translation_issues(topic): + raise RegistryError( + f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}" + ) _validate_documentation_extensions(manifest) _validate_architecture_declarations(manifest) _validate_workflow_definition_contributions(manifest) diff --git a/tests/test_documentation_topic_contract.py b/tests/test_documentation_topic_contract.py index 5e1bf8c..d7675fa 100644 --- a/tests/test_documentation_topic_contract.py +++ b/tests/test_documentation_topic_contract.py @@ -10,6 +10,7 @@ from govoplan_core.core.modules import ( DocumentationSourceDefinition, DocumentationTopic, ModuleManifest, + localized_documentation_metadata, user_workflow_scope_condition_issues, ) from govoplan_core.core.registry import PlatformRegistry, RegistryError @@ -83,6 +84,58 @@ class DocumentationTopicContractTests(unittest.TestCase): self.assertEqual(user_workflow_scope_condition_issues(user_reference), ()) registry_for(scoped, admin_workflow, user_reference).validate() + def test_versioned_structured_translation_preserves_metadata_shape(self) -> None: + topic = DocumentationTopic( + id="example.workflow.localized", + title="Run task", + summary="Run the task.", + metadata={ + "kind": "workflow", + "steps": ["Review", "Execute"], + "verification": "Confirm the result.", + }, + structured_translation_version="1", + structured_translations={ + "de": { + "steps": ["Prüfen", "Ausführen"], + "verification": "Das Ergebnis bestätigen.", + } + }, + ) + + registry_for(topic).validate() + self.assertEqual( + ["Prüfen", "Ausführen"], + localized_documentation_metadata(topic, "de")["steps"], + ) + self.assertEqual( + "workflow", localized_documentation_metadata(topic, "de")["kind"] + ) + + def test_structured_translation_requires_version_and_complete_shape(self) -> None: + missing_version = DocumentationTopic( + id="example.localized.missing-version", + title="Localized", + summary="Invalid contract.", + metadata={"limitations": ["One", "Two"]}, + structured_translations={"de": {"limitations": ["Eins", "Zwei"]}}, + ) + with self.assertRaisesRegex( + RegistryError, "require structured_translation_version" + ): + registry_for(missing_version).validate() + + incomplete_shape = DocumentationTopic( + id="example.localized.incomplete", + title="Localized", + summary="Invalid shape.", + metadata={"limitations": ["One", "Two"]}, + structured_translation_version="1", + structured_translations={"de": {"limitations": ["Eins"]}}, + ) + with self.assertRaisesRegex(RegistryError, "preserve list length"): + registry_for(incomplete_shape).validate() + def test_documentation_configuration_and_source_extensions_are_validated(self) -> None: resolver = lambda _context, keys: { # noqa: E731 key: DocumentationConfigurationDecision(key=key, state="enabled") diff --git a/webui/package-lock.json b/webui/package-lock.json index 1c305f7..ff4b706 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index eb066e1..09584a8 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "dependencies": { "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.20", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19", diff --git a/webui/package.json b/webui/package.json index 61da970..f4f7b3d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index 26fc809..76772d6 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.36", + "version": "0.1.37", "private": true, "type": "module", "main": "src/index.ts",