diff --git a/src/backend/workflows/durable/talk_representation_workflow.py b/src/backend/workflows/durable/talk_representation_workflow.py index 66cdb6ab..b8635734 100644 --- a/src/backend/workflows/durable/talk_representation_workflow.py +++ b/src/backend/workflows/durable/talk_representation_workflow.py @@ -79,6 +79,20 @@ def _handle(request: WorkflowActionRequest) -> WorkflowActionResult: request.inputs.get("presentation_concept_id"), request.data.get("presentation_concept_id"), ) + # This action materialises exactly one presentation. A programme-level + # list cannot identify that record, even when a workshop title is present. + if not presentation_concept_id and _resolve_context_list( + request, "existing_presentation_concept_ids" + ): + return WorkflowActionResult( + status="failed", + error=( + "talk_representation_requires_single_presentation:" + "provide presentation_concept_id and the fields for that talk; " + "existing_presentation_concept_ids is a programme-level list, " + "not a single presentation input" + ), + ) if not title and not presentation_concept_id: return WorkflowActionResult( status="failed", diff --git a/src/backend/workflows/repo_seed_bundles/talk_representation_workflow_seed_bundle.json b/src/backend/workflows/repo_seed_bundles/talk_representation_workflow_seed_bundle.json index b022ee24..706669b0 100644 --- a/src/backend/workflows/repo_seed_bundles/talk_representation_workflow_seed_bundle.json +++ b/src/backend/workflows/repo_seed_bundles/talk_representation_workflow_seed_bundle.json @@ -2,7 +2,7 @@ "family_id": "talk_representation_workflow_seed_bundle", "managed_by": "talk_representation_workflow_vontology_service", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "1", + "seed_version": "2", "source_tag": "JVNAUTOSCI-1459", "supported_action_ids": [ "talk.normalise_inputs", @@ -12,8 +12,8 @@ ], "workflows": [ { - "content": "Represent a talk by normalising presentation inputs, materialising or reusing the presentation concept, linking the presenter, and verifying the requested talk representation profile.", - "description": "Canonical durable workflow for representing talks and presentations in Vontology from structured meeting and speaker inputs.", + "content": "Normalise one presentation, materialise or reuse its explicit concept ID, link its presenter when supplied, and verify the requested fields. Optional meeting and speaker fields may be omitted. A programme list of existing_presentation_concept_ids must first be resolved into separate per-presentation inputs by the caller; it is not the singular presentation_concept_id.", + "description": "Represent one talk or presentation from structured inputs: provide presentation_concept_id to reuse an existing concept, or title to create one. This is not a programme/batch enrichment or source-text recovery workflow.", "display_name": "Talk Representation Workflow", "publication_spec": { "initial_state": "normalise_inputs", @@ -27,67 +27,67 @@ { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_presentation_concept_id_to_presentation_concept_id_parameter", "context_key": "presentation_concept_id", - "required": true, + "required": false, "tool_param": "presentation_concept_id" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_title_to_title_parameter", "context_key": "title", - "required": true, + "required": false, "tool_param": "title" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_speaker_name_to_speaker_name_parameter", "context_key": "speaker_name", - "required": true, + "required": false, "tool_param": "speaker_name" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_summary_to_summary_parameter", "context_key": "summary", - "required": true, + "required": false, "tool_param": "summary" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_start_time_to_start_time_parameter", "context_key": "start_time", - "required": true, + "required": false, "tool_param": "start_time" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_meeting_link_to_meeting_link_parameter", "context_key": "meeting_link", - "required": true, + "required": false, "tool_param": "meeting_link" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_meeting_id_to_meeting_id_parameter", "context_key": "meeting_id", - "required": true, + "required": false, "tool_param": "meeting_id" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_meeting_passcode_to_meeting_passcode_parameter", "context_key": "meeting_passcode", - "required": true, + "required": false, "tool_param": "meeting_passcode" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_presentation_status_to_presentation_status_parameter", "context_key": "presentation_status", - "required": true, + "required": false, "tool_param": "presentation_status" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_presentation_type_ids_to_presentation_type_ids_parameter", "context_key": "presentation_type_ids", - "required": true, + "required": false, "tool_param": "presentation_type_ids" }, { "concept_id": "#V#workflow_mapping_talk_representation_workflow_normalise_inputs_verification_profile_to_verification_profile_parameter", "context_key": "verification_profile", - "required": true, + "required": false, "tool_param": "verification_profile" } ], @@ -1168,5 +1168,22 @@ "Capability-gap note: this workflow depends on Python durable actions because current VWL/runtime primitives cannot yet express a reusable, deterministic representation-materialisation primitive for concept creation/update plus postcondition verification. Workflow #V#academic_presentation_instance_workflow should migrate fully into VWL once the runtime can declaratively express stable concept reuse, singleton text upserts, relationship writes, and verification against required representation predicates." ] } - ] + ], + "known_legacy_authority_payload_sha256_by_seed_version": { + "#V#talk_representation_workflow": { + "1": [ + "10121992e1bfe7d848eabc4dbf325dd5bd73caabfd40c7b1a15365ebb2ed9f5b" + ] + }, + "#V#technical_scientific_talk_representation_workflow": { + "1": [ + "37b003e5b07ced18137c37e4a7a45e0a5cd07afcdd94d2b100ecf1a50bb7d961" + ] + }, + "#V#academic_presentation_instance_workflow": { + "1": [ + "7ca0537839d2f03b85ee7250e1d38297f1f26d073a1f2f99ce9dd56df2a318ee" + ] + } + } } diff --git a/tests/backend/test_talk_representation_workflow_vontology_service.py b/tests/backend/test_talk_representation_workflow_vontology_service.py index c67bf795..9e3d841a 100644 --- a/tests/backend/test_talk_representation_workflow_vontology_service.py +++ b/tests/backend/test_talk_representation_workflow_vontology_service.py @@ -203,3 +203,133 @@ def test_technical_scientific_talk_workflow_materialises_and_verifies_representa for row in status_rows if isinstance(row, dict) ) + +@pytest.mark.parametrize( + "workflow_id", + [ + TALK_REPRESENTATION_WORKFLOW_ID, + TECHNICAL_SCIENTIFIC_TALK_REPRESENTATION_WORKFLOW_ID, + ACADEMIC_PRESENTATION_INSTANCE_WORKFLOW_ID, + ], +) +def test_sparse_existing_presentation_reuses_identity_idempotently( + _reset_mock_db: Any, + workflow_id: str, +) -> None: + bootstrap_canonical_talk_representation_workflows() + existing_id = "#V#existing_programme_presentation" + concept_service.create_concept( + concept_id=existing_id, + name="Existing programme presentation", + parent_concept_ids=["#V#presentation"], + create_as_instance=True, + ) + definition = load_workflow_definition_from_vontology(workflow_id) + executor = WorkflowExecutor( + registry=registry_factory.build_durable_action_registry(), + max_transitions=20, + ) + from src.backend.db.mongo_client import get_db + + before = set(get_db().concepts.distinct("concept_id")) + for _ in range(2): + result = executor.run( + definition, + environment=WorkflowEnvironment( + llm_client=None, + user_namespace="#V#test_user", + ), + data={"presentation_concept_id": existing_id}, + ) + assert result.completed, (result.error, result.data.get("last_action_error")) + assert result.data["presentation_concept_id"] == existing_id + assert result.data["talk_representation_verified"] is True + assert ( + result.data["materialisation_report"]["created_presentation_concept"] + is False + ) + assert set(get_db().concepts.distinct("concept_id")) == before + + +def test_direct_talk_accepts_title_without_optional_meeting_fields(_reset_mock_db: Any): + bootstrap_canonical_talk_representation_workflows() + definition = load_workflow_definition_from_vontology( + TALK_REPRESENTATION_WORKFLOW_ID + ) + result = WorkflowExecutor( + registry=registry_factory.build_durable_action_registry(), + max_transitions=20, + ).run( + definition, + environment=WorkflowEnvironment(llm_client=None, user_namespace="#V#test_user"), + data={"title": "Sparse new presentation"}, + ) + assert result.completed, (result.error, result.data.get("last_action_error")) + assert result.data["talk_representation_verified"] is True + + +@pytest.mark.parametrize("title", [None, "Workshop programme"]) +def test_batch_ids_are_not_silently_converted_to_a_new_presentation( + _reset_mock_db: Any, + title: str | None, +): + bootstrap_canonical_talk_representation_workflows() + from src.backend.db.mongo_client import get_db + + before = set(get_db().concepts.distinct("concept_id")) + definition = load_workflow_definition_from_vontology( + TALK_REPRESENTATION_WORKFLOW_ID + ) + data = {"existing_presentation_concept_ids": ["#V#talk_one", "#V#talk_two"]} + if title: + data["title"] = title + result = WorkflowExecutor( + registry=registry_factory.build_durable_action_registry(), + max_transitions=20, + ).run( + definition, + environment=WorkflowEnvironment(llm_client=None, user_namespace="#V#test_user"), + data=data, + ) + assert not result.data.get("talk_representation_materialised") + assert "talk_representation_requires_single_presentation" in str( + result.data.get("last_action_error") + ) + assert set(get_db().concepts.distinct("concept_id")) == before + + +def test_reviewed_v1_migrates_optional_inputs_without_forced_publication( + _reset_mock_db: Any, + tmp_path, + monkeypatch, +): + import json + from src.backend.services import ( + talk_representation_workflow_vontology_service as service, + ) + + path = service._REPO_SEED_ASSET_PATH + legacy = json.loads(path.read_text()) + legacy["seed_version"] = "1" + legacy.pop("known_legacy_authority_payload_sha256_by_seed_version") + for mapping in legacy["workflows"][0]["publication_spec"]["steps"][0][ + "context_input_mapping_specs" + ]: + mapping["required"] = True + old_path = tmp_path / "legacy.json" + old_path.write_text(json.dumps(legacy)) + monkeypatch.setattr(service, "_REPO_SEED_ASSET_PATH", old_path) + service.bootstrap_canonical_talk_representation_workflows() + old = load_workflow_definition_from_vontology(TALK_REPRESENTATION_WORKFLOW_ID) + assert ( + "presentation_concept_id" + in old.states[old.initial_state].metadata["reads_context_keys"] + ) + monkeypatch.setattr(service, "_REPO_SEED_ASSET_PATH", path) + report = service.bootstrap_canonical_talk_representation_workflows() + assert report["publication"]["counts"]["errors"] == 0 + assert report["publication"]["counts"]["workflows_published"] == 1 + current = load_workflow_definition_from_vontology(TALK_REPRESENTATION_WORKFLOW_ID) + assert not current.states[current.initial_state].metadata.get("reads_context_keys") + repeat = service.bootstrap_canonical_talk_representation_workflows() + assert repeat["publication"]["skipped"] is True