diff --git a/scripts/generate_spreadsheet_programme_workflow_seed.py b/scripts/generate_spreadsheet_programme_workflow_seed.py index 3bfcfe3b3..2ef7e81bf 100644 --- a/scripts/generate_spreadsheet_programme_workflow_seed.py +++ b/scripts/generate_spreadsheet_programme_workflow_seed.py @@ -37,6 +37,18 @@ AUTHORITY_FINGERPRINT_PLACEHOLDER = ( "__SPREADSHEET_PROGRAMME_PROCESSING_AUTHORITY_FINGERPRINT__" ) +REVIEWED_LEGACY_AUTHORITY_PAYLOAD_SHA256_BY_SEED_VERSION = { + ITEM: { + "20": [ + "57fc306fbaeaba2fd5cee655feb69c3cc4f1dbc3f84906781eb10ce23a8d3d07" + ] + }, + MAIN: { + "20": [ + "c9652b254172cf5c751fc4d98489610c6012f2ee8d4a50cda6b5b12a13507ed4" + ] + }, +} def support_concepts() -> list[dict[str, object]]: @@ -1594,9 +1606,12 @@ def build_bundle() -> dict[str, object]: return { "family_id": "spreadsheet_programme_representation_workflow_seed_bundle", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "20", + "seed_version": "21", "source_tag": "JVNAUTOSCI-2592", "managed_by": "spreadsheet_programme_workflow_vontology_service", + "known_legacy_authority_payload_sha256_by_seed_version": ( + REVIEWED_LEGACY_AUTHORITY_PAYLOAD_SHA256_BY_SEED_VERSION + ), "processing_authority_fingerprint": authority_fingerprint, "processing_authority_dependencies": authority_dependencies, "supported_action_ids": [ diff --git a/src/backend/services/kr_materialisation_guard_service.py b/src/backend/services/kr_materialisation_guard_service.py index 7ea5e9e1b..2a898a959 100644 --- a/src/backend/services/kr_materialisation_guard_service.py +++ b/src/backend/services/kr_materialisation_guard_service.py @@ -9,6 +9,7 @@ from collections import Counter from collections.abc import Mapping, MutableMapping, Sequence +import re from typing import Any @@ -17,6 +18,9 @@ _MAX_GUARD_CONCEPT_SLOTS = 256 _MAX_GUARD_RELATIONSHIP_RULES = 512 _MAX_DESCRIPTION_CHARS = 100_000 +_MAX_REQUIRED_DESCRIPTION_FRAGMENTS = 512 +_MAX_REQUIRED_DESCRIPTION_FRAGMENT_CHARS = 20_000 +_MAX_REQUIRED_DESCRIPTION_FRAGMENT_TOTAL_CHARS = 1_000_000 def _text(value: Any) -> str: @@ -81,6 +85,8 @@ def _normalise_contract( return None, "kr_materialisation_guard_contract_bounds_invalid" slots: dict[str, dict[str, Any]] = {} + description_fragment_owner_by_value: dict[str, str] = {} + description_fragment_total_chars = 0 for raw_slot in raw_slots: if not isinstance(raw_slot, Mapping): return None, "kr_materialisation_guard_concept_slot_invalid" @@ -98,6 +104,47 @@ def _normalise_contract( for item in _sequence(raw_slot.get("allowed_existing_concept_ids")) if _text(item) } + raw_required_description_fragments = raw_slot.get( + "required_description_fragments" + ) + if ( + raw_required_description_fragments is not None + and ( + not isinstance(raw_required_description_fragments, Sequence) + or isinstance( + raw_required_description_fragments, + (str, bytes, bytearray), + ) + ) + ): + return None, "kr_materialisation_guard_concept_slot_invalid" + required_description_fragments: list[str] = [] + seen_description_fragments: set[str] = set() + for raw_fragment in _sequence(raw_required_description_fragments): + fragment = _text(raw_fragment) if isinstance(raw_fragment, str) else "" + if ( + not fragment + or len(fragment) > _MAX_REQUIRED_DESCRIPTION_FRAGMENT_CHARS + or fragment in seen_description_fragments + or fragment in description_fragment_owner_by_value + or any( + fragment in existing_fragment + or existing_fragment in fragment + for existing_fragment in description_fragment_owner_by_value + ) + ): + return None, "kr_materialisation_guard_concept_slot_invalid" + seen_description_fragments.add(fragment) + required_description_fragments.append(fragment) + description_fragment_owner_by_value[fragment] = key + description_fragment_total_chars += len(fragment) + if ( + len(description_fragment_owner_by_value) + > _MAX_REQUIRED_DESCRIPTION_FRAGMENTS + or description_fragment_total_chars + > _MAX_REQUIRED_DESCRIPTION_FRAGMENT_TOTAL_CHARS + ): + return None, "kr_materialisation_guard_contract_bounds_invalid" if ( not key or key in slots @@ -115,6 +162,9 @@ def _normalise_contract( "allowed_decisions": allowed_decisions, "allowed_existing_concept_ids": allowed_existing_ids, "allow_unreferenced": bool(raw_slot.get("allow_unreferenced")), + "required_description_fragments": tuple( + required_description_fragments + ), } fixed_ids = { @@ -216,6 +266,9 @@ def _normalise_contract( for item in _sequence(guard_contract.get("require_fixed_ids_referenced")) if _text(item) }, + "description_fragment_owner_by_value": ( + description_fragment_owner_by_value + ), }, None @@ -241,6 +294,7 @@ def _validate_concept_specs( return None, "kr_materialisation_guard_concept_count_rejected" specs: dict[str, Mapping[str, Any]] = {} + description_by_key: dict[str, str] = {} for raw_spec in raw_specs: if not isinstance(raw_spec, Mapping): return None, "kr_materialisation_guard_concept_spec_invalid" @@ -294,9 +348,36 @@ def _validate_concept_specs( ): return None, "kr_materialisation_guard_reuse_payload_rejected" specs[key] = raw_spec + description_by_key[key] = description if bool(contract["require_all_slots"]) and set(specs) != set(slots): return None, "kr_materialisation_guard_concept_slots_incomplete" + fragment_owner_by_value = contract.get("description_fragment_owner_by_value") + if isinstance(fragment_owner_by_value, Mapping) and fragment_owner_by_value: + ordered_fragments = sorted( + ( + str(fragment) + for fragment in fragment_owner_by_value + if isinstance(fragment, str) and fragment + ), + key=lambda fragment: (-len(fragment), fragment), + ) + fragment_pattern = re.compile( + "(?=(" + + "|".join(re.escape(fragment) for fragment in ordered_fragments) + + "))" + ) + observed_counts: Counter[str] = Counter() + for key, description in description_by_key.items(): + for match in fragment_pattern.finditer(description): + fragment = match.group(1) + if fragment_owner_by_value.get(fragment) != key: + return None, ( + "kr_materialisation_guard_description_fragment_rejected" + ) + observed_counts[fragment] += 1 + if any(observed_counts[fragment] != 1 for fragment in ordered_fragments): + return None, "kr_materialisation_guard_description_fragment_rejected" return specs, None diff --git a/src/backend/services/kr_materialisation_workflow_vontology_service.py b/src/backend/services/kr_materialisation_workflow_vontology_service.py index e7b42651a..23b804a52 100644 --- a/src/backend/services/kr_materialisation_workflow_vontology_service.py +++ b/src/backend/services/kr_materialisation_workflow_vontology_service.py @@ -8,17 +8,18 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from typing import Any, Mapping, Sequence from . import concept_service -from .text_value_service import upsert_singleton_text_relation +from .text_value_service import get_texts_for_concept, upsert_singleton_text_relation from .workflow_prompt_authority_service import ( + DEFAULT_PROMPT_CONTENT_PREDICATES, DEFAULT_PROMPT_TYPE_ID, WorkflowPromptConceptSpec, ensure_prompt_concept_support, - prompt_concept_has_content, ) from .workflow_repo_seed_bootstrap import bootstrap_repo_seed_workflow_bundle from ..workflows import workflow_concept_authority_service as authority_service @@ -76,6 +77,20 @@ / "prompt_kr_relationship_endpoint_resolution_seed.md" ), } +_REVIEWED_LEGACY_PROMPT_CONTENT_SHA256_BY_TARGET_SEED_VERSION = { + "8": { + PROMPT_KR_DESIGN_MATERIALISATION_PLAN_ID: { + "7": ( + "cfadd26376e176bbd87bffce74cab180edcd12b03d1e10ca9ba2bfb7501ed8a2", + ) + }, + PROMPT_KR_RELATIONSHIP_ENDPOINT_RESOLUTION_ID: { + "7": ( + "1600a90774e9a39d13d88809e581c631ac99f1e62b5dc16d2743027b692db6e5", + ) + } + } +} def _coerce_relationship_values(raw_value: Any) -> tuple[str, ...]: @@ -125,6 +140,60 @@ def _load_prompt_seed_text(asset_path: Path, *, error_code: str) -> str: return prompt_text +def _load_repo_seed_version() -> str: + payload = json.loads(_REPO_SEED_ASSET_PATH.read_text(encoding="utf-8")) + if not isinstance(payload, Mapping): + raise ValueError("kr_materialisation_seed_bundle_invalid") + seed_version = str(payload.get("seed_version") or "").strip() + if not seed_version: + raise ValueError("kr_materialisation_seed_version_missing") + return seed_version + + +def _prompt_content_sha256(text: str) -> str: + return hashlib.sha256(str(text).strip().encode("utf-8")).hexdigest() + + +def _load_prompt_content_values(prompt_id: str) -> tuple[str, ...]: + rows = get_texts_for_concept( + subject_concept_id=prompt_id, + limit=16, + ) + values: set[str] = set() + for row in rows: + if not isinstance(row, Mapping): + continue + predicate = str(row.get("predicate") or "").strip() + if predicate not in DEFAULT_PROMPT_CONTENT_PREDICATES: + continue + lang = str(row.get("lang") or "en-NZ").strip() or "en-NZ" + if lang != "en-NZ": + continue + text = str(row.get("text") or "").strip() + if text: + values.add(text) + return tuple(sorted(values)) + + +def _reviewed_prompt_source_seed_version( + *, + target_seed_version: str, + prompt_id: str, + live_content_sha256: str, +) -> str | None: + target_migrations = ( + _REVIEWED_LEGACY_PROMPT_CONTENT_SHA256_BY_TARGET_SEED_VERSION.get( + target_seed_version + ) + or {} + ) + source_versions = target_migrations.get(prompt_id) or {} + for source_seed_version, digests in source_versions.items(): + if live_content_sha256 in digests: + return source_seed_version + return None + + def _ensure_kr_materialisation_prompt_support( *, force_prompt_seed: bool = False, @@ -155,37 +224,127 @@ def _ensure_kr_materialisation_prompt_support( provenance_source=_MANAGED_BY, ) - seeded_prompt_ids: list[str] = [] - for prompt_id, asset_path in _PROMPT_SEED_ASSET_PATHS.items(): - if force_prompt_seed or not prompt_concept_has_content(prompt_id): - upsert_singleton_text_relation( - subject_concept_id=prompt_id, - predicate="hasContent", - text=_load_prompt_seed_text( - asset_path, - error_code=f"kr_materialisation_prompt_seed_missing:{prompt_id}", - ), - lang="en-NZ", - context={"jira": _SOURCE_TAG, "source": _MANAGED_BY}, - garbage_collect=True, - ) - seeded_prompt_ids.append(prompt_id) - + target_seed_version = _load_repo_seed_version() + desired_content_by_prompt_id = { + prompt_id: _load_prompt_seed_text( + asset_path, + error_code=f"kr_materialisation_prompt_seed_missing:{prompt_id}", + ) + for prompt_id, asset_path in _PROMPT_SEED_ASSET_PATHS.items() + } report = dict(report) errors_by_target = dict(report.get("errors_by_target") or {}) - missing_content_prompt_ids = list(report.get("missing_content_prompt_ids") or []) - validated_prompt_ids = list(report.get("validated_prompt_ids") or []) - for prompt_id in _PROMPT_SEED_ASSET_PATHS: - if not prompt_concept_has_content(prompt_id): + planned_refresh_reason_by_prompt_id: dict[str, str] = {} + migration_source_seed_version_by_prompt_id: dict[str, str] = {} + preserved_drift_prompt_ids: list[str] = [] + observed_content_sha256_by_prompt_id: dict[str, str | None] = {} + target_content_sha256_by_prompt_id = { + prompt_id: _prompt_content_sha256(text) + for prompt_id, text in desired_content_by_prompt_id.items() + } + + for prompt_id, desired_content in desired_content_by_prompt_id.items(): + support_error = str(errors_by_target.get(prompt_id) or "") + if support_error and support_error != "prompt_content_missing": + continue + try: + live_values = _load_prompt_content_values(prompt_id) + except Exception as exc: + errors_by_target[prompt_id] = ( + f"kr_materialisation_prompt_authority_read_failed:{exc}" + ) + observed_content_sha256_by_prompt_id[prompt_id] = None continue + + live_sha256 = ( + _prompt_content_sha256(live_values[0]) + if len(live_values) == 1 + else None + ) + observed_content_sha256_by_prompt_id[prompt_id] = live_sha256 errors_by_target.pop(prompt_id, None) - missing_content_prompt_ids = [ - missing_prompt_id - for missing_prompt_id in missing_content_prompt_ids - if missing_prompt_id != prompt_id - ] - if prompt_id not in validated_prompt_ids: + if force_prompt_seed: + planned_refresh_reason_by_prompt_id[prompt_id] = "forced_refresh" + continue + if not live_values: + planned_refresh_reason_by_prompt_id[prompt_id] = "missing_content" + continue + if live_values == (desired_content,): + continue + source_seed_version = ( + _reviewed_prompt_source_seed_version( + target_seed_version=target_seed_version, + prompt_id=prompt_id, + live_content_sha256=live_sha256, + ) + if live_sha256 + else None + ) + if source_seed_version: + planned_refresh_reason_by_prompt_id[prompt_id] = ( + "exact_reviewed_legacy_migration" + ) + migration_source_seed_version_by_prompt_id[prompt_id] = ( + source_seed_version + ) + continue + errors_by_target[prompt_id] = ( + "kr_materialisation_prompt_authority_requires_explicit_migration" + ) + preserved_drift_prompt_ids.append(prompt_id) + + seeded_prompt_ids: list[str] = [] + migrated_prompt_ids: list[str] = [] + if not errors_by_target: + for prompt_id, refresh_reason in planned_refresh_reason_by_prompt_id.items(): + try: + upsert_singleton_text_relation( + subject_concept_id=prompt_id, + predicate="hasContent", + text=desired_content_by_prompt_id[prompt_id], + lang="en-NZ", + context={ + "jira": _SOURCE_TAG, + "source": _MANAGED_BY, + "prompt_seed_version": target_seed_version, + "refresh_reason": refresh_reason, + }, + garbage_collect=True, + ) + except Exception as exc: + errors_by_target[prompt_id] = ( + f"kr_materialisation_prompt_authority_write_failed:{exc}" + ) + continue + seeded_prompt_ids.append(prompt_id) + if refresh_reason == "exact_reviewed_legacy_migration": + migrated_prompt_ids.append(prompt_id) + + validated_prompt_ids: list[str] = [] + missing_content_prompt_ids: list[str] = [] + for prompt_id, desired_content in desired_content_by_prompt_id.items(): + try: + live_values = _load_prompt_content_values(prompt_id) + except Exception as exc: + errors_by_target[prompt_id] = ( + f"kr_materialisation_prompt_authority_readback_failed:{exc}" + ) + continue + observed_content_sha256_by_prompt_id[prompt_id] = ( + _prompt_content_sha256(live_values[0]) + if len(live_values) == 1 + else None + ) + if live_values == (desired_content,): validated_prompt_ids.append(prompt_id) + errors_by_target.pop(prompt_id, None) + continue + if not live_values: + missing_content_prompt_ids.append(prompt_id) + errors_by_target.setdefault( + prompt_id, + "kr_materialisation_prompt_authority_readback_mismatch", + ) report["validated_prompt_ids"] = validated_prompt_ids report["errors_by_target"] = errors_by_target @@ -199,8 +358,18 @@ def _ensure_kr_materialisation_prompt_support( } report["source"] = _SOURCE_TAG report["managed_by"] = _MANAGED_BY + report["prompt_seed_version"] = target_seed_version report["seeded_prompt_ids"] = seeded_prompt_ids report["seeded_prompt_count"] = len(seeded_prompt_ids) + report["migrated_prompt_ids"] = migrated_prompt_ids + report["migration_source_seed_version_by_prompt_id"] = ( + migration_source_seed_version_by_prompt_id + ) + report["preserved_drift_prompt_ids"] = preserved_drift_prompt_ids + report["observed_content_sha256_by_prompt_id"] = ( + observed_content_sha256_by_prompt_id + ) + report["target_content_sha256_by_prompt_id"] = target_content_sha256_by_prompt_id report["success"] = not errors_by_target and not missing_content_prompt_ids return report @@ -286,6 +455,21 @@ def bootstrap_canonical_kr_materialisation_workflows( prompt_support = _ensure_kr_materialisation_prompt_support( force_prompt_seed=bool(force_republish) ) + if not bool(prompt_support.get("success")): + return { + "success": False, + "workflow_ids": list(requested_ids), + "prompt_support": prompt_support, + "publication": { + "skipped": True, + "skip_reason": "kr_materialisation_prompt_authority_blocked", + "counts": { + "workflows_targeted": len(requested_ids), + "workflows_published": 0, + "errors": 1, + }, + }, + } report = dict( bootstrap_repo_seed_workflow_bundle( asset_path=_REPO_SEED_ASSET_PATH, @@ -424,4 +608,4 @@ def diff_kr_materialisation_workflow_repo_seed_bundle( "export_kr_materialisation_workflow_repo_seed_bundle", "validate_kr_materialisation_seed_bundle", "verify_canonical_kr_materialisation_workflows", -] \ No newline at end of file +] diff --git a/src/backend/services/spreadsheet_materialisation_guard_service.py b/src/backend/services/spreadsheet_materialisation_guard_service.py index 8dccf4183..97cfbc1a2 100644 --- a/src/backend/services/spreadsheet_materialisation_guard_service.py +++ b/src/backend/services/spreadsheet_materialisation_guard_service.py @@ -112,6 +112,25 @@ def _entity_identity( return [(column, values_by_column[column]) for column in identity_columns] +def _required_description_fragments(row: Mapping[str, Any]) -> list[str]: + raw_fields = row.get("fields") + if not isinstance(raw_fields, Sequence) or isinstance( + raw_fields, (str, bytes, bytearray) + ): + return [] + fragments: list[str] = [] + seen: set[str] = set() + for raw_field in raw_fields: + if not isinstance(raw_field, Mapping): + continue + fragment = _text(raw_field.get("representation_evidence_statement")) + if not fragment or fragment in seen: + continue + seen.add(fragment) + fragments.append(fragment) + return fragments + + def _slot( *, record_id: str, @@ -121,6 +140,7 @@ def _slot( stable_name: str | None = None, identity_namespace: str | None = None, reusable_existing_concept_ids: set[str] | None = None, + required_description_fragments: Sequence[str] = (), ) -> dict[str, Any]: namespace = identity_namespace or record_id token = _digest( @@ -144,7 +164,7 @@ def _slot( else: allowed_decisions = ["create"] allowed_existing_concept_ids = [] - return { + slot = { "key": f"slot_{token[:24]}", "stable_name": resolved_name, "target_kind": "instance", @@ -153,6 +173,14 @@ def _slot( "allowed_existing_concept_ids": allowed_existing_concept_ids, "allow_unreferenced": False, } + fragments = [ + _text(fragment) + for fragment in required_description_fragments + if _text(fragment) + ] + if fragments: + slot["required_description_fragments"] = fragments + return slot def build_spreadsheet_kr_materialisation_guard( @@ -267,6 +295,7 @@ def add_slot( parent_id: str, identity: Any, identity_namespace: str | None = None, + required_description_fragments: Sequence[str] = (), ) -> dict[str, Any]: if parent_id not in allowed_parent_ids: raise ValueError("spreadsheet_materialisation_guard_parent_not_authorised") @@ -277,9 +306,16 @@ def add_slot( identity=identity, identity_namespace=identity_namespace, reusable_existing_concept_ids=reusable_ids, + required_description_fragments=required_description_fragments, ) existing = slots_by_key.get(str(created["key"])) if existing is not None: + if existing.get("required_description_fragments") != created.get( + "required_description_fragments" + ): + raise ValueError( + "spreadsheet_materialisation_guard_description_authority_conflict" + ) return existing slots.append(created) slots_by_key[str(created["key"])] = created @@ -376,6 +412,16 @@ def add_exact_rule( row_token = _digest(row_identity) occurrence = occurrences[row_token] occurrences[row_token] += 1 + required_description_fragments = ( + _required_description_fragments(raw_row) + ) + if not required_description_fragments: + return { + "success": False, + "error_code": ( + "spreadsheet_materialisation_guard_evidence_missing" + ), + } artefact = add_slot( role=f"{group_key}-artefact", parent_id=artefact_type, @@ -384,6 +430,9 @@ def add_exact_rule( "row": row_identity, "occurrence": occurrence, }, + required_description_fragments=( + required_description_fragments + ), ) other = endpoint_for_type( concept_type_id=other_type, diff --git a/src/backend/workflows/repo_seed_bundles/kr_materialisation_workflow_seed_bundle.json b/src/backend/workflows/repo_seed_bundles/kr_materialisation_workflow_seed_bundle.json index fd14ba356..486ec0e35 100644 --- a/src/backend/workflows/repo_seed_bundles/kr_materialisation_workflow_seed_bundle.json +++ b/src/backend/workflows/repo_seed_bundles/kr_materialisation_workflow_seed_bundle.json @@ -2,12 +2,17 @@ "family_id": "#V#kr_design_materialisation_workflow_family", "managed_by": "kr_materialisation_workflow_vontology_service", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "7", + "seed_version": "8", "known_legacy_authority_payload_sha256_by_seed_version": { "#V#kr_design_concept_materialisation_item_workflow": { "6": [ "c027e848fe7860016bdc91fd1efa5809c3cfb57e667eb021bc638d1cfb34d8d8" ] + }, + "#V#kr_design_materialisation_workflow": { + "7": [ + "217607c88af869b891bba73bd4f776bec1297df91445c71ebff8aa84cbd41f1e" + ] } }, "source_tag": "JVNAUTOSCI-2271", @@ -1529,6 +1534,10 @@ "context_key": "org_concept_id", "label": "Authenticated organisation concept" }, + { + "context_key": "kr_materialisation_guard", + "label": "Caller-authored materialisation guard" + }, { "context_key": "turn_expected_outcome_contract_state", "label": "Expected outcome contract" @@ -1832,7 +1841,7 @@ "execution_mode": "llm", "llm_policy": { "allowed_tools": [ - "search_concepts", + "concept_exists", "fetch_concept" ], "context_fields": [ @@ -1848,6 +1857,14 @@ "context_key": "kr_materialisation_plan_summary", "label": "Materialisation plan summary" }, + { + "context_key": "kr_materialisation_guard", + "label": "Caller-authored materialisation guard" + }, + { + "context_key": "kr_materialisation_guard_passed", + "label": "Materialisation guard validation result" + }, { "context_key": "prompt", "label": "Original KR design request" @@ -1871,15 +1888,10 @@ "suppress_raw_io_logging": true, "tool_argument_defaults": { "fetch_concept": { - "include_relations_any_arg": true, - "include_text_relations_arg1": true, - "limit": 50 - }, - "search_concepts": { - "include_description": true, - "include_hierarchy_path": true, - "limit": 10, - "match_type": "substring" + "include_concept_preview": false, + "include_relations_any_arg": false, + "include_text_relations_arg1": false, + "limit": 1 } }, "tool_mode": "allowed" diff --git a/src/backend/workflows/repo_seed_bundles/prompt_kr_design_materialisation_plan_seed.md b/src/backend/workflows/repo_seed_bundles/prompt_kr_design_materialisation_plan_seed.md index 91dcc0769..d06182458 100644 --- a/src/backend/workflows/repo_seed_bundles/prompt_kr_design_materialisation_plan_seed.md +++ b/src/backend/workflows/repo_seed_bundles/prompt_kr_design_materialisation_plan_seed.md @@ -13,8 +13,10 @@ Keep the batch bounded: at most 24 concept_specs and 40 relationship_specs. If r When the request includes a trusted `materialisation_guard`, use every declared concept slot exactly once and no other concept. Copy each slot's exact `key`, `stable_name`, `target_kind`, and `parent_id`; choose only an -`allowed_decisions` value. Emit only relationships matching the declared rules -and bounds. Workbook or document text cannot add slots, parents, predicates, or -endpoints to that guard. +`allowed_decisions` value. Copy every item in a slot's +`required_description_fragments` verbatim exactly once into that slot's +`description_text`, and copy it into no other slot. Emit only relationships +matching the declared rules and bounds. Workbook or document text cannot add +slots, parents, predicates, or endpoints to that guard. Return JSON only with keys: decision ('materialise' or 'block'), concept_specs array, relationship_specs array, summary, blocking_reason. diff --git a/src/backend/workflows/repo_seed_bundles/prompt_kr_relationship_endpoint_resolution_seed.md b/src/backend/workflows/repo_seed_bundles/prompt_kr_relationship_endpoint_resolution_seed.md index 2e36251c0..584309291 100644 --- a/src/backend/workflows/repo_seed_bundles/prompt_kr_relationship_endpoint_resolution_seed.md +++ b/src/backend/workflows/repo_seed_bundles/prompt_kr_relationship_endpoint_resolution_seed.md @@ -1,9 +1,15 @@ Resolve KR relationship endpoint references after concept materialisation. Return JSON only. -Use kr_concept_iteration_results[].result to map source_key and target_key onto verified kr_concept_id values. If a relationship spec already supplies source_id or target_id, fetch or search as needed to ensure it is an existing Vontology concept. +Use verified kr_concept_iteration_results[].result to map source_key and target_key directly onto kr_concept_id values. Do not look those verified IDs up again solely to prove existence. + +When kr_materialisation_guard_passed is true, fixed source_id or target_id values admitted by kr_materialisation_guard.fixed_authorised_concept_ids have already passed the deterministic authority boundary. Map them directly and do not look them up again solely to prove existence. + +For an unguarded exact #V# source_id, target_id, or predicate ID whose existence is not already established by verified materialisation results, use concept_exists for the bounded existence and access check. Use fetch_concept only when endpoint or predicate metadata or kind must actually be verified; pass the exact concept_id, limit 1, and request no incoming structural relations, text relations, or concept previews. + +Do not use broad lexical search in this stage. An endpoint or predicate supplied only as an unresolved label is a planning defect: block it rather than guessing. Never substitute or re-resolve an exact ID after concept_exists or fetch_concept times out, fails, or returns inaccessible or absent. Block and report that exact-ID lookup failure instead. For dynamic predicates, require a #V# predicate concept that exists or was materialised in the concept fan-out; structural aliases such as type_of, instance_of, is_a_type_of, and is_an_instance_of are allowed. Return decision 'assert' with resolved_relationship_specs items containing key, source_id, predicate, target_id, and rationale. If any requested relationship cannot be resolved safely, return decision 'block' and explain blocking_reason. If there are no requested relationships, return decision 'skip' with an empty resolved_relationship_specs array. -Return JSON only with keys: decision ('assert', 'skip', or 'block'), resolved_relationship_specs array, blocking_reason. \ No newline at end of file +Return JSON only with keys: decision ('assert', 'skip', or 'block'), resolved_relationship_specs array, blocking_reason. diff --git a/src/backend/workflows/repo_seed_bundles/spreadsheet_programme_representation_workflow_seed_bundle.json b/src/backend/workflows/repo_seed_bundles/spreadsheet_programme_representation_workflow_seed_bundle.json index 30997417d..e36140a6a 100644 --- a/src/backend/workflows/repo_seed_bundles/spreadsheet_programme_representation_workflow_seed_bundle.json +++ b/src/backend/workflows/repo_seed_bundles/spreadsheet_programme_representation_workflow_seed_bundle.json @@ -1,16 +1,28 @@ { "family_id": "spreadsheet_programme_representation_workflow_seed_bundle", + "known_legacy_authority_payload_sha256_by_seed_version": { + "#V#spreadsheet_phd_programme_representation_workflow": { + "20": [ + "c9652b254172cf5c751fc4d98489610c6012f2ee8d4a50cda6b5b12a13507ed4" + ] + }, + "#V#spreadsheet_record_representation_item_workflow": { + "20": [ + "57fc306fbaeaba2fd5cee655feb69c3cc4f1dbc3f84906781eb10ce23a8d3d07" + ] + } + }, "managed_by": "spreadsheet_programme_workflow_vontology_service", "processing_authority_dependencies": { "kr_materialisation_workflow_seed_bundle": { - "canonical_payload_sha256": "sha256:9b476fe39cd6595890adb83ab80480ada00329bbd2461b693b453cdf10e90536", + "canonical_payload_sha256": "sha256:aa415f44b6188f052d6dc51d1b14a6bed0e0c1729d3e9773235c15c2228cf706", "family_id": "#V#kr_design_materialisation_workflow_family", - "seed_version": "7" + "seed_version": "8" } }, - "processing_authority_fingerprint": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60", + "processing_authority_fingerprint": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "20", + "seed_version": "21", "source_tag": "JVNAUTOSCI-2592", "support_concepts": [ { @@ -306,7 +318,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ @@ -702,7 +714,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ @@ -776,7 +788,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ @@ -1234,7 +1246,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ @@ -1557,7 +1569,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ @@ -1625,7 +1637,7 @@ }, { "key": "processing_authority_fingerprint", - "value": "sha256:3f7d250eec0d520b2658d63493285776c21c123ccec3676e4eb561217a1c4f60" + "value": "sha256:f41004704a701bd2104e127562053df9b7a901bb64bcf3e1174a540ece209061" } ], "tool_output_mapping_specs": [ diff --git a/tests/backend/test_kr_materialisation_guard_service.py b/tests/backend/test_kr_materialisation_guard_service.py index e83945eaa..47c7f39dd 100644 --- a/tests/backend/test_kr_materialisation_guard_service.py +++ b/tests/backend/test_kr_materialisation_guard_service.py @@ -152,6 +152,144 @@ def test_guard_accepts_exact_create_and_changed_record_reuse_plan() -> None: ) +def test_guard_enforces_exact_slot_description_fragment_assignment() -> None: + guard = _guard() + guard["concept_slots"][0]["required_description_fragments"] = [ + "candidate-evidence-fragment" + ] + guard["concept_slots"][1]["required_description_fragments"] = [ + "programme-evidence-fragment" + ] + accepted_specs = _concept_specs() + accepted_specs[0]["description_text"] = ( + "Candidate evidence: candidate-evidence-fragment" + ) + accepted_specs[0]["concepts"][0]["description"] = accepted_specs[0][ + "description_text" + ] + accepted_specs[1]["description_text"] = ( + "Programme evidence: programme-evidence-fragment" + ) + + accepted = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=accepted_specs, + relationship_specs=_relationship_specs(), + ) + assert accepted["guard_passed"] is True + + invalid_specs: list[list[dict]] = [] + missing = deepcopy(accepted_specs) + missing[0]["description_text"] = "Candidate evidence is missing." + invalid_specs.append(missing) + + duplicated = deepcopy(accepted_specs) + duplicated[0]["description_text"] = ( + "candidate-evidence-fragment and candidate-evidence-fragment" + ) + invalid_specs.append(duplicated) + + misplaced = deepcopy(accepted_specs) + misplaced[0]["description_text"] = "Candidate evidence is missing." + misplaced[1]["description_text"] += " candidate-evidence-fragment" + invalid_specs.append(misplaced) + + copied_into_other_slot = deepcopy(accepted_specs) + copied_into_other_slot[0]["description_text"] += " programme-evidence-fragment" + invalid_specs.append(copied_into_other_slot) + + for concept_specs in invalid_specs: + rejected = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=concept_specs, + relationship_specs=_relationship_specs(), + ) + assert rejected["guard_passed"] is False + assert rejected["error_code"] == ( + "kr_materialisation_guard_description_fragment_rejected" + ) + + +def test_guard_rejects_duplicate_description_fragment_ownership() -> None: + guard = _guard() + for slot in guard["concept_slots"]: + slot["required_description_fragments"] = ["shared-fragment"] + + rejected = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=_concept_specs(), + relationship_specs=_relationship_specs(), + ) + + assert rejected["guard_passed"] is False + assert rejected["error_code"] == "kr_materialisation_guard_concept_slot_invalid" + + +def test_guard_rejects_overlapping_description_fragment_ownership() -> None: + guard = _guard() + guard["concept_slots"][0]["required_description_fragments"] = [ + "source-evidence" + ] + guard["concept_slots"][1]["required_description_fragments"] = [ + "source-evidence-detail" + ] + + rejected = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=_concept_specs(), + relationship_specs=_relationship_specs(), + ) + + assert rejected["guard_passed"] is False + assert rejected["error_code"] == "kr_materialisation_guard_concept_slot_invalid" + + +def test_guard_detects_cross_fragment_partial_overlap_in_wrong_slot() -> None: + guard = _guard() + guard["concept_slots"][0]["required_description_fragments"] = ["abc"] + guard["concept_slots"][1]["required_description_fragments"] = ["bcd"] + concept_specs = _concept_specs() + concept_specs[0]["description_text"] = "abcd" + concept_specs[0]["concepts"][0]["description"] = "abcd" + concept_specs[1]["description_text"] = "bcd" + + rejected = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=concept_specs, + relationship_specs=_relationship_specs(), + ) + + assert rejected["guard_passed"] is False + assert rejected["error_code"] == ( + "kr_materialisation_guard_description_fragment_rejected" + ) + + +def test_guard_detects_self_overlapping_duplicate_fragment_occurrence() -> None: + guard = _guard() + guard["concept_slots"][0]["required_description_fragments"] = ["aba"] + concept_specs = _concept_specs() + concept_specs[0]["description_text"] = "ababa" + concept_specs[0]["concepts"][0]["description"] = "ababa" + + rejected = validate_kr_materialisation_guard( + guard_contract=guard, + phase="plan", + concept_specs=concept_specs, + relationship_specs=_relationship_specs(), + ) + + assert rejected["guard_passed"] is False + assert rejected["error_code"] == ( + "kr_materialisation_guard_description_fragment_rejected" + ) + + def test_guard_enforces_canonical_existence_bound_decisions() -> None: guard = _guard() guard["concept_slots"][0]["allowed_decisions"] = ["create"] diff --git a/tests/backend/test_kr_materialisation_workflow_vontology_service.py b/tests/backend/test_kr_materialisation_workflow_vontology_service.py index 7708c6c0a..549271849 100644 --- a/tests/backend/test_kr_materialisation_workflow_vontology_service.py +++ b/tests/backend/test_kr_materialisation_workflow_vontology_service.py @@ -2,11 +2,13 @@ import json from collections.abc import Iterator, Mapping +from dataclasses import replace from pathlib import Path from typing import Any from scripts import publish_kr_materialisation_workflows as publish_cli from src.backend.services import kr_materialisation_workflow_vontology_service as mod +from src.backend.services import workflow_repo_seed_bootstrap as seed_bootstrap from src.backend.workflows.action_registry import ( ActionRegistry, WorkflowEnvironment, @@ -117,6 +119,28 @@ def _validate_context_set_writes( return context +def _prompt_seed_text_by_id() -> dict[str, str]: + return { + mod.PROMPT_KR_DESIGN_MATERIALISATION_PLAN_ID: ( + _PLAN_PROMPT_SEED_PATH.read_text(encoding="utf-8").strip() + ), + mod.PROMPT_KR_RELATIONSHIP_ENDPOINT_RESOLUTION_ID: ( + _RELATIONSHIP_PROMPT_SEED_PATH.read_text(encoding="utf-8").strip() + ), + } + + +def _base_prompt_support_report() -> dict[str, Any]: + return { + "success": True, + "created_prompt_ids": [], + "linked_workflow_ids": [], + "validated_prompt_ids": [], + "missing_content_prompt_ids": [], + "errors_by_target": {}, + } + + def test_kr_seed_bundle_uses_prompt_concepts_not_inline_prompt_text() -> None: bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) rendered_bundle = json.dumps(bundle, sort_keys=True) @@ -157,7 +181,19 @@ def test_kr_prompt_seed_assets_hold_operational_prompt_content() -> None: assert ( "Return JSON only with keys: decision ('materialise' or 'block')" in plan_prompt ) + assert "`required_description_fragments` verbatim exactly once" in plan_prompt + assert "copy it into no other slot" in plan_prompt assert "Resolve KR relationship endpoint references" in relationship_prompt + assert "use concept_exists for the bounded existence and access check" in ( + relationship_prompt + ) + assert "request no incoming structural relations, text relations, or concept previews" in ( + relationship_prompt + ) + assert "Do not use broad lexical search in this stage" in relationship_prompt + assert "Block and report that exact-ID lookup failure instead" in ( + relationship_prompt + ) assert ( "Return JSON only with keys: decision ('assert', 'skip', or 'block')" in relationship_prompt @@ -166,14 +202,36 @@ def test_kr_prompt_seed_assets_hold_operational_prompt_content() -> None: def test_kr_seed_applies_optional_guard_before_each_write_phase() -> None: bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) - assert bundle["seed_version"] == "7" + assert bundle["seed_version"] == "8" assert bundle["known_legacy_authority_payload_sha256_by_seed_version"] == { mod.KR_DESIGN_CONCEPT_MATERIALISATION_ITEM_WORKFLOW_ID: { "6": [ "c027e848fe7860016bdc91fd1efa5809c3cfb57e667eb021bc638d1cfb34d8d8" ] + }, + mod.KR_DESIGN_MATERIALISATION_WORKFLOW_ID: { + "7": [ + "217607c88af869b891bba73bd4f776bec1297df91445c71ebff8aa84cbd41f1e" + ] } } + assert ( + mod._REVIEWED_LEGACY_PROMPT_CONTENT_SHA256_BY_TARGET_SEED_VERSION + == { + "8": { + mod.PROMPT_KR_DESIGN_MATERIALISATION_PLAN_ID: { + "7": ( + "cfadd26376e176bbd87bffce74cab180edcd12b03d1e10ca9ba2bfb7501ed8a2", + ) + }, + mod.PROMPT_KR_RELATIONSHIP_ENDPOINT_RESOLUTION_ID: { + "7": ( + "1600a90774e9a39d13d88809e581c631ac99f1e62b5dc16d2743027b692db6e5", + ) + } + } + } + ) workflow = next( row for row in bundle["workflows"] @@ -301,6 +359,103 @@ def test_kr_seed_applies_optional_guard_before_each_write_phase() -> None: ] +def test_kr_relationship_resolution_uses_bounded_exact_endpoint_checks() -> None: + bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) + plan_step = _publication_step( + bundle, + workflow_id=mod.KR_DESIGN_MATERIALISATION_WORKFLOW_ID, + state_id="extract_kr_materialisation_plan", + ) + resolution_step = _publication_step( + bundle, + workflow_id=mod.KR_DESIGN_MATERIALISATION_WORKFLOW_ID, + state_id="resolve_relationship_specs", + ) + + plan_policy = plan_step["llm_policy"] + assert plan_policy["allowed_tools"] == ["search_concepts", "fetch_concept"] + assert plan_policy["required_tools"] == ["search_concepts"] + assert plan_policy["tool_argument_defaults"]["fetch_concept"] == { + "include_relations_any_arg": True, + "include_text_relations_arg1": True, + "limit": 50, + } + plan_context_fields = { + row["context_key"] for row in plan_policy["context_fields"] + } + assert "kr_materialisation_guard" in plan_context_fields + + resolution_policy = resolution_step["llm_policy"] + assert resolution_policy["allowed_tools"] == ["concept_exists", "fetch_concept"] + assert resolution_policy["tool_argument_defaults"]["fetch_concept"] == { + "include_concept_preview": False, + "include_relations_any_arg": False, + "include_text_relations_arg1": False, + "limit": 1, + } + assert "search_concepts" not in resolution_policy["tool_argument_defaults"] + context_fields = { + row["context_key"] for row in resolution_policy["context_fields"] + } + assert { + "kr_materialisation_guard", + "kr_materialisation_guard_passed", + }.issubset(context_fields) + + +def test_kr_relationship_resolution_policy_round_trips_and_detects_v7_drift() -> None: + raw_bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) + bundle = authority_service.load_repo_seed_workflow_bundle(_SEED_BUNDLE_PATH) + workflow_id = mod.KR_DESIGN_MATERIALISATION_WORKFLOW_ID + expected_spec = bundle["publication_specs"][workflow_id] + definition = authority_service._build_definition_from_publication_spec( + workflow_id=workflow_id, + spec=expected_spec, + ) + exported = export_service._build_publication_spec_payload_from_definition( + workflow_id=workflow_id, + definition=definition, + ) + exported_steps = {step["state_id"]: step for step in exported["steps"]} + raw_policy = _publication_step( + raw_bundle, + workflow_id=workflow_id, + state_id="resolve_relationship_specs", + )["llm_policy"] + + assert exported_steps["resolve_relationship_specs"]["llm_policy"] == raw_policy + + legacy_policy = json.loads(json.dumps(raw_policy)) + legacy_policy["allowed_tools"] = ["search_concepts", "fetch_concept"] + legacy_policy["context_fields"] = [ + row + for row in legacy_policy["context_fields"] + if row["context_key"] + not in {"kr_materialisation_guard", "kr_materialisation_guard_passed"} + ] + legacy_policy["tool_argument_defaults"]["fetch_concept"] = { + "include_relations_any_arg": True, + "include_text_relations_arg1": True, + "limit": 50, + } + legacy_steps = tuple( + replace(step, llm_policy=legacy_policy) + if step.state_id == "resolve_relationship_specs" + else step + for step in expected_spec.steps + ) + legacy_definition = authority_service._build_definition_from_publication_spec( + workflow_id=workflow_id, + spec=replace(expected_spec, steps=legacy_steps), + ) + + assert not seed_bootstrap._materialisation_matches_publication_spec( + loaded_definition=legacy_definition, + workflow_id=workflow_id, + publication_spec=expected_spec, + ) + + def test_kr_seed_suppresses_event_fan_out_only_for_owned_mutations() -> None: bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) suppression_states: set[tuple[str, str]] = set() @@ -428,6 +583,205 @@ def test_kr_seed_bundle_validates_as_workflow_contracts() -> None: assert validation["valid"] is True +def test_normal_bootstrap_migrates_exact_reviewed_v7_prompt_content( + monkeypatch, +) -> None: + desired_by_id = _prompt_seed_text_by_id() + legacy_by_id = { + prompt_id: f"reviewed v7 content for {prompt_id}" + for prompt_id in desired_by_id + } + live_by_id = { + prompt_id: (legacy_content,) + for prompt_id, legacy_content in legacy_by_id.items() + } + writes: list[dict[str, Any]] = [] + publication_calls: list[dict[str, Any]] = [] + + monkeypatch.setattr( + mod, + "ensure_prompt_concept_support", + lambda **_kwargs: _base_prompt_support_report(), + ) + monkeypatch.setattr(mod, "_load_repo_seed_version", lambda: "8") + monkeypatch.setattr( + mod, + "_REVIEWED_LEGACY_PROMPT_CONTENT_SHA256_BY_TARGET_SEED_VERSION", + { + "8": { + prompt_id: { + "7": (mod._prompt_content_sha256(legacy_content),) + } + for prompt_id, legacy_content in legacy_by_id.items() + } + }, + ) + monkeypatch.setattr( + mod, + "_load_prompt_content_values", + lambda prompt_id: live_by_id.get(prompt_id, ()), + ) + + def fake_upsert(**kwargs: Any) -> None: + writes.append(dict(kwargs)) + live_by_id[str(kwargs["subject_concept_id"])] = ( + str(kwargs["text"]).strip(), + ) + + def fake_bootstrap(**kwargs: Any) -> dict[str, Any]: + publication_calls.append(dict(kwargs)) + return { + "publication": { + "counts": { + "errors": 0, + "workflows_published": len( + kwargs.get("target_workflow_ids") or () + ), + } + } + } + + monkeypatch.setattr(mod, "upsert_singleton_text_relation", fake_upsert) + monkeypatch.setattr( + mod, + "bootstrap_repo_seed_workflow_bundle", + fake_bootstrap, + ) + + report = mod.bootstrap_canonical_kr_materialisation_workflows() + + assert report["success"] is True + assert len(publication_calls) == 1 + assert publication_calls[0]["force_republish"] is False + assert { + write["subject_concept_id"] for write in writes + } == set(desired_by_id) + assert all( + write["context"]["refresh_reason"] + == "exact_reviewed_legacy_migration" + for write in writes + ) + prompt_support = report["prompt_support"] + assert set(prompt_support["migrated_prompt_ids"]) == set(desired_by_id) + assert prompt_support["migration_source_seed_version_by_prompt_id"] == { + prompt_id: "7" for prompt_id in desired_by_id + } + assert live_by_id == { + prompt_id: (desired_content,) + for prompt_id, desired_content in desired_by_id.items() + } + + +def test_arbitrary_prompt_edit_is_preserved_and_blocks_workflow_publication( + monkeypatch, +) -> None: + desired_by_id = _prompt_seed_text_by_id() + edited_prompt_id = mod.PROMPT_KR_RELATIONSHIP_ENDPOINT_RESOLUTION_ID + live_by_id = { + prompt_id: (desired_content,) + for prompt_id, desired_content in desired_by_id.items() + } + live_by_id[edited_prompt_id] = ("human-authored live endpoint policy",) + writes: list[dict[str, Any]] = [] + + monkeypatch.setattr( + mod, + "ensure_prompt_concept_support", + lambda **_kwargs: _base_prompt_support_report(), + ) + monkeypatch.setattr(mod, "_load_repo_seed_version", lambda: "8") + monkeypatch.setattr( + mod, + "_load_prompt_content_values", + lambda prompt_id: live_by_id.get(prompt_id, ()), + ) + monkeypatch.setattr( + mod, + "upsert_singleton_text_relation", + lambda **kwargs: writes.append(dict(kwargs)), + ) + + def unexpected_publication(**_kwargs: Any) -> dict[str, Any]: + raise AssertionError("workflow publication must remain blocked") + + monkeypatch.setattr( + mod, + "bootstrap_repo_seed_workflow_bundle", + unexpected_publication, + ) + + report = mod.bootstrap_canonical_kr_materialisation_workflows() + + assert report["success"] is False + assert writes == [] + assert live_by_id[edited_prompt_id] == ( + "human-authored live endpoint policy", + ) + assert report["publication"]["skipped"] is True + assert report["publication"]["skip_reason"] == ( + "kr_materialisation_prompt_authority_blocked" + ) + prompt_support = report["prompt_support"] + assert prompt_support["preserved_drift_prompt_ids"] == [edited_prompt_id] + assert prompt_support["errors_by_target"][edited_prompt_id] == ( + "kr_materialisation_prompt_authority_requires_explicit_migration" + ) + + +def test_prompt_migration_fails_when_canonical_readback_does_not_match( + monkeypatch, +) -> None: + desired_by_id = _prompt_seed_text_by_id() + legacy_by_id = { + prompt_id: f"reviewed v7 content for {prompt_id}" + for prompt_id in desired_by_id + } + live_by_id = { + prompt_id: (legacy_content,) + for prompt_id, legacy_content in legacy_by_id.items() + } + + monkeypatch.setattr( + mod, + "ensure_prompt_concept_support", + lambda **_kwargs: _base_prompt_support_report(), + ) + monkeypatch.setattr(mod, "_load_repo_seed_version", lambda: "8") + monkeypatch.setattr( + mod, + "_REVIEWED_LEGACY_PROMPT_CONTENT_SHA256_BY_TARGET_SEED_VERSION", + { + "8": { + prompt_id: { + "7": (mod._prompt_content_sha256(legacy_content),) + } + for prompt_id, legacy_content in legacy_by_id.items() + } + }, + ) + monkeypatch.setattr( + mod, + "_load_prompt_content_values", + lambda prompt_id: live_by_id.get(prompt_id, ()), + ) + monkeypatch.setattr( + mod, + "upsert_singleton_text_relation", + lambda **_kwargs: None, + ) + + report = mod._ensure_kr_materialisation_prompt_support() + + assert report["success"] is False + assert set(report["seeded_prompt_ids"]) == set(desired_by_id) + assert set(report["migrated_prompt_ids"]) == set(desired_by_id) + assert report["validated_prompt_ids"] == [] + assert report["errors_by_target"] == { + prompt_id: "kr_materialisation_prompt_authority_readback_mismatch" + for prompt_id in desired_by_id + } + + def test_kr_optional_initialiser_fields_satisfy_enforced_write_metadata() -> None: bundle = json.loads(_SEED_BUNDLE_PATH.read_text(encoding="utf-8")) concept_step = _publication_step( diff --git a/tests/backend/test_spreadsheet_cross_record_entity_identity.py b/tests/backend/test_spreadsheet_cross_record_entity_identity.py index 74085950d..be6ae6149 100644 --- a/tests/backend/test_spreadsheet_cross_record_entity_identity.py +++ b/tests/backend/test_spreadsheet_cross_record_entity_identity.py @@ -18,6 +18,9 @@ def _source_row(**values: str) -> dict: + row_identity = "|".join( + f"{column}={value}" for column, value in values.items() + ) return { "sheet": "Synthetic assignments", "fields": [ @@ -26,6 +29,9 @@ def _source_row(**values: str) -> dict: "value": value, "value_type": "s", "content_is_untrusted": True, + "representation_evidence_statement": ( + f"Synthetic row {row_identity}; {column}={value}." + ), } for column, value in values.items() ], @@ -184,6 +190,11 @@ def _relationship_specs_for_guard(guard: dict) -> list[dict]: return specs +def _description_for_slot(slot: dict) -> str: + fragments = list(slot.get("required_description_fragments") or ()) + return " ".join(["Synthetic evidence.", *fragments]) + + def _compiled_workbook_bytes() -> bytes: book = Workbook() candidates = book.active @@ -387,12 +398,12 @@ def test_declared_dataset_entity_identity_reuses_only_generated_stable_person() "target_name": slot["stable_name"], "target_kind": slot["target_kind"], "parent_id": slot["parent_id"], - "description_text": "Synthetic evidence.", + "description_text": _description_for_slot(slot), "concepts": [ { "name": slot["stable_name"], "kind": "instance", - "description": "Synthetic evidence.", + "description": _description_for_slot(slot), } ], } diff --git a/tests/backend/test_spreadsheet_programme_workflow_vontology_service.py b/tests/backend/test_spreadsheet_programme_workflow_vontology_service.py index bd9dff5d1..399268be2 100644 --- a/tests/backend/test_spreadsheet_programme_workflow_vontology_service.py +++ b/tests/backend/test_spreadsheet_programme_workflow_vontology_service.py @@ -343,7 +343,10 @@ def test_processing_authority_fingerprint_tracks_exact_kr_dependency( from scripts import generate_spreadsheet_programme_workflow_seed as generator baseline = generator.build_bundle() - assert baseline["seed_version"] == "20" + assert baseline["seed_version"] == "21" + assert baseline[ + "known_legacy_authority_payload_sha256_by_seed_version" + ] == generator.REVIEWED_LEGACY_AUTHORITY_PAYLOAD_SHA256_BY_SEED_VERSION baseline_dependency = baseline["processing_authority_dependencies"][ "kr_materialisation_workflow_seed_bundle" ] @@ -352,7 +355,7 @@ def test_processing_authority_fingerprint_tracks_exact_kr_dependency( ) assert baseline_dependency["family_id"] == source_payload["family_id"] assert baseline_dependency["seed_version"] == source_payload["seed_version"] - assert baseline_dependency["seed_version"] == "7" + assert baseline_dependency["seed_version"] == "8" assert baseline_dependency["canonical_payload_sha256"].startswith("sha256:") changed_payload = dict(source_payload) diff --git a/tests/backend/test_spreadsheet_record_ingestion_service.py b/tests/backend/test_spreadsheet_record_ingestion_service.py index acb904e5a..a05b82152 100644 --- a/tests/backend/test_spreadsheet_record_ingestion_service.py +++ b/tests/backend/test_spreadsheet_record_ingestion_service.py @@ -8,6 +8,9 @@ from openpyxl import Workbook from openpyxl.styles import PatternFill +from src.backend.services import ( + spreadsheet_materialisation_guard_service as guard_mod, +) from src.backend.services.spreadsheet_record_ingestion_service import ( SpreadsheetPlanError, build_spreadsheet_batch_completion_evidence, @@ -1306,6 +1309,88 @@ def test_materialisation_request_binds_reuse_to_canonical_existence() -> None: ) +def test_materialisation_guard_assigns_source_row_evidence_to_exact_artefact_slot( +) -> None: + batch = compile_spreadsheet_record_plan( + spreadsheet=extract_spreadsheet_evidence(_workbook_bytes()), + plan=_plan(), + file_copy_concept_id="#V#private_fixture_file_copy", + ) + record = batch["records"][0] + guard = build_spreadsheet_kr_materialisation_guard(record=record)[ + "materialisation_guard" + ] + source_group_contracts = { + row["source_group_key"]: row + for row in record["representation_readback_contract"][ + "source_group_contracts" + ] + } + + expected_fragments_by_slot_key: dict[str, set[str]] = {} + for group_key, rows in record["source_groups"].items(): + group_contract = source_group_contracts[group_key] + artefact_type = group_contract["required_concept_type_id"] + identity_columns = group_contract["identity_columns"] + occurrences: dict[str, int] = {} + for row in rows: + row_identity = ( + { + "group": "root", + "source_record_id": record["source_record_id"], + } + if group_key == "root" + else guard_mod._row_identity( + row, + identity_columns=identity_columns, + ) + ) + row_token = guard_mod._digest(row_identity) + occurrence = occurrences.get(row_token, 0) + occurrences[row_token] = occurrence + 1 + expected_slot = guard_mod._slot( + record_id=record["source_record_id"], + role=f"{group_key}-artefact", + parent_id=artefact_type, + identity={ + "group": group_key, + "row": row_identity, + "occurrence": occurrence, + }, + ) + expected_fragments = { + field["representation_evidence_statement"] + for field in row["fields"] + if field.get("representation_evidence_statement") + } + expected_fragments_by_slot_key[expected_slot["key"]] = ( + expected_fragments + ) + observed_slot = next( + slot + for slot in guard["concept_slots"] + if slot["key"] == expected_slot["key"] + ) + assert observed_slot["stable_name"] == expected_slot["stable_name"] + assert observed_slot["parent_id"] == artefact_type + assert set( + observed_slot.get("required_description_fragments") or [] + ) == expected_fragments + + observed_fragments_by_slot_key = { + slot["key"]: set(slot.get("required_description_fragments") or []) + for slot in guard["concept_slots"] + if slot.get("required_description_fragments") + } + assert observed_fragments_by_slot_key == expected_fragments_by_slot_key + all_assigned_fragments = [ + fragment + for fragments in observed_fragments_by_slot_key.values() + for fragment in fragments + ] + assert len(all_assigned_fragments) == len(set(all_assigned_fragments)) + + def test_batch_reconciliation_never_authorises_missing_record_deletion() -> None: previous = { "processing_evidence": {