Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:36 +02:00
parent 1ae23b4e9d
commit cbe434de02
18 changed files with 842 additions and 81 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.22"
__version__ = "0.1.23"
@@ -40,6 +40,7 @@ from govoplan_docs.backend.sources import (
)
from govoplan_docs.backend.semantic_service import (
list_semantic_entries,
prefetch_semantic_revisions,
select_locale_entries,
semantic_entry_payload,
)
@@ -830,6 +831,11 @@ def _classify_documentation(
) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
installed = {manifest.id for manifest in registry.manifests()}
visible_area_modules = frozenset(
str(item["module_id"])
for item in _route_items(registry.manifests(), principal)
if item["visible"]
) if documentation_type == "user" else frozenset(installed)
visible_runtime_paths = frozenset([
"/settings", # Authenticated shell route, not contributed by a module manifest.
*(
@@ -909,6 +915,7 @@ def _classify_documentation(
visible_runtime_paths=visible_runtime_paths,
configuration=configuration,
resolved_version=resolved_version,
visible_area_modules=visible_area_modules,
))
return layers
@@ -1011,6 +1018,7 @@ def _semantic_documentation_topics(
list_semantic_entries(session, principal),
locale=locale,
)
revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False)
topics: list[DocumentationTopic] = []
for entry in entries:
payload = semantic_entry_payload(
@@ -1020,6 +1028,7 @@ def _semantic_documentation_topics(
entry=entry,
editor=False,
requested_locale=locale,
revisions=revisions,
)
if payload is None:
continue
@@ -1430,6 +1439,7 @@ def _documentation_topic_payload(
visible_runtime_paths: frozenset[str],
configuration: Mapping[str, DocumentationConfigurationDecision],
resolved_version: str,
visible_area_modules: frozenset[str] = frozenset(),
) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale)
@@ -1484,6 +1494,16 @@ def _documentation_topic_payload(
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
"links": [_documentation_link_payload(link) for link in topic.links],
"related_modules": list(topic.related_modules),
"area_module_ids": sorted({
module_id,
*(
area for area in (
*topic.related_modules,
*_bounded_string_list(localized_metadata.get("areas"), maximum_items=32, maximum_length=255),
)
if area in visible_area_modules
),
}),
"unlocks": list(topic.unlocks),
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
"configuration_states": [
@@ -1497,6 +1517,7 @@ def _documentation_topic_payload(
return {
"id": payload["id"],
"source_module_id": payload["source_module_id"],
"area_module_ids": payload["area_module_ids"],
"kind": payload["kind"],
"anchor_id": payload["anchor_id"],
"title": payload["title"],
@@ -1555,6 +1576,9 @@ def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, An
value = _bounded_string(metadata.get(key), maximum=255)
if value:
projected[key] = value
tags = _bounded_string_list(metadata.get("tags"), maximum_items=32, maximum_length=80)
if tags:
projected["tags"] = tags
if kind == "reference" and isinstance(metadata.get("fields"), list):
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
if fields:
@@ -43,6 +43,7 @@ from govoplan_docs.backend.semantic_service import (
create_semantic_entry,
get_semantic_entry,
list_semantic_entries,
prefetch_semantic_revisions,
publication_policy,
publish_semantic_entry,
retire_semantic_entry,
@@ -167,6 +168,7 @@ def list_entries(
subject_kind=subject_kind,
)
selected = entries if editor else select_locale_entries(entries, locale=locale)
revisions = prefetch_semantic_revisions(session, principal, entries=selected, editor=editor)
items = [
item
for entry in selected
@@ -178,6 +180,7 @@ def list_entries(
entry=entry,
editor=editor,
requested_locale=locale,
revisions=revisions,
)
)
is not None
+49 -3
View File
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
manifest = ModuleManifest(
id="docs",
name="Docs",
version="0.1.22",
version="0.1.23",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -302,6 +302,46 @@ manifest = ModuleManifest(
),
),
documentation=(
DocumentationTopic(
id="docs.workflow.find-help",
title="Find help by area and keyword",
summary="Search visible help topics and use area and keyword tags to narrow the results.",
body=(
"Enter words from your question in Search help topics. Titles, summaries, topic text, area names, and contributed tags are searched together. "
"Areas and tags is a multi-selection dropdown: selected tags match any of those tags; Select all removes the restriction and Clear all selects no topics. "
"Topics by area includes all visible guidance associated with an area, including guidance contributed by another module. "
"A topic can appear in several branches; only the occurrence you select is highlighted, and expanding it does not expand its other occurrences. "
"Search results show each topic once. Choosing a result clears the filters and opens the topic. Search and tags only narrow the documentation already authorized for your role. "
"Administrators and module authors contribute public keywords in DocumentationTopic metadata.tags and optional module-area IDs in metadata.areas; the source module and authorized related_modules also supply area tags. "
"Use stable module IDs for areas and readable keywords for tags; do not put credentials, private configuration, or hidden capability names in public search tags. "
"User area facets omit related modules without a visible route; administrative documentation still requires its separate permission."
),
layer="always",
documentation_types=("admin", "user"),
order=9,
conditions=(DocumentationCondition(required_scopes=(DOCS_READ_SCOPE,)),),
translations={
"de": {
"title": "Hilfe nach Bereich und Stichwort finden",
"summary": "Sichtbare Hilfethemen durchsuchen und Ergebnisse mit Bereichen und Schlagwörtern eingrenzen.",
"body": (
"Geben Sie unter Hilfethemen suchen Wörter aus Ihrer Frage ein. Titel, Zusammenfassungen, Thementexte, Bereichsnamen und beigetragene Schlagwörter werden gemeinsam durchsucht. "
"Bereiche und Schlagwörter ist eine Auswahlliste mit Mehrfachauswahl: Ein ausgewähltes Schlagwort genügt für einen Treffer. Alle auswählen entfernt die Einschränkung, Auswahl aufheben wählt keine Themen. "
"Themen nach Bereich enthält sämtliche sichtbaren Hinweise eines Bereichs, auch Beiträge anderer Module. "
"Ein Thema kann in mehreren Zweigen erscheinen; nur die angeklickte Stelle wird hervorgehoben, und das Aufklappen öffnet nicht zugleich die anderen Vorkommen. "
"Suchergebnisse zeigen jedes Thema einmal. Die Auswahl eines Ergebnisses setzt die Filter zurück und öffnet das Thema. Suche und Schlagwörter grenzen ausschließlich die bereits für Ihre Rolle freigegebene Dokumentation ein. "
"Administratoren und Modulautoren hinterlegen öffentliche Stichwörter in DocumentationTopic metadata.tags und optionale Modul-Bereichskennungen in metadata.areas; das Quellmodul und berechtigte related_modules liefern ebenfalls Bereichsschlagwörter. "
"Verwenden Sie stabile Modulkennungen für Bereiche und lesbare Stichwörter für Schlagwörter. Zugangsdaten, private Konfiguration und Namen verborgener Fähigkeiten gehören nicht in öffentliche Suchschlagwörter. "
"Bereichsfilter der Benutzerdokumentation zeigen keine zugeordneten Module ohne sichtbare Route. Administrative Dokumentation benötigt weiterhin ihre gesonderte Berechtigung."
),
},
},
metadata={
"kind": "workflow",
"tags": ["Help", "Hilfe", "Search", "Suche", "Tags", "Schlagwörter"],
"help_contexts": ["docs.help-center.search"],
},
),
DocumentationTopic(
id="docs.semantic-documentation",
title="Tenant semantic documentation",
@@ -311,7 +351,8 @@ manifest = ModuleManifest(
"Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. "
"Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. "
"Changed, missing, superseded, or temporarily unavailable subjects are shown explicitly; direct links, contextual help, search, caches, and tenant exports apply the same read-time authorization. "
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export."
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export. "
"Collection, configured-context, and search-authorization reads batch the required revisions in request-local groups of at most 400 identifiers instead of fetching revisions once per entry. Read-only views load published content, not pending draft bodies. Each revision must belong to the same tenant and entry, and a published pointer must reference a published revision; inconsistent references fail closed. Audience denial avoids unnecessary subject-provider work, while allowed results still require the owning subject's current authorization. No cross-request permission cache is introduced; complete tenant exports and full history remain separately authorized operations."
),
layer="always",
documentation_types=("admin", "user"),
@@ -351,7 +392,12 @@ manifest = ModuleManifest(
"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."
"enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit. "
"Listen, Konfigurationskontext und Suchberechtigungsprüfung laden benötigte Revisionen anfragebezogen in Gruppen von höchstens 400 Kennungen statt einzeln je Eintrag. "
"Nur lesbare Ansichten laden veröffentlichte Inhalte und keine offenen Entwurfstexte. Jede Revision muss zum selben Mandanten und Eintrag gehören; "
"ein Veröffentlichungsverweis muss auf eine veröffentlichte Revision zeigen. Widersprüchliche Verweise werden abgewiesen. "
"Bei einer nicht berechtigten Zielgruppe entfällt unnötige Arbeit des Fachobjekt-Providers; zulässige Ergebnisse erfordern weiterhin dessen aktuelle Berechtigungsprüfung. "
"Es entsteht kein anfrageübergreifender Berechtigungszwischenspeicher. Vollständiger Mandantenexport und Historie bleiben getrennt berechtigte Vorgänge."
),
}
},
+47 -14
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
@@ -23,7 +23,11 @@ from govoplan_docs.backend.db.models import (
SemanticDocumentationEntry,
SemanticDocumentationRevision,
)
from govoplan_docs.backend.semantic_service import semantic_entry_payload
from govoplan_docs.backend.semantic_service import (
prefetch_semantic_revisions,
published_revision,
semantic_entry_payload,
)
PROVIDER_ID = "docs.semantic_documentation"
@@ -58,8 +62,12 @@ class SemanticDocumentationSearchSource:
select(SemanticDocumentationEntry, SemanticDocumentationRevision)
.join(
SemanticDocumentationRevision,
SemanticDocumentationRevision.id
== SemanticDocumentationEntry.published_revision_id,
and_(
SemanticDocumentationRevision.id == SemanticDocumentationEntry.published_revision_id,
SemanticDocumentationRevision.entry_id == SemanticDocumentationEntry.id,
SemanticDocumentationRevision.tenant_id == SemanticDocumentationEntry.tenant_id,
SemanticDocumentationRevision.lifecycle_state == "published",
),
)
.where(
SemanticDocumentationEntry.tenant_id == request.tenant_id,
@@ -105,15 +113,32 @@ class SemanticDocumentationSearchSource:
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
return decisions
db = _session(session)
for item in requests:
reference = item.reference
valid_requests = [
item
for item in requests
if (
reference.tenant_id != principal.tenant_id
or reference.module_id != "docs"
or reference.resource_type != RESOURCE_TYPE
):
continue
entry = db.get(SemanticDocumentationEntry, reference.resource_id)
item.reference.tenant_id == principal.tenant_id
and item.reference.module_id == "docs"
and item.reference.resource_type == RESOURCE_TYPE
)
]
ids = sorted({item.reference.resource_id for item in valid_requests})
entries: dict[str, SemanticDocumentationEntry] = {}
for offset in range(0, len(ids), 400):
rows = db.scalars(
select(SemanticDocumentationEntry).where(
SemanticDocumentationEntry.id.in_(ids[offset:offset + 400]),
SemanticDocumentationEntry.tenant_id == principal.tenant_id,
SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")),
)
)
entries.update((entry.id, entry) for entry in rows)
revisions = prefetch_semantic_revisions(
db, principal, entries=tuple(entries.values()), editor=False,
)
for item in valid_requests:
reference = item.reference
entry = entries.get(reference.resource_id)
if (
entry is None
or entry.tenant_id != principal.tenant_id
@@ -127,6 +152,7 @@ class SemanticDocumentationSearchSource:
principal,
entry=entry,
editor=False,
revisions=revisions,
)
decisions[reference.key] = bool(
payload
@@ -153,8 +179,12 @@ class SemanticDocumentationSearchSource:
db = _session(session)
entry = db.get(SemanticDocumentationEntry, event.resource.id)
revision = (
db.get(SemanticDocumentationRevision, entry.published_revision_id)
if entry is not None and entry.published_revision_id
published_revision(db, entry)
if (
entry is not None
and entry.tenant_id == event.tenant.id
and entry.lifecycle_state in {"draft", "published"}
)
else None
)
visible = bool(
@@ -162,6 +192,9 @@ class SemanticDocumentationSearchSource:
and entry.tenant_id == event.tenant.id
and entry.lifecycle_state in {"draft", "published"}
and revision is not None
and revision.entry_id == entry.id
and revision.tenant_id == entry.tenant_id
and revision.lifecycle_state == "published"
)
cursor = event.event_id
document = _document(entry, revision, change_cursor=cursor) if visible else None
+73 -16
View File
@@ -396,13 +396,21 @@ def semantic_entry_history(
def current_revision(
session: Session,
entry: SemanticDocumentationEntry,
*,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> SemanticDocumentationRevision:
revision = (
session.get(SemanticDocumentationRevision, entry.current_revision_id)
if entry.current_revision_id
else None
)
if revision is None or revision.entry_id != entry.id:
revision = None
if entry.current_revision_id:
revision = (
revisions.get(entry.current_revision_id)
if revisions is not None
else session.get(SemanticDocumentationRevision, entry.current_revision_id)
)
if (
revision is None
or revision.entry_id != entry.id
or revision.tenant_id != entry.tenant_id
):
raise SemanticDocumentationError(
"Semantic documentation current revision is unavailable."
)
@@ -412,14 +420,60 @@ def current_revision(
def published_revision(
session: Session,
entry: SemanticDocumentationEntry,
*,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> SemanticDocumentationRevision | None:
if not entry.published_revision_id:
return None
revision = session.get(
SemanticDocumentationRevision,
entry.published_revision_id,
revision = (
revisions.get(entry.published_revision_id)
if revisions is not None
else session.get(SemanticDocumentationRevision, entry.published_revision_id)
)
return revision if revision is not None and revision.entry_id == entry.id else None
if (
revision is None
or revision.entry_id != entry.id
or revision.tenant_id != entry.tenant_id
or revision.lifecycle_state != "published"
):
return None
return revision
def prefetch_semantic_revisions(
session: Session,
principal: object,
*,
entries: Sequence[SemanticDocumentationEntry],
editor: bool,
) -> dict[str, SemanticDocumentationRevision]:
"""Load only the revisions this request may project, in bounded SQL batches.
This is request-local data loading, never an authorization cache. Payload
projection still validates each entry/revision pair and its owner subject.
Readers have no reason to load the bodies of pending unpublished drafts.
"""
tenant_id = _principal_tenant_id(principal)
revision_ids = sorted({
revision_id
for entry in entries
if entry.tenant_id == tenant_id
for revision_id in (
entry.published_revision_id,
entry.current_revision_id if editor else None,
)
if revision_id
})
revisions: dict[str, SemanticDocumentationRevision] = {}
for offset in range(0, len(revision_ids), 400):
rows = session.query(SemanticDocumentationRevision).filter(
SemanticDocumentationRevision.tenant_id == tenant_id,
SemanticDocumentationRevision.id.in_(revision_ids[offset:offset + 400]),
)
if not editor:
rows = rows.filter(SemanticDocumentationRevision.lifecycle_state == "published")
revisions.update((revision.id, revision) for revision in rows)
return revisions
def entry_subject_reference(
@@ -487,12 +541,16 @@ def semantic_entry_payload(
entry: SemanticDocumentationEntry,
editor: bool,
requested_locale: str | None = None,
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
) -> dict[str, object] | None:
current = current_revision(session, entry)
published = published_revision(session, entry)
selected = current if editor else published
if entry.tenant_id != _principal_tenant_id(principal):
return None
published = published_revision(session, entry, revisions=revisions)
selected = current_revision(session, entry, revisions=revisions) if editor else published
if selected is None:
return None
if not content_visible_to_principal(selected.content, principal):
return None
resolution = resolve_entry_subject(
session,
registry,
@@ -505,8 +563,6 @@ def semantic_entry_payload(
unavailable = resolution.availability == "temporarily_unavailable"
if unavailable and not editor:
return None
if not content_visible_to_principal(selected.content, principal):
return None
subject = resolution.subject
required_scopes = subject.required_scopes if subject is not None else ()
if any(not _principal_has(principal, scope) for scope in required_scopes):
@@ -523,7 +579,7 @@ def semantic_entry_payload(
"lifecycle_state": entry.lifecycle_state,
"effective_state": selected.lifecycle_state,
"pending_draft": bool(
published is not None and current.id != published.id
published is not None and entry.current_revision_id != published.id
),
"current_revision": entry.current_revision,
"selected_revision": selected.revision,
@@ -965,6 +1021,7 @@ __all__ = [
"entry_subject_reference",
"get_semantic_entry",
"list_semantic_entries",
"prefetch_semantic_revisions",
"publication_policy",
"publish_semantic_entry",
"published_revision",