diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01ee10491..7cbe3697f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -370,6 +370,21 @@ than exposing the generic PROV-O `Person` class as business context. the list badge and popup meta show `Voice of Customer` / `Public` instead of raw codes. +`POST /api/posts/{post_id}/voice-assignments` lets a `post_admin` add one +governed atomic Voice with an explicit truth state and an ABAC-visible evidence +Post. The server creates the normalized PROV-O derivation and assignment in one +transaction; clients never submit an internal assertion id, and this route +cannot replace the imported primary Voice. +The bounded ontology response carries a visible Voice assignment's evidence +Post id alongside its exact-value row. The exact-value table therefore offers +separate carrying-Post and derivation-evidence actions; hidden evidence removes +the additional assignment before serialization rather than leaking its id or +showing a fabricated count. +The live Post popup exposes the route only to its existing `post_admin` +permission result and only outside knowledge-cutoff views. Its form excludes +already assigned catalog options, requires an explicit truth state, and uses +the open Post as evidence so the UI never asks for an internal Post id. + `GET /api/posts/{post_id}/voc-evidence` returns the `common_lookup_value` label for the post's `voc_type_code` plus the sentences in the post body that name a counterparty or affiliated diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b1fc939..6e57455bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ All notable changes to this project are documented here. Format follows ### Added +- Normalized Voice-of-X composition persistence (ADR 0256): every imported + primary voice is mirrored into `source_post_voice`; each additional voice + requires its own PROV-O assertion and truth status. Compound lookup codes, + keyword inference, confidence thresholds, and invented weights remain out of + the contract; the ontology publishes qualified `VoiceAssignment` resources, + authorized post responses expose the assignments, filters match any assigned + voice, post cards display combined labels, and the authorized neighborhood + carries the assignments through SHACL-validated JSON-LD, exact-value CSV, + and source-post evidence navigation. Storybook includes the combined primary + plus additional Voice evidence state for desktop and narrow-screen audit. + Board filters match additional as well as primary voices, labels retain the + active locale, and cutoff reads use assignment-effective time rather than + migration recording time. All twelve governed atomic Voice labels are + translated across the five supported product locales. Ontology neighborhoods + load assignments for every authorized visible Post in one bounded query, + including Person-, Organization-, Team-, and Project-focused exploration. A + governed `post_admin` API creates each additional assignment and its + `prov:wasDerivedFrom` assertion atomically from an ABAC-visible evidence Post; + callers cannot replace the imported primary or supply an assertion UUID. + Post detail lists the primary and evidence-connected perspectives separately, + with localized provenance cues and knowledge-cutoff filtering. A + permission- and cutoff-gated popup form connects an unassigned Voice with an + explicit truth state and the open Post as evidence; localized success/error + feedback and responsive Storybook scenes cover the write interaction. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and @@ -62,7 +86,6 @@ All notable changes to this project are documented here. Format follows deterministic application read model (`lineageweave.worker_function_taxonomy`) exposes fail-closed lookups; ranks are scale positions and are never used as weights. -======= - Global Ask accepts an optional UTC `knowledge_cutoff`. Dated questions retrieve only posts available by that clock, cite the retained `source_post_revision`, and name when a historical body was not kept. @@ -2462,4 +2485,4 @@ All notable changes to this project are documented here. Format follows dependency. - Synthetic-only demo dataset (`lineageweave/fixtures.py`). - `docs/lineage-bi-research-notes.md`: the literature this design is - grounded in, APA 7th. \ No newline at end of file + grounded in, APA 7th. diff --git a/backend/app/main.py b/backend/app/main.py index 91517e9eb..08a8391f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -167,6 +167,10 @@ rebuild_period_reports, ) from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock +from backend.app.source_post_voice_ingestion import ( + PrimaryVoiceAssignmentError, + persist_additional_voice_assignment, +) from lineageweave.adjudication_client import ( AdjudicationClientError, ContextualOrchestratorAdjudicationClient, @@ -591,11 +595,15 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) project_evidence = post.get("project_evidence") or [] if isinstance(project_evidence, str): project_evidence = json.loads(project_evidence) + voice_types = post.get("voice_types") or [] + if isinstance(voice_types, str): + voice_types = json.loads(voice_types) return { "post_id": str(post["post_id"]), "post_title": post["post_title"], "voc_type_code": voc, "voc_type_label": resolved.get(voc, voc), + "voice_types": voice_types, "visibility_code": visibility, "visibility_label": resolved.get(visibility, visibility), "source_stage_code": post.get("source_stage_code"), @@ -695,6 +703,43 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor return await labels_for_codes(conn, codes) +async def _load_post_voice_types( + conn: asyncpg.Connection, + post_id: str, + effective_cutoff: datetime | None = None, +) -> list[dict[str, Any]]: + """Return qualified Voice-of-X associations without exposing assertion ids.""" + rows = await conn.fetch( + """ + select voice.voice_type_code, lookup.lookup_label, voice.is_primary, + voice.truth_status_code, + voice.provenance_assertion_id is not null as evidence_available + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = $1 + and (($2::timestamptz is null and voice.effective_to is null) + or ($2::timestamptz is not null + and voice.effective_from <= $2 + and (voice.effective_to is null or $2 < voice.effective_to))) + order by voice.is_primary desc, lookup.display_order, voice.voice_type_code + """, + post_id, + effective_cutoff, + ) + return [ + { + "code": row["voice_type_code"], + "label": row["lookup_label"], + "is_primary": row["is_primary"], + "truth_status_code": row["truth_status_code"], + "evidence_available": row["evidence_available"], + } + for row in rows + ] + + async def _post_filter_options( conn: asyncpg.Connection, corporate_entity_ids: frozenset[str], @@ -706,9 +751,11 @@ async def _post_filter_options( coalesce(lookup.lookup_label, option.code) as label, coalesce(lookup.display_order, 2147483647) as display_order from source_post post + left join source_post_voice voice + on voice.post_id = post.post_id and voice.effective_to is null cross join lateral ( values ('post_visibility', post.visibility_code), - ('voc_type', post.voc_type_code) + ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code)) ) as option(lookup_category, code) left join common_lookup_value lookup on lookup.lookup_category = option.lookup_category @@ -1346,6 +1393,17 @@ async def list_posts( voc_type_options, visibility_options = await _post_filter_options( conn, account.corporate_entity_ids, account.process_unit_ids ) + voice_type_catalog = [ + {"code": row["lookup_code"], "label": row["lookup_label"]} + for row in await conn.fetch( + """ + select lookup_code, lookup_label + from common_lookup_value + where lookup_category = 'voc_type' + order by display_order, lookup_code + """ + ) + ] body_search_ids: list[str] = [] if search_term: # Safe SQL: search SQL is a closed schema query; search_term is bound through $1. @@ -1520,7 +1578,12 @@ async def list_posts( or affiliated.corporate_entity_code ilike '%' || $1 || '%') ) ) - and ($3::text[] is null or post.voc_type_code = any($3::text[])) + and ($3::text[] is null or exists ( + select 1 from source_post_voice voice_filter + where voice_filter.post_id = post.post_id + and voice_filter.effective_to is null + and voice_filter.voice_type_code = any($3::text[]) + )) and ($4::text is null or post.visibility_code = $4) order by search_priority asc, @@ -1549,7 +1612,8 @@ async def list_posts( else btrim(left(source_post_search_text(post.post_body), 420)) end as post_body_excerpt, char_length(coalesce(post.post_body, '')) > 420 as post_body_truncated, - coalesce(projects.project_evidence, '[]'::json) as project_evidence + coalesce(projects.project_evidence, '[]'::json) as project_evidence, + coalesce(voices.voice_types, '[]'::json) as voice_types from page join source_post post on post.post_id = page.post_id left join lateral ( @@ -1576,6 +1640,25 @@ async def list_posts( limit 5 ) project ) projects on true + left join lateral ( + select json_agg( + json_build_object( + 'code', voice.voice_type_code, + 'label', lookup.lookup_label, + 'is_primary', voice.is_primary, + 'truth_status_code', voice.truth_status_code, + 'evidence_available', voice.provenance_assertion_id is not null + ) + order by voice.is_primary desc, lookup.display_order, + voice.voice_type_code + ) as voice_types + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = page.post_id + and voice.effective_to is null + ) voices on true order by case when $1::text is not null then page.search_priority end asc, case @@ -1606,6 +1689,7 @@ async def list_posts( "limit": limit, "offset": offset, "voc_type_options": voc_type_options, + "voice_type_catalog": voice_type_catalog, "visibility_options": visibility_options, } @@ -1659,6 +1743,7 @@ async def read_post( project_evidence = await _load_project_evidence( conn, post_id, row["source_project_code"], row["source_project_name"] ) + voice_types = await _load_post_voice_types(conn, post_id, as_of_clock) if as_of_clock is None: occupational_construct_assertions = ( await load_occupational_construct_assertions(conn, post_id) @@ -1683,6 +1768,7 @@ async def read_post( **_serialize_post(row, labels), "post_body": row["post_body"], "project_evidence": project_evidence, + "voice_types": voice_types, "occupational_construct_assertions": occupational_construct_assertions, } if known_at is not None: @@ -1690,6 +1776,64 @@ async def read_post( return payload +class CreatePostVoiceAssignmentRequest(BaseModel): + """Evidence and governed truth state for one additional Voice assignment.""" + + voice_type_code: str + truth_status_code: str + evidence_post_id: UUID + + +@app.post( + "/api/posts/{post_id}/voice-assignments", + status_code=status.HTTP_201_CREATED, +) +async def create_post_voice_assignment( + post_id: str, + request: CreatePostVoiceAssignmentRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Attach one additional Voice using an authorized evidence Post.""" + _require_post_admin(account) + await _load_visible_post(post_id, account, pool) + evidence_post_id = str(request.evidence_post_id) + if evidence_post_id != post_id: + await _load_visible_post(evidence_post_id, account, pool) + async with pool.acquire() as conn: + try: + await persist_additional_voice_assignment( + conn, + post_id=post_id, + voice_type_code=request.voice_type_code, + truth_status_code=request.truth_status_code, + evidence_post_id=evidence_post_id, + ) + except PrimaryVoiceAssignmentError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + except ( + asyncpg.CheckViolationError, + asyncpg.ForeignKeyViolationError, + ) as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "voice_type_code and truth_status_code must use governed lookup values", + ) from exc + assignments = await _load_post_voice_types(conn, post_id) + assignment = next( + item for item in assignments if item["code"] == request.voice_type_code + ) + await publish_activity_event( + valkey, + post_id, + "voice_assignment_added", + account.user_account_id, + "Additional Voice evidence connected", + ) + return assignment + + @app.get("/api/posts/{post_id}/content") async def read_post_content( post_id: str, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index cd3859320..f64987ec4 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -18,8 +18,8 @@ visible_team_mention_post_ids, ) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from lineageweave.post_summary import parse_project_candidate_node_id from lineageweave.knowledge_graph import ( + EDGE_MENTION_PROJECT, NODE_CORPORATE_ENTITY, NODE_OCCUPATIONAL_CONSTRUCT, NODE_PERSON, @@ -29,30 +29,33 @@ EDGE_MENTION_PROJECT, EDGE_SUPPORTS_OCCUPATIONAL_CONSTRUCT, ) +from lineageweave.ontology import iri_for_lookup_code from lineageweave.ontology_neighborhood import ( DEFAULT_MAXIMUM_DEPTH, DEFAULT_MAXIMUM_EDGES, DEFAULT_MAXIMUM_NODES, HARD_MAXIMUM_EDGES, KNOWN_NODE_TYPES, + PROPERTY_SKOS_BROADER, NeighborhoodFact, - OntologyNodeMetadata, OntologyNeighborhood, OntologyNeighborhoodError, - PROPERTY_SKOS_BROADER, + OntologyNodeMetadata, + OntologyVoiceAssignment, assemble_ontology_neighborhood, fact_from_knowledge_graph_edge, skos_broader_fact, ) from lineageweave.ontology_source_cursor import ( - OntologySourceKey, - OntologySourceCursor, SOURCE_CURSOR_PREFIX, + OntologySourceCursor, + OntologySourceKey, mint_source_cursor, source_cursor_secret_from_env, source_key_from_row, verify_source_cursor, ) +from lineageweave.post_summary import parse_project_candidate_node_id NOT_FOUND_NEIGHBORHOOD_CODES = frozenset( {"focus_hidden", "focus_not_visible", "unknown_node_type", "dangling_endpoint"} @@ -854,11 +857,95 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any } for edge in neighborhood.edges ], + "voice_assignments": [ + { + "post_id": assignment.post_id, + "voice_type_code": assignment.voice_type_code, + "voice_type_iri": assignment.voice_type_iri, + "voice_type_label": assignment.voice_type_label, + "is_primary": assignment.is_primary, + "truth_status_code": assignment.truth_status_code, + "recorded_at": assignment.recorded_at.isoformat(), + "provenance_reference": assignment.provenance_reference, + "evidence_post_id": assignment.evidence_post_id, + } + for assignment in neighborhood.voice_assignments + ], "exact_value_rows": list(neighborhood.exact_value_rows()), "jsonld": neighborhood.jsonld_document(), } +async def _load_voice_assignments( + conn: asyncpg.Connection, + post_ids: Sequence[str], + *, + knowledge_cutoff: datetime | None, + snapshot_at: datetime, +) -> tuple[OntologyVoiceAssignment, ...]: + """Load qualified voices only for posts admitted to the visible neighborhood.""" + if not post_ids: + return () + rows = await conn.fetch( + """ + select voice.post_id, voice.voice_type_code, lookup.lookup_label, voice.is_primary, + voice.truth_status_code, voice.recorded_at, + case when evidence.node_id = any($1::uuid[]) then evidence.node_id end + as evidence_post_id + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + left join provenance_assertion assertion + on assertion.assertion_id = voice.provenance_assertion_id + left join provenance_resource_binding evidence + on evidence.resource_id = assertion.object_resource_id + and evidence.node_type_code = 'node_post' + where voice.post_id = any($1::uuid[]) + and (voice.is_primary or evidence.node_id = any($1::uuid[])) + and (($2::timestamptz is null and voice.effective_to is null) + or ($2::timestamptz is not null + and voice.effective_from <= $2 + and (voice.effective_to is null or $2 < voice.effective_to))) + and voice.recorded_at <= $3::timestamptz + order by voice.post_id, voice.is_primary desc, + lookup.display_order, voice.voice_type_code + """, + list(post_ids), + knowledge_cutoff, + snapshot_at, + ) + assignments: list[OntologyVoiceAssignment] = [] + for row in rows: + voice_type_iri = iri_for_lookup_code(row["voice_type_code"]) + if voice_type_iri is None: + raise OntologyNeighborhoodError( + "unknown_property", "voice type has no published ontology term" + ) + assignments.append( + OntologyVoiceAssignment( + post_id=str(row["post_id"]), + voice_type_code=row["voice_type_code"], + voice_type_iri=voice_type_iri, + voice_type_label=row["lookup_label"], + is_primary=row["is_primary"], + truth_status_code=row["truth_status_code"], + recorded_at=row["recorded_at"], + provenance_reference=( + "Evidence-backed additional voice" + if not row["is_primary"] + else "Imported primary voice" + ), + evidence_post_id=( + str(row["evidence_post_id"]) + if row["evidence_post_id"] is not None + else None + ), + ) + ) + return tuple(assignments) + + async def visible_ontology_neighborhood( conn: asyncpg.Connection, *, @@ -1173,6 +1260,21 @@ async def visible_ontology_neighborhood( cursor=assembler_cursor, source_truncated=source_truncated, ) + visible_post_ids = tuple( + node.node_id + for node in getattr(neighborhood, "nodes", ()) + if node.node_type_code == NODE_POST + ) + if visible_post_ids: + neighborhood = replace( + neighborhood, + voice_assignments=await _load_voice_assignments( + conn, + visible_post_ids, + knowledge_cutoff=knowledge_cutoff, + snapshot_at=snapshot_at, + ), + ) last_source_key = None neighborhood_edges = getattr(neighborhood, "edges", ()) for edge in reversed(neighborhood_edges): diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py new file mode 100644 index 000000000..609a71f4f --- /dev/null +++ b/backend/app/source_post_voice_ingestion.py @@ -0,0 +1,166 @@ +"""Persist evidence-bearing additional Voice assignments (ADR 0256).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import asyncpg + +from lineageweave.knowledge_graph import NODE_POST +from lineageweave.ontology import LW, ontology_node_iri + + +class PrimaryVoiceAssignmentError(ValueError): + """Raised when the additional-voice path targets the imported primary.""" + + +async def _post_resource_id(conn: asyncpg.Connection, post_id: str) -> str: + """Return the bound PROV Entity resource for one evidence post.""" + existing = await conn.fetchval( + """ + select resource_id + from provenance_resource_binding + where node_type_code = 'node_post' + and node_id = $1::uuid + """, + post_id, + ) + if existing is not None: + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + existing, + ) + return str(existing) + resource_id = await conn.fetchval( + """ + insert into provenance_resource (resource_iri, resource_label) + values ($1, 'Authorized Voice evidence post') + on conflict (resource_iri) do update + set resource_label = coalesce( + provenance_resource.resource_label, + excluded.resource_label + ) + returning resource_id + """, + ontology_node_iri(NODE_POST, post_id), + ) + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + resource_id, + ) + await conn.execute( + """ + insert into provenance_resource_binding (resource_id, node_type_code, node_id) + values ($1::uuid, 'node_post', $2::uuid) + on conflict do nothing + """, + resource_id, + post_id, + ) + bound = await conn.fetchval( + """ + select resource_id + from provenance_resource_binding + where node_type_code = 'node_post' + and node_id = $1::uuid + """, + post_id, + ) + if bound is None: + raise RuntimeError("evidence post provenance binding was not persisted") + return str(bound) + + +async def persist_additional_voice_assignment( + conn: asyncpg.Connection, + *, + post_id: str, + voice_type_code: str, + truth_status_code: str, + evidence_post_id: str, +) -> None: + """Atomically bind one additional Voice to an authorized evidence post.""" + assignment_iri = str(LW[f"voice-assignment/{post_id}/{voice_type_code}"]) + async with conn.transaction(): + evidence_resource_id = await _post_resource_id(conn, evidence_post_id) + assignment_resource_id = await conn.fetchval( + """ + insert into provenance_resource (resource_iri, resource_label) + values ($1, 'Qualified Voice assignment') + on conflict (resource_iri) do update + set resource_label = coalesce( + provenance_resource.resource_label, + excluded.resource_label + ) + returning resource_id + """, + assignment_iri, + ) + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + assignment_resource_id, + ) + assertion_id = await conn.fetchval( + """ + insert into provenance_assertion + (subject_resource_id, relation_code, object_resource_id) + values ($1::uuid, 'prov_was_derived_from', $2::uuid) + on conflict do nothing + returning assertion_id + """, + assignment_resource_id, + evidence_resource_id, + ) + if assertion_id is None: + assertion_id = await conn.fetchval( + """ + select assertion_id + from provenance_assertion + where subject_resource_id = $1::uuid + and relation_code = 'prov_was_derived_from' + and object_resource_id = $2::uuid + and bundle_resource_id is null + """, + assignment_resource_id, + evidence_resource_id, + ) + if assertion_id is None: + raise RuntimeError("Voice evidence derivation was not persisted") + stored = await conn.fetchrow( + """ + insert into source_post_voice + (post_id, voice_type_code, is_primary, truth_status_code, + provenance_assertion_id, effective_from, recorded_at) + values ($1::uuid, $2, false, $3, $4::uuid, now(), now()) + on conflict (post_id, voice_type_code) where effective_to is null do update + set truth_status_code = excluded.truth_status_code, + provenance_assertion_id = excluded.provenance_assertion_id, + recorded_at = now() + where not source_post_voice.is_primary + returning voice_type_code + """, + post_id, + voice_type_code, + truth_status_code, + assertion_id, + ) + if stored is None: + raise PrimaryVoiceAssignmentError( + "the imported primary Voice cannot be changed through the additional-voice path" + ) + + +__all__ = ["PrimaryVoiceAssignmentError", "persist_additional_voice_assignment"] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 61b1dfd41..32680f24c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -16,8 +16,10 @@ import math import os import uuid +from concurrent.futures import ThreadPoolExecutor from contextlib import closing from pathlib import Path +from threading import Barrier from types import SimpleNamespace import asyncpg @@ -37,6 +39,11 @@ _VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0") _REALM = "lineageweave-demo" _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" +_PROVENANCE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0017_prov_o_standard_relations.sql" +) _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" _RECONSTRUCTION_MIGRATION = ( @@ -87,6 +94,9 @@ _SOURCE_ORG_NAMED_HINTS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0039_source_org_named_hints.sql" ) +_VOC_VOCABULARY_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0042_voc_type_vocabulary.sql" +) _MEMBER_LOCALE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0044_member_locale_preference.sql" ) @@ -237,7 +247,19 @@ / "0183_source_post_event_occurred_at.sql" ) _ONTOLOGY_TRUTH_STATUS_MIGRATION = ( - Path(__file__).resolve().parents[2] / "migrations" / "0175_ontology_truth_status.sql" + Path(__file__).resolve().parents[2] + / "migrations" + / "0175_ontology_truth_status.sql" +) +_VOICE_TAXONOMY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0235_voice_of_x_post_taxonomy.sql" +) +_VOICE_ASSIGNMENT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0237_source_post_voice_combination.sql" ) _OCCUPATIONAL_CONSTRUCT_MIGRATION = ( Path(__file__).resolve().parents[2] @@ -334,6 +356,7 @@ def seeded_db(demo_analyst_token): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_PROVENANCE_MIGRATION.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) @@ -356,6 +379,7 @@ def seeded_db(demo_analyst_token): (Path(__file__).resolve().parents[2] / "migrations" / "0040_post_summary_contract.sql") .read_text() ) + cur.execute(_VOC_VOCABULARY_MIGRATION.read_text()) cur.execute(_MEMBER_LOCALE_MIGRATION.read_text()) cur.execute(_IMAGE_REGION_MIGRATION.read_text()) cur.execute(_POST_CONTENT_STRUCTURE_MIGRATION.read_text()) @@ -401,6 +425,17 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) + # psycopg2 sends one multi-statement execute as one transaction, + # which PostgreSQL correctly rejects for CREATE INDEX CONCURRENTLY. + migration_sql = "\n".join( + line + for line in _GLOBAL_ASK_EVIDENCE_SEARCH_MIGRATION.read_text().splitlines() + if not line.lstrip().startswith("--") + ) + for statement in migration_sql.split(";"): + sql = statement.strip() + if sql: + cur.execute(sql) for statement in _GLOBAL_ASK_EVIDENCE_SEARCH_MIGRATION.read_text().split(";\n\n"): if statement.strip(): cur.execute(statement + ";") @@ -415,6 +450,9 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_ONTOLOGY_TRUTH_STATUS_MIGRATION.read_text()) + cur.execute(_VOICE_TAXONOMY_MIGRATION.read_text()) + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " @@ -423,7 +461,6 @@ def seeded_db(demo_analyst_token): "('corporate_entity_level', 'plant', 'Plant'), " "('post_visibility', 'public', 'Public'), " "('post_visibility', 'private', 'Private'), " - "('voc_type', 'voc', 'Voice of Customer'), " "('permission', 'post_read', 'Read posts'), " "('person_side', 'our_side', 'Our side'), " "('person_side', 'counterparty', 'Counterparty'), " @@ -1873,6 +1910,20 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" assert {option["code"] for option in payload["voc_type_options"]} == {"voc"} + assert {option["code"] for option in payload["voice_type_catalog"]} == { + "voc", + "voe", + "vob", + "voi", + "vop", + "vops", + "vos", + "vocc", + "voco", + "vom", + "vor", + "voso", + } assert {option["code"] for option in payload["visibility_options"]} == {"public", "private"} assert next(option for option in payload["visibility_options"] if option["code"] == "public")["label"] == "Public" @@ -4761,6 +4812,138 @@ def _grant_post_admin(dsn: str) -> None: admin_conn.close() +def test_create_voice_assignment_persists_authorized_prov_o_evidence( + client, demo_analyst_token, seeded_db +) -> None: + """A real OIDC admin write retains its governed Voice and evidence Post.""" + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + endpoint = f"/api/posts/{seeded_db['own_private_post_id']}/voice-assignments" + payload = { + "voice_type_code": "vos", + "truth_status_code": "truth_observed", + "evidence_post_id": seeded_db["public_post_id"], + } + + assert client.post(endpoint, json=payload, headers=headers).status_code == 403 + _grant_post_admin(seeded_db["dsn"]) + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "insert into provenance_resource (resource_iri, resource_label) " + "values (%s, 'Synthetic evidence Post') returning resource_id", + (f"https://example.test/posts/{seeded_db['public_post_id']}",), + ) + resource_id = cur.fetchone()[0] + cur.execute( + "insert into provenance_resource_type (resource_id, class_code) " + "values (%s, 'prov_entity')", + (resource_id,), + ) + cur.execute( + "insert into provenance_resource_binding " + "(resource_id, node_type_code, node_id) values (%s, 'node_post', %s)", + (resource_id, seeded_db["public_post_id"]), + ) + conn.commit() + + response = client.post(endpoint, json=payload, headers=headers) + assert response.status_code == 201, response.text + assert response.json() == { + "code": "vos", + "label": "Voice of Supplier", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + } + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + select voice.voice_type_code, voice.is_primary, + assertion.relation_code, binding.node_id + from source_post_voice voice + join provenance_assertion assertion + on assertion.assertion_id = voice.provenance_assertion_id + join provenance_resource_binding binding + on binding.resource_id = assertion.object_resource_id + where voice.post_id = %s and voice.voice_type_code = 'vos' + """, + (seeded_db["own_private_post_id"],), + ) + stored = cur.fetchone() + assert stored == ( + "vos", + False, + "prov_was_derived_from", + seeded_db["public_post_id"], + ) + cur.execute( + "select voice_type_code from source_post_voice " + "where post_id = %s and is_primary", + (seeded_db["own_private_post_id"],), + ) + assert cur.fetchone() == ("voc",) + + +def test_primary_voice_history_survives_concurrent_changes_and_cutoff_reads( + client, demo_analyst_token, seeded_db +) -> None: + """Concurrent A→B→C→A changes retain one non-overlapping primary timeline.""" + post_id = seeded_db["own_private_post_id"] + barrier = Barrier(2) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + + def update_voice(code: str) -> None: + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + barrier.wait() + cur.execute( + "update source_post set voc_type_code = %s where post_id = %s", + (code, post_id), + ) + conn.commit() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(update_voice, code) for code in ("vop", "voe")] + for future in futures: + future.result() + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "update source_post set voc_type_code = 'voc' where post_id = %s", + (post_id,), + ) + conn.commit() + cur.execute( + "select voice_type_code, effective_from, effective_to " + "from source_post_voice where post_id = %s and is_primary " + "order by effective_from, voice_assignment_id", + (post_id,), + ) + history = cur.fetchall() + + assert len(history) == 4 + assert history[0][0] == "voc" + assert history[-1][0] == "voc" + assert sum(effective_to is None for _, _, effective_to in history) == 1 + assert all( + history[index][2] == history[index + 1][1] + for index in range(len(history) - 1) + ) + for voice_type_code, effective_from, _effective_to in history: + response = client.get( + f"/api/posts/{post_id}", + params={"as_of": effective_from.isoformat()}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + primaries = [ + voice for voice in response.json()["voice_types"] if voice["is_primary"] + ] + assert [voice["code"] for voice in primaries] == [voice_type_code] + + def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/tickets", diff --git a/docs/adr/0256-evidence-bearing-voice-combinations.md b/docs/adr/0256-evidence-bearing-voice-combinations.md new file mode 100644 index 000000000..79279130d --- /dev/null +++ b/docs/adr/0256-evidence-bearing-voice-combinations.md @@ -0,0 +1,166 @@ +# ADR 0256: Evidence-bearing Voice-of-X combinations + +## Status + +Accepted (2026-08-27). Extends ADR 0246 without replacing the imported +`source_post.voc_type_code` contract. + +## Context + +A record can carry more than one stakeholder perspective: for example, a +customer-authored record can preserve a downstream user's statement, or an +employee can report a process-generated signal. Encoding every pair or larger +combination as a new lookup code creates an unbounded Cartesian vocabulary and +loses the evidence for each component. + +No cited standard defines a finite, universal list of stakeholder +combinations. ISO stakeholder guidance explicitly allows the relevant +categories to vary by subject. ISO 26000 and AA1000SES instead require ongoing, +context-sensitive stakeholder identification and engagement. Mitchell, Agle, +and Wood (1997) likewise derive stakeholder salience from combinations of +attributes rather than from one exhaustive industry-role list. + +## Decision + +Represent composition as rows in normalized `source_post_voice`, not as +compound lookup codes. + +- The existing `source_post.voc_type_code` remains the authoritative imported + primary voice. A trigger mirrors it into exactly one primary association so + existing import, filtering, and lineage behavior remains stable. +- An additional voice uses another existing `voc_type` code and must reference + a normalized `provenance_assertion`. Missing evidence therefore cannot be + persisted as a positive association. +- Each association interval has an immutable assignment identifier. Partial + unique indexes permit only one current row for `(post_id, voice_type_code)` + and one current primary while allowing closed historical intervals. +- `effective_from` records when an assignment became applicable. The initial + imported primary starts at the source post's `created_at`; a later imported + primary change and every added evidence-bearing voice start when recorded. + `effective_to` closes a replaced primary as a half-open interval. Knowledge- + cutoff reads select the interval containing the cutoff, so A → B → A changes + retain all three states without presenting two primaries at one instant. +- A database trigger verifies that every association code belongs to the + `voc_type` lookup category and every truth code belongs to + `ontology_truth_status`; + the global lookup-code foreign key alone does not establish either category + boundary. Promoting an existing additional voice to the imported primary + resets it to observed source evidence and removes the now-unneeded derived + assertion reference. +- Voice remains distinct from counterparty relationship, actor role, topic, + channel, lifecycle, and stakeholder-salience attributes. No inference, + keyword rule, confidence threshold, or weight converts those dimensions into + a voice. +- The public ontology represents each row as a qualified `VoiceAssignment` + linked from its post. Each assignment names one atomic SKOS voice concept; + additional assignments retain evidence through `prov:wasDerivedFrom`. +- Authorized post list/detail responses expose ordered voice assignments with + labels, truth state, and evidence availability but never internal assertion + identifiers. Filters match any associated voice, and repeated post cards show + the combined labels. A knowledge-cutoff detail read includes only assignments + effective by that cutoff; the popup lists the imported and evidence-connected + perspectives separately instead of flattening them into a compound label. +- A `post_admin` may add an additional assignment by naming an ABAC-visible + evidence Post, an atomic Voice code, and a governed truth state. The API does + not accept a caller-supplied assertion identifier: one transaction binds the + evidence Post as a PROV Entity, records `prov:wasDerivedFrom`, and upserts the + assignment. It cannot replace or demote the imported primary Voice. +- In the live Post popup, a `post_admin` may choose one unassigned atomic Voice + and one explicit truth state. The open Post is submitted as its own evidence, + which covers a single record that contains several perspectives without + asking the user for an internal identifier. Historical-cutoff views and + accounts without `post_admin` do not expose this write control. +- The authorized ontology neighborhood projects each association as a + qualified assignment in JSON-LD and the exact-value CSV. SHACL requires its + atomic voice concept, primary flag, and source-post evidence. The exact-value + table opens the carrying Post and, separately, the already-authorized + derivation-evidence Post. It does not invent a graph edge or expose an + internal assertion identifier. A single bounded query loads + assignments for every authorized Post in the neighborhood, regardless of + whether the focus is a Post, Person, Organization, Team, or Project. An + additional assignment whose evidence Post is outside that authorized node + set is omitted as a whole, keeping the JSON-LD conformant with the SHACL + evidence minimum without disclosing or substituting hidden evidence. When + bounded pages are accumulated, properties for the same JSON-LD subject are + merged and multi-value Voice relations are unioned instead of one page + replacing another. + +## Data model + +```mermaid +classDiagram + class SourcePost { + uuid post_id + text voc_type_code + } + class SourcePostVoice { + uuid voice_assignment_id + uuid post_id + text voice_type_code + boolean is_primary + text truth_status_code + uuid provenance_assertion_id + timestamptz effective_from + timestamptz effective_to + timestamptz recorded_at + } + class LookupValue { + text lookup_code + text lookup_category + } + class ProvenanceAssertion { + uuid assertion_id + } + SourcePost "1" --> "1..*" SourcePostVoice + LookupValue "1" --> "0..*" SourcePostVoice + ProvenanceAssertion "0..1" --> "0..*" SourcePostVoice +``` + +```mermaid +sequenceDiagram + actor Admin + participant API + participant ABAC + participant PostgreSQL + Admin->>API: Add atomic Voice + truth + evidence Post + API->>ABAC: Authorize target and evidence Posts + ABAC-->>API: Both visible + API->>PostgreSQL: Atomic PROV derivation + assignment upsert + PostgreSQL-->>API: Evidence-bearing assignment +``` + +## Consequences + +Migration 0237 is replay-safe, backfills one primary association per existing +post, closes rather than deletes a replaced primary, synchronizes later +inserts and primary-voice changes, and adds a voice-first index for bounded +filtering. It introduces no new Voice-of-X category and stores no source +content or identifying evidence in repository artifacts. + +The repository candidate projects authorized combinations through JSON-LD, +SHACL, CSV, and separate carrying-Post/evidence navigation and includes the governed admin API and +Post-popup authoring path above. Synthetic Storybook desktop/mobile scenes +verify the focused evidence action, contained horizontally scrollable +exact-value table, explicit unassigned-Voice/truth selections, success state, +and 44-pixel touch controls. A synthetic real-OIDC integration on 2026-08-27 +proved the permission denial, authorized write, normalized PROV-O derivation, +additional-Voice row, and unchanged imported primary against PostgreSQL. A +release claim still requires protected-main delivery evidence. + +## References + +AccountAbility. (2015). *AA1000 stakeholder engagement standard*. +https://www.accountability.org/standards/aa1000-stakeholder-engagement + +International Organization for Standardization. (2010). *Guidance on social +responsibility* (ISO Standard No. 26000:2010). +https://www.iso.org/standard/42546.html + +International Organization for Standardization. (2023, December 19). +*Global Directory stakeholder categories*. +https://helpdesk-docs.iso.org/article/331-gd-stakeholders-categories + +Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of +stakeholder identification and salience: Defining the principle of who and +what really counts. *Academy of Management Review, 22*(4), 853–886. +https://doi.org/10.5465/amr.1997.9711022105 diff --git a/docs/adr/README.md b/docs/adr/README.md index 1d7102a2b..de21b1028 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,7 +16,7 @@ decision from them. | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | -| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md) | +| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0256](0256-evidence-bearing-voice-combinations.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index 855b43b95..2a5b6fbab 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -167,6 +167,34 @@ sh:datatype xsd:string ; ] . +:VoiceAssignmentShape a sh:NodeShape ; + rdfs:label "Voice assignment shape" ; + sh:targetClass :VoiceAssignment ; + sh:property [ + sh:path :assignedVoiceType ; + sh:name "assigned voice type" ; + sh:description "Every qualified assignment names exactly one governed atomic Voice-of-X concept." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class ; + ] ; + sh:property [ + sh:path :primaryVoiceAssignment ; + sh:name "primary voice assignment" ; + sh:description "The imported-primary marker is explicit and single-valued." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:boolean ; + ] ; + sh:property [ + sh:path :voiceAssignmentEvidence ; + sh:name "voice assignment evidence" ; + sh:description "Every assignment retains exactly one authorized supporting source post." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class :Post ; + ] . + :OccupationalConstructAssertionShape a sh:NodeShape ; rdfs:label "Occupational construct assertion shape" ; sh:targetClass :OccupationalConstructAssertion ; diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 673ee2d23..067462572 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -362,6 +362,37 @@ rdfs:comment "A process- or system-generated source record." ; :lookupCode "vops" . +# ADR 0256 -- qualified, evidence-bearing combinations. A post links to one +# assignment per atomic voice instead of minting a term for each Cartesian +# combination. Additional assignments use prov:wasDerivedFrom to retain their +# evidence lineage. +:VoiceAssignment a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Voice assignment"@en ; + rdfs:comment "One atomic Voice-of-X classification attached to a post with its own truth and provenance contract."@en . + +:hasVoiceAssignment a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :VoiceAssignment ; + rdfs:label "has voice assignment"@en . + +:assignedVoiceType a owl:ObjectProperty ; + rdfs:domain :VoiceAssignment ; + rdfs:range skos:Concept ; + rdfs:label "assigned voice type"@en . + +:primaryVoiceAssignment a owl:DatatypeProperty ; + rdfs:domain :VoiceAssignment ; + rdfs:range xsd:boolean ; + rdfs:label "primary voice assignment"@en . + +:voiceAssignmentEvidence a owl:ObjectProperty ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + rdfs:domain :VoiceAssignment ; + rdfs:range :Post ; + rdfs:label "voice assignment evidence"@en ; + rdfs:comment "The authorized source post that supports this qualified voice assignment."@en . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 7e1abff1e..df2d15bce 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -49,12 +49,24 @@ edge exposes the same authorized endpoints and evidence through API and UI. governed catalog resolution supplies a stable cross-record identity. - Preserve truth status, valid/system time, provenance, and evidence references. +- Preserve one imported primary Voice and allow a `post_admin` to add any + governed atomic Voice only with an ABAC-visible evidence Post and explicit + truth state; create the normalized PROV-O derivation server-side and never + accept an opaque provenance assertion identifier from the caller. +- Let a `post_admin` connect another perspective from the live Post popup by + choosing an unassigned atomic Voice and an explicit truth state; use the open + authorized Post as evidence and hide the write action on cutoff views. - Validate DB-to-RDF projections with SHACL, including complete reified ProjectMention subject/predicate/object chains. - Keep SKOS broader/narrower distinct from OWL subclass semantics. Acceptance: Turtle, JSON-LD, N-Triples, SHACL, API payloads, persisted IRIs, -and rendered labels agree on term kind, direction, namespace, and provenance. +and rendered labels agree on term kind, direction, namespace, and provenance; +an additional Voice cannot demote the imported primary or cite hidden evidence; +the exact-value table opens the carrying Post and its authorized derivation +evidence as distinct actions; +the authoring form has explicit selections, permission/cutoff gating, retryable +feedback, keyboard labels, and desktop/mobile Storybook evidence. ### PRD-FR-2A — Worker-function taxonomy @@ -315,6 +327,7 @@ A release claim requires one exact protected-main head that proves: - Product/data boundary: ADR 0001, ADR 0089. - Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213. - Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065, + ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0256. ADR 0184, ADR 0207, ADR 0222, ADR 0246. - Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217. - LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5318e4e1b..e09efd117 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,11 @@ # Product & Technical Gap Baseline +> Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was +> `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was +> `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was +> audited at implementation head +> `d5fe4828e9005f0157c308e8ea3c3a590cdf465b`. This candidate and the +> historical evidence below are not protected-main release evidence. > Loop snapshot: 2026-08-27. Protected `main` advanced through the > I/O-Psychology job-family and occupational-classification delivery: PRs > #709 (DOT/FJA worker functions, ADR 0232), #718 (evidence-bound construct @@ -32,6 +38,120 @@ Accordingly, ADR 0246 keeps the controlled vocabulary extensible and refuses keyword inference, defaults, invented weights, or an asserted exhaustive cross-product. +ADR 0256 and migration 0237 now define the persistence contract for +evidence-bearing composition. A post keeps one source-provided +`voc_type_code`, mirrored as its sole primary association, while every +additional voice requires a normalized PROV-O assertion and explicit truth +status. Half-open assignment intervals preserve a backfilled primary at +historical cutoffs, close a replaced primary without deleting it, and permit a +later return to the same Voice. The #717 candidate therefore addresses #748's +A → B → A storage root cause without adding Cartesian-product codes. Protected +delivery and synthetic PostgreSQL concurrency/cutoff evidence remain required. +The remaining acceptance boundary is: + +1. preserve the imported primary voice without reclassification (implemented + in the candidate migration; migration 0237 replayed twice successfully on + an isolated PostgreSQL stack on 2026-08-27, including both primary-sync + triggers; a synthetic real-OIDC PostgreSQL API write also proved that the + imported primary remains unchanged); +2. record each additional voice with its own source/evidence and truth state + (schema-enforced and candidate `post_admin` API plus live Post-popup + authoring implemented; synthetic authenticated PostgreSQL integration + proved denial before permission, the authorized write, and its normalized + PROV-O derivation on 2026-08-27); +3. keeps post voice distinct from named-counterparty relationship, actor role, + topic, channel, lifecycle, and stakeholder-salience attributes; +4. return only authorized associations through API, JSON-LD, CSV, filters, + and UI (candidate API list/detail, filters, combined post-card labels, + qualified JSON-LD, exact-value CSV, SHACL, and source-post evidence + navigation implemented; the board re-filter matches every associated voice + and all twelve governed atomic labels are localized across English, Korean, + Chinese, Japanese, and Vietnamese; one bounded query projects assignments + for every authorized Post even when another node type is the focus; post + detail lists primary and evidence-connected perspectives separately and + honors its knowledge cutoff; client-side JSON-LD filtering retains only + exact canonical repository-case node and Voice-assignment IRIs rather than + accepting cross-origin suffix matches; the exact-value row exposes distinct + carrying-Post and authorized derivation-evidence actions, while hidden + evidence emits neither an identifier nor a fabricated evidence count; + paged JSON-LD merges properties for one subject and unions its multi-Voice + relation rather than overwriting an earlier page); and +5. proves zero-, one-, and multi-voice states with synthetic fixtures, + migration replay, ontology/SHACL, API, accessibility, and Storybook edge + tests before any release claim. The candidate `CombinedVoiceEvidence` scene + covers primary-plus-additional assignments; desktop and mobile screenshots + were inspected on 2026-08-27. At 390 CSS pixels the document did not + overflow, the named exact-value region remained horizontally scrollable, + and the source-post evidence action remained visible and labeled. The + `Post/Recorded perspectives` desktop and 390-pixel scenes were also inspected + on 2026-08-27; both kept each complete Voice label paired with its imported + or evidence-connected state without clipping or horizontal overflow. The + `Post/Connect perspective` ready/success scenes were inspected at 1440 and + 390 CSS pixels on 2026-08-27: labels stay above controls, the mobile form is + a single column, controls meet the 44-pixel touch target, and no horizontal + overflow was visible. + +At this snapshot the repository had 42 open PRs and 11 open issues. PR #713 +head `850494c3` includes the review-driven localization of all twelve governed +Voice labels. Its frontend, ontology publication, static-analysis, dependency, +coverage, full-suite, CodeRabbit, Devin, and OpenCode checks passed. Strix +failed closed before producing a vulnerability report: +the primary NVIDIA NIM model returned HTTP 429, one configured fallback had +reached end of life, and the OpenAI fallback reported exhausted credits. A +same-head retry completed on 2026-08-27 with the explicit +`STRIX_PROVIDER_UNAVAILABLE` annotation and again produced no vulnerability +report. This +is provider/control-plane unavailability, not a vulnerability result or +permission to transfer an older success. Auto-merge remains enabled, while an +independent approval is still required. PR #717 implementation head +`d5fe4828` merges that +parent change without force-pushing and separates the complete governed Voice +catalog used for authoring from usage-derived Board filters, so an authorized +administrator can attach a Voice that no visible Post carries yet. It also +labels Voice exact-value navigation as opening the carrying Post rather than +misrepresenting that Post as the separately recorded derivation evidence. Its +CodeRabbit and hosted Frontend/Storybook checks passed at predecessor head +`ebb4ef1d`; refreshed checks for exact head `d5fe4828` were queued. Focused local +backend tests, frontend type checking/lint, and the new unused-Voice authoring +regression passed, and the exact-value navigation tests, lint, and type check +passed after the label repair. The paged JSON-LD union regression and Voice +evidence navigation suite passed 23 focused frontend tests; 48 focused backend +ontology/docstring tests also passed. The full backend suite at predecessor +head `ebb4ef1d` passed 1,366 tests with 148 environment-dependent skips. The +real-integration fixture now applies +the existing migration 0042 before the expanded taxonomy migrations instead +of seeding an incomplete or duplicate legacy catalog; the exact +`d5fe4828` authenticated post-list integration passed in 91.54 seconds. The +wider local frontend run had 400 passes and eight five-second timeouts under +concurrent backend-suite load; a later App-only run had 94 passes and five +five-second timeouts, while the hosted Frontend/Storybook job passed on +`ebb4ef1d`. Neither local timeout run is promoted to full-suite success. An initial +authenticated integration attempt was unavailable while Keycloak initialized; +a later retry against the shared synthetic stack succeeded in 56.18 seconds +and proved the permission, API, PostgreSQL, +PROV-O, and primary-preservation assertions; no identifying source data was +used or retained. No self-approval, admin bypass, or stale-head check transfer +is permitted. + +Stacked PR #717 carries ADR 0256, migration 0237, qualified +ontology terms, persistence/API/UI tests, and the category-validation review +repairs plus a local candidate admin write path that creates its PROV-O +derivation from an authorized evidence Post. Its JSON-LD projection names that +evidence Post only when it is in the authorized visible set and omits the whole +additional assignment otherwise, preserving the SHACL evidence minimum without +substituting the assigned Post. It targets +#713's branch, not protected `main`; +its checks and review are candidate evidence only. After +#713 reaches protected main, #717 must be synchronized, retargeted to `main`, +and revalidated on its then-current head. + +Downstream Dashboard repair PR #737 exact head `a837ee5d` is stacked on base +`7c7bb2cf`, which contains migration 0235 through a non-#713 composition but +does not contain #713's twelve-label locale update. Its added Voice labels are +therefore necessary on that exact base, yet overlap #713 and must be reconciled +when the stack is eventually rebuilt on protected `main`; neither branch is a +second taxonomy authority, and pre-parent Checks cannot transfer across that +restack. The remaining user-visible gap is evidence-bearing composition. A post still has one source-provided `voc_type_code`; the product cannot yet represent a single record that intentionally carries multiple independently evidenced @@ -499,6 +619,7 @@ this file per §3.5 of the prior snapshot). | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | +| Voice primary history | The #717 candidate updates ADR 0256 and migration 0237 with immutable assignment ids and half-open intervals, closing rather than deleting a replaced primary so A → B → A is representable; this is not protected-main evidence | Prove migration replay, concurrent primary changes, non-overlap, and API/ontology cutoff reads against synthetic PostgreSQL at the current exact head, then close #748 only after protected delivery | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 131860cf8..8891ba568 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,8 +7,11 @@ operator-facing control you can click before changing product CSS. |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | +| `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | +| `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | +| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project/Work-evidence shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The populated scene includes one assertion-backed occupational construct without a person-trait promotion. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0255 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 6e651a926..294beb1e0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -526,6 +526,51 @@ color: var(--text-h); } +.voice-assignment-form { + display: grid; + grid-template-columns: repeat(2, minmax(12rem, 1fr)) auto; + align-items: end; + gap: var(--space-panel-block); +} + +.voice-assignment-form label { + display: flex; + flex-direction: column; + gap: var(--space-control-gap); + color: var(--text-h); + font-weight: 600; +} + +.voice-assignment-form select, +.voice-assignment-form button { + min-height: var(--size-touch-target); +} + +.voice-assignment-form select { + width: 100%; + padding-inline: var(--space-chip-inline); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + font: inherit; +} + +.voice-assignment-feedback { + grid-column: 1 / -1; + margin: 0; +} + +@media (max-width: 768px) { + .voice-assignment-form { + grid-template-columns: 1fr; + } + + .voice-assignment-form button { + width: 100%; + } +} + .lineage-dag-group { margin: 0 0 1.25rem; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dc3e169ca..7060ed767 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -128,6 +128,8 @@ describe("App, authenticated", () => { staleSummary?: boolean; contentAfterSummary?: boolean; organizationAliases?: boolean; + combinedVoices?: boolean; + omitVoiceOptions?: boolean; askLineageGraph?: boolean; askImageCitation?: boolean; askDelivery?: boolean; @@ -1165,6 +1167,26 @@ describe("App, authenticated", () => { post_title: "Public post", voc_type_code: "voc", voc_type_label: "Voice of Customer", + ...(options?.combinedVoices + ? { + voice_types: [ + { + code: "voc", + label: "Voice of Customer", + is_primary: true, + truth_status_code: "truth_observed", + evidence_available: false, + }, + { + code: "vops", + label: "Voice of Process", + is_primary: false, + truth_status_code: "truth_observed", + evidence_available: true, + }, + ], + } + : {}), visibility_code: "public", visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", @@ -1173,10 +1195,22 @@ describe("App, authenticated", () => { total_count: 1, limit: 50, offset: 0, - voc_type_options: [ - { code: "voc", label: "Voice of Customer" }, - { code: "vop", label: "Voice of Partner" }, - ], + ...(options?.omitVoiceOptions + ? {} + : { + voc_type_options: [ + { code: "voc", label: "Voice of Customer" }, + { code: "vop", label: "Voice of Partner" }, + ...(options?.combinedVoices + ? [{ code: "vops", label: "Voice of Process" }] + : []), + ], + voice_type_catalog: [ + { code: "voc", label: "Voice of Customer" }, + { code: "vop", label: "Voice of Partner" }, + { code: "vos", label: "Voice of Supplier" }, + ], + }), visibility_options: [{ code: "public", label: "Public" }], }, ), @@ -1970,6 +2004,41 @@ describe("App, authenticated", () => { return Object.assign(fetchMock, { releaseMe, releasePostOne }); } + it("shows all evidence-bearing Voice-of-X labels on a post card", async () => { + stubBackend({ combinedVoices: true }); + render(); + + expect( + await screen.findByText("Voice of Customer (Observed) + Voice of Process (Observed)"), + ).toBeInTheDocument(); + }); + + it("keeps a post whose additional voice matches the board filter", async () => { + stubBackend({ combinedVoices: true }); + render(); + + await screen.findByRole("button", { name: "View post: Public post" }); + await userEvent.click(screen.getByRole("checkbox", { name: "Voice of Process" })); + expect(screen.getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + }); + + it("offers additional voices when filter options are omitted", async () => { + stubBackend({ combinedVoices: true, omitVoiceOptions: true }); + render(); + + expect(await screen.findByRole("checkbox", { name: "Voice of Process" })).toBeInTheDocument(); + }); + + it("offers an unused governed Voice when an administrator connects evidence", async () => { + stubBackend({ admin: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByRole("option", { name: "Voice of Supplier" })).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Voice of Supplier" })).not.toBeInTheDocument(); + }); + it("renders safe Ask Agent evidence under each cited post", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8adb6881..e160e51d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { AdminPanel } from "./components/AdminPanel"; import { LeftoverPairList } from "./components/LeftoverPairList"; import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { focusedGraphMustReset } from "./focusedGraphSelection"; +import { canAuthorVoice, postPrimaryVoiceLabel } from "./voicePerspective"; import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; @@ -13,6 +14,7 @@ import { createAnalysisRun, startAnalysisRun, createPostTicket, + createPostVoiceAssignment, deriveCommitment, evaluatePost, extractPostKeymen, @@ -78,6 +80,7 @@ import { type PeriodReportIndex, type PeriodReports, type PostLineage, + type PostVoiceType, type PostSummary, type PostSortOrder, type RankingList, @@ -1729,12 +1732,30 @@ const ACTIVITY_TYPE_LABELS: Record = { relations_verified: "Relations verified", post_evaluated: "Post evaluated", chat_answered: "Chat answered", + voice_assignment_added: "Voice perspective connected", }; function activityTypeLabel(eventType: string): string { return t(ACTIVITY_TYPE_LABELS[eventType] ?? eventType); } +const VOICE_TRUTH_LABELS: Record = { + truth_authoritative: "Authoritative", + truth_observed: "Observed", + truth_inferred: "Inferred", + truth_proposed: "Proposed", + truth_superseded: "Superseded", + truth_rejected: "Rejected", +}; + +function voiceTruthLabel(truthStatusCode: string): string { + return t(VOICE_TRUTH_LABELS[truthStatusCode] ?? "Status unavailable"); +} + +function voiceDisplayLabel(voice: PostVoiceType): string { + return `${t(voice.label)} (${voiceTruthLabel(voice.truth_status_code)})`; +} + function ActivityPanel({ postId, accessToken }: { postId: string; accessToken: string }) { const [events, setEvents] = useState(null); const [error, setError] = useState(null); @@ -1778,6 +1799,113 @@ function ActivityPanel({ postId, accessToken }: { postId: string; accessToken: s ); } +export function VoicePerspectiveList({ voices }: { voices: PostVoiceType[] }) { + return ( +
+

{t("Recorded perspectives")}

+
    + {voices.map((voice) => ( +
  • + {voiceDisplayLabel(voice)} + + {t(voice.is_primary ? "Imported from source" : "Evidence connected")} + +
  • + ))} +
+
+ ); +} + +const VOICE_TRUTH_OPTIONS = [ + "truth_authoritative", + "truth_observed", + "truth_inferred", + "truth_proposed", + "truth_superseded", + "truth_rejected", +]; + +export function VoiceAssignmentForm({ + voices, + options, + onSave, +}: { + voices: PostVoiceType[]; + options: PostFilterOption[]; + onSave: (voiceTypeCode: string, truthStatusCode: string) => Promise; +}) { + const assigned = new Set(voices.map((voice) => voice.code)); + const available = options.filter((option) => !assigned.has(option.code)); + const [voiceTypeCode, setVoiceTypeCode] = useState(""); + const [truthStatusCode, setTruthStatusCode] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!voiceTypeCode || !truthStatusCode || saving) return; + setSaving(true); + setSaved(false); + setError(null); + try { + await onSave(voiceTypeCode, truthStatusCode); + setVoiceTypeCode(""); + setTruthStatusCode(""); + setSaved(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("Perspective could not be connected.")); + } finally { + setSaving(false); + } + } + + if (available.length === 0) return null; + return ( +
+

{t("Connect another perspective")}

+

{t("This post will be recorded as the evidence.")}

+
+ + + + {saved ?

{t("Perspective connected.")}

: null} + {error ?

{error}

: null} +
+
+ ); +} + + function PostDetailPopup({ postId, accessToken, @@ -1790,6 +1918,7 @@ function PostDetailPopup({ onClose, onSelectPost, onSearch, + voiceOptions = [], }: { postId: string; accessToken: string; @@ -1802,6 +1931,7 @@ function PostDetailPopup({ onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; + voiceOptions?: PostFilterOption[]; }) { const [post, setPost] = useState(null); const [imageContent, setImageContent] = useState([]); @@ -2103,10 +2233,32 @@ function PostDetailPopup({ <>

{post.post_title}

- {post.voc_type_label ?? post.voc_type_code} ·{" "} + {t(postPrimaryVoiceLabel(post, knowledgeCutoff))} ·{" "} {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

+ {post.voice_types?.length ? : null} + {canAuthorVoice(canExtract, knowledgeCutoff) ? ( + { + const assignment = await createPostVoiceAssignment( + accessToken, + postId, + voiceTypeCode, + truthStatusCode, + ); + setPost((current) => current ? { + ...current, + voice_types: [ + ...(current.voice_types ?? []).filter((voice) => voice.code !== assignment.code), + assignment, + ], + } : current); + }} + /> + ) : null}
@@ -547,7 +570,19 @@ function OntologyExactValueTable({ {t(TRUTH_LABEL[row.truth_status_code] ?? row.truth_status_code)} {row.valid_from.slice(0, 10) || t("Unknown")} {row.valid_to.slice(0, 10) || t("Unknown")} - {row.evidence_count} + + {row.property_code === "hasVoiceAssignment" && row.evidence_post_id ? ( + + ) : row.evidence_count} + {row.recorded_at.slice(0, 10)} ))} diff --git a/frontend/src/components/VoiceAssignmentForm.stories.tsx b/frontend/src/components/VoiceAssignmentForm.stories.tsx new file mode 100644 index 000000000..3af56b933 --- /dev/null +++ b/frontend/src/components/VoiceAssignmentForm.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; + +import { VoiceAssignmentForm } from "../App"; +import "../App.css"; + +const meta = { + title: "Post/Connect perspective", + component: VoiceAssignmentForm, + args: { + voices: [ + { + code: "voc", + label: "Voice of Customer", + is_primary: true, + truth_status_code: "truth_observed", + evidence_available: false, + }, + ], + options: [ + { code: "voc", label: "Voice of Customer" }, + { code: "vops", label: "Voice of Process" }, + { code: "vor", label: "Voice of Regulator" }, + ], + onSave: fn().mockResolvedValue(undefined), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Ready: Story = {}; + +export const Completed: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.selectOptions(canvas.getByLabelText("Perspective"), "vops"); + await userEvent.selectOptions(canvas.getByLabelText("Evidence status"), "truth_observed"); + await userEvent.click(canvas.getByRole("button", { name: "Connect perspective" })); + await expect(canvas.getByRole("status")).toHaveTextContent("Perspective connected."); + }, +}; + +export const NarrowViewport: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; diff --git a/frontend/src/components/VoiceAssignmentForm.test.tsx b/frontend/src/components/VoiceAssignmentForm.test.tsx new file mode 100644 index 000000000..61ea56caa --- /dev/null +++ b/frontend/src/components/VoiceAssignmentForm.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { VoiceAssignmentForm } from "../App"; +import { canAuthorVoice, postPrimaryVoiceLabel } from "../voicePerspective"; + +describe("VoiceAssignmentForm", () => { + it("does not substitute the live primary Voice into a cutoff with no assignment", () => { + const post = { + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + voice_types: [], + }; + + expect(postPrimaryVoiceLabel(post, "2026-01-01T00:00:00Z")).toBe( + "Perspective unavailable at this cutoff", + ); + expect(postPrimaryVoiceLabel(post)).toBe("Voice of Customer"); + expect(canAuthorVoice(true, "2026-01-01T00:00:00Z")).toBe(false); + expect(canAuthorVoice(false)).toBe(false); + expect(canAuthorVoice(true)).toBe(true); + }); + + it("requires an explicit unassigned perspective and evidence status", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + expect(screen.queryByRole("option", { name: "Voice of Customer" })).toBeNull(); + const submit = screen.getByRole("button", { name: "Connect perspective" }); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Perspective"), { target: { value: "vops" } }); + fireEvent.change(screen.getByLabelText("Evidence status"), { + target: { value: "truth_observed" }, + }); + fireEvent.click(submit); + + expect(await screen.findByRole("status")).toHaveTextContent("Perspective connected."); + expect(onSave).toHaveBeenCalledWith("vops", "truth_observed"); + }); + + it("keeps the submitted values available after a failed save", async () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText("Perspective"), { target: { value: "vor" } }); + fireEvent.change(screen.getByLabelText("Evidence status"), { + target: { value: "truth_proposed" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect perspective" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Evidence is no longer visible."); + expect(screen.getByLabelText("Perspective")).toHaveValue("vor"); + expect(screen.getByLabelText("Evidence status")).toHaveValue("truth_proposed"); + }); +}); diff --git a/frontend/src/components/VoicePerspectiveList.stories.tsx b/frontend/src/components/VoicePerspectiveList.stories.tsx new file mode 100644 index 000000000..d081a3e75 --- /dev/null +++ b/frontend/src/components/VoicePerspectiveList.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; + +import { VoicePerspectiveList } from "../App"; +import "../App.css"; + +const meta = { + title: "Post/Recorded perspectives", + component: VoicePerspectiveList, + args: { + voices: [ + { + code: "voc", + label: "Voice of Customer", + is_primary: true, + truth_status_code: "truth_observed", + evidence_available: false, + }, + { + code: "vops", + label: "Voice of Process", + is_primary: false, + truth_status_code: "truth_observed", + evidence_available: true, + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CombinedEvidence: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Imported from source")).toBeVisible(); + await expect(canvas.getByText("Evidence connected")).toBeVisible(); + }, +}; + +export const NarrowViewport: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; + +export const RejectedEvidence: Story = { + args: { + voices: [ + ...meta.args.voices, + { + code: "vor", + label: "Voice of Regulator", + is_primary: false, + truth_status_code: "truth_rejected", + evidence_available: true, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Voice of Regulator (Rejected)")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/VoicePerspectiveList.test.tsx b/frontend/src/components/VoicePerspectiveList.test.tsx new file mode 100644 index 000000000..b4a74ed75 --- /dev/null +++ b/frontend/src/components/VoicePerspectiveList.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { VoicePerspectiveList } from "../App"; + +describe("VoicePerspectiveList", () => { + it("keeps primary and evidence-connected perspectives distinct", () => { + render( + , + ); + + const perspectives = screen.getByRole("region", { name: "Recorded perspectives" }); + expect(within(perspectives).getByText("Voice of Customer (Observed)")).toBeInTheDocument(); + expect(within(perspectives).getByText("Voice of Process (Observed)")).toBeInTheDocument(); + expect(within(perspectives).getByText("Voice of Regulator (Rejected)")).toBeInTheDocument(); + expect(within(perspectives).getByText("Imported from source")).toBeInTheDocument(); + expect(within(perspectives).getAllByText("Evidence connected")).toHaveLength(2); + }); +}); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index acc6bbf28..2302fea56 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -19,6 +19,21 @@ const STORAGE_KEY = "lineageweave.locale"; const TRANSLATIONS: Partial>> = { ko: { + "Connect another perspective": "다른 관점 연결", + "This post will be recorded as the evidence.": "이 글이 근거로 기록됩니다.", + Perspective: "관점", + "Choose a perspective": "관점 선택", + "Evidence status": "근거 상태", + "Choose an evidence status": "근거 상태 선택", + "Connect perspective": "관점 연결", + "Connecting...": "연결 중...", + "Perspective connected.": "관점이 연결되었습니다.", + "Perspective could not be connected.": "관점을 연결하지 못했습니다.", + "Perspective unavailable at this cutoff": "이 기준 시각의 관점을 확인할 수 없음", + "Recorded perspectives": "기록된 관점", + "Imported from source": "원본에서 가져옴", + "Evidence connected": "근거 연결됨", + "Status unavailable": "상태를 확인할 수 없음", "Unknown": "알 수 없음", Language: "언어", unresolved: "미해결", @@ -87,6 +102,7 @@ const TRANSLATIONS: Partial>> = { "Relations verified": "관계 검증됨", "Post evaluated": "글 평가됨", "Chat answered": "채팅 답변됨", + "Voice perspective connected": "Voice 관점 연결됨", "Not yet checked": "아직 확인하지 않음", Corroborated: "뒷받침됨", "No evidence found": "근거를 찾지 못함", @@ -562,6 +578,21 @@ const TRANSLATIONS: Partial>> = { "IRT 주효과 이후 잔여 맵 랭크 0은 잔여 구조가 없음을 뜻합니다. 관측 Y {observed}와 기대 E {expected}를 읽은 다음, 이 글을 여세요.", }, zh: { + "Connect another perspective": "关联另一个观点", + "This post will be recorded as the evidence.": "此文章将被记录为证据。", + Perspective: "观点", + "Choose a perspective": "选择观点", + "Evidence status": "证据状态", + "Choose an evidence status": "选择证据状态", + "Connect perspective": "关联观点", + "Connecting...": "正在关联...", + "Perspective connected.": "观点已关联。", + "Perspective could not be connected.": "无法关联观点。", + "Perspective unavailable at this cutoff": "此时间点的观点不可用", + "Recorded perspectives": "已记录的观点", + "Imported from source": "从来源导入", + "Evidence connected": "已关联证据", + "Status unavailable": "状态不可用", "Unknown": "未知", Language: "语言", unresolved: "未解决", @@ -630,6 +661,7 @@ const TRANSLATIONS: Partial>> = { "Relations verified": "关系已验证", "Post evaluated": "文章已评估", "Chat answered": "聊天已回答", + "Voice perspective connected": "已关联 Voice 观点", "Not yet checked": "尚未检查", Corroborated: "已有佐证", "No evidence found": "未找到证据", @@ -1097,6 +1129,21 @@ const TRANSLATIONS: Partial>> = { "残余图秩 0 表示 IRT 主效应后没有残余结构。阅读观测 Y {observed} 与期望 E {expected},然后打开这篇帖子。", }, ja: { + "Connect another perspective": "別の観点を関連付ける", + "This post will be recorded as the evidence.": "この投稿が根拠として記録されます。", + Perspective: "観点", + "Choose a perspective": "観点を選択", + "Evidence status": "根拠の状態", + "Choose an evidence status": "根拠の状態を選択", + "Connect perspective": "観点を関連付ける", + "Connecting...": "関連付け中...", + "Perspective connected.": "観点を関連付けました。", + "Perspective could not be connected.": "観点を関連付けられませんでした。", + "Perspective unavailable at this cutoff": "この基準時点の観点は利用できません", + "Recorded perspectives": "記録された観点", + "Imported from source": "元データから取得", + "Evidence connected": "根拠を関連付け済み", + "Status unavailable": "状態を確認できません", "Unknown": "不明", "5W1H": "5W1H", Who: "誰が", @@ -1189,6 +1236,7 @@ const TRANSLATIONS: Partial>> = { "Relations verified": "関係を検証済み", "Post evaluated": "投稿を評価済み", "Chat answered": "チャットに回答済み", + "Voice perspective connected": "Voice の観点を関連付け済み", "Not yet checked": "未確認", Corroborated: "裏付けあり", "No evidence found": "証拠なし", @@ -1635,6 +1683,21 @@ const TRANSLATIONS: Partial>> = { "残差マップランク 0 は IRT 主効果後に残差構造がないことを示します。観測 Y {observed} と期待 E {expected} を読んでから、この投稿を開いてください。", }, vi: { + "Connect another perspective": "Liên kết góc nhìn khác", + "This post will be recorded as the evidence.": "Bài đăng này sẽ được ghi nhận làm bằng chứng.", + Perspective: "Góc nhìn", + "Choose a perspective": "Chọn góc nhìn", + "Evidence status": "Trạng thái bằng chứng", + "Choose an evidence status": "Chọn trạng thái bằng chứng", + "Connect perspective": "Liên kết góc nhìn", + "Connecting...": "Đang liên kết...", + "Perspective connected.": "Đã liên kết góc nhìn.", + "Perspective could not be connected.": "Không thể liên kết góc nhìn.", + "Perspective unavailable at this cutoff": "Không có góc nhìn tại thời điểm này", + "Recorded perspectives": "Các góc nhìn đã ghi nhận", + "Imported from source": "Được nhập từ nguồn", + "Evidence connected": "Đã liên kết bằng chứng", + "Status unavailable": "Không có trạng thái", "Unknown": "Không rõ", "5W1H": "5W1H", Who: "Ai", @@ -1727,6 +1790,7 @@ const TRANSLATIONS: Partial>> = { "Relations verified": "Đã xác minh quan hệ", "Post evaluated": "Đã đánh giá bài viết", "Chat answered": "Đã trả lời trò chuyện", + "Voice perspective connected": "Đã liên kết góc nhìn Voice", "Not yet checked": "Chưa kiểm tra", Corroborated: "Được chứng thực", "No evidence found": "Không tìm thấy bằng chứng", diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index 7517456ab..b609e7e2b 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "vitest"; import type { OntologyNeighborhoodPayload } from "./api"; -import { accumulateNeighborhoodPages, layoutOntologyNeighborhood, neighborhoodCsv } from "./ontologyLayout"; +import { + accumulateNeighborhoodPages, + filterNeighborhood, + layoutOntologyNeighborhood, + neighborhoodCsv, +} from "./ontologyLayout"; const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1"; +const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#"; function payload(): OntologyNeighborhoodPayload { return { @@ -109,6 +115,104 @@ function payload(): OntologyNeighborhoodPayload { } describe("ontologyLayout", () => { + it("keeps evidence-bearing voice assignments in CSV, filters, and page accumulation", () => { + const source = payload(); + const assignment = { + post_id: POST_ID, + voice_type_code: "voc_customer", + voice_type_iri: "https://example.test/voice/customer", + voice_type_label: "Voice of Customer", + is_primary: false, + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "Evidence-backed additional voice", + evidence_post_id: POST_ID, + }; + const row = { + ...source.exact_value_rows[0], + edge_id: `voice-assignment:${POST_ID}:voc_customer`, + property_code: "hasVoiceAssignment", + property_label: "Voice carried by this post", + target_node_id: assignment.voice_type_code, + target_label: assignment.voice_type_label, + target_type_code: "node_voice_type", + evidence_post_id: POST_ID, + }; + const withVoice = { + ...source, + voice_assignments: [assignment], + exact_value_rows: [...source.exact_value_rows, row], + jsonld: { + "@graph": [ + { "@id": `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc_customer` }, + { "@id": assignment.voice_type_iri }, + ], + }, + } satisfies OntologyNeighborhoodPayload; + + const csv = neighborhoodCsv(withVoice); + expect(csv).toContain("Voice of Customer"); + expect(csv.split("\n")[0]).toContain("evidence_post_id"); + expect(csv).toContain(POST_ID); + expect(filterNeighborhood(withVoice, "customer")!.voice_assignments).toEqual([assignment]); + expect(filterNeighborhood(withVoice, "missing")!.voice_assignments).toEqual([assignment]); + expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]); + }); + + it("merges JSON-LD properties and multi-value relations for one paged subject", () => { + const source = payload(); + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const propertyIri = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; + const first = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, "rdfs:label": "Demo public post", [propertyIri]: [{ "@id": "voice:one" }] }] }, + }; + const second = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, [propertyIri]: [{ "@id": "voice:two" }] }] }, + }; + + expect(accumulateNeighborhoodPages(first, second).jsonld["@graph"]).toEqual([ + { + "@id": postIri, + "rdfs:label": "Demo public post", + [propertyIri]: [{ "@id": "voice:one" }, { "@id": "voice:two" }], + }, + ]); + }); + + it("keeps only exact canonical JSON-LD node ids when filtering", () => { + const source = payload(); + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const filtered = filterNeighborhood({ + ...source, + jsonld: { + "@graph": [ + { "@id": postIri }, + { "@id": `https://example.test/prefix/${postIri}` }, + ], + }, + }, "missing")!; + + expect(filtered.jsonld["@graph"]).toEqual([{ "@id": postIri }]); + }); + + it("matches the backend's canonical encoding for node ids", () => { + const source = payload(); + const nodeId = `${POST_ID}/operator's-plan`; + const nodeIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}/operator%27s-plan`; + const filtered = filterNeighborhood({ + ...source, + focus_node_id: nodeId, + nodes: [{ ...source.nodes[0], node_id: nodeId }], + edges: [], + exact_value_rows: [], + jsonld: { "@graph": [{ "@id": nodeIri }] }, + }, "missing")!; + + expect(filtered.jsonld["@graph"]).toEqual([{ "@id": nodeIri }]); + }); + it("is deterministic for a fixed payload", () => { const first = layoutOntologyNeighborhood(payload()); const second = layoutOntologyNeighborhood(payload()); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index 4bd930b2e..dc49de443 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -28,11 +28,20 @@ const COLUMN_GAP = 260; const ROW_GAP = 128; const LEFT = ONTOLOGY_NODE_LABEL_WIDTH / 2 + 20; const TOP = 48; +const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#"; function nodeKey(nodeTypeCode: string, nodeId: string): string { return `${nodeTypeCode}:${nodeId}`; } +function ontologyNodeId(nodeTypeCode: string, nodeId: string): string { + const encode = (value: string) => encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `${ONTOLOGY_NAMESPACE}node/${encode(nodeTypeCode)}/${encode(nodeId).replaceAll("%2F", "/")}`; +} + /** * Deterministic left-to-right neighborhood layout from the focus node. * @@ -137,6 +146,7 @@ export function neighborhoodCsv(payload: OntologyNeighborhoodPayload): string { "truth_status_code", "recorded_at", "ontology_property_iri", + "evidence_post_id", ]; const lines = [header.join(",")]; for (const row of payload.exact_value_rows) { @@ -193,12 +203,29 @@ export function filterNeighborhood( } const nodes = payload.nodes.filter((node) => keep.has(nodeKey(node.node_type_code, node.node_id))); const exact_value_rows = payload.exact_value_rows.filter((row) => - edges.some((edge) => edge.edge_id === row.edge_id), + edges.some((edge) => edge.edge_id === row.edge_id) || + (payload.voice_assignments ?? []).some( + (assignment) => + row.edge_id === `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}` && + keep.has(nodeKey("node_post", assignment.post_id)), + ), ); const visibleIds = new Set([ ...nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`), ...edges.map((edge) => `lw:edge/${edge.edge_id}`), ]); + const visibleNodeIds = new Set(nodes.map( + (node) => ontologyNodeId(node.node_type_code, node.node_id), + )); + const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) => + keep.has(nodeKey("node_post", assignment.post_id)), + ); + const visibleVoiceIds = new Set( + visibleVoiceAssignments.flatMap((assignment) => [ + assignment.voice_type_iri, + `${ONTOLOGY_NAMESPACE}voice-assignment/${assignment.post_id}/${assignment.voice_type_code}`, + ]), + ); const graph = payload.jsonld["@graph"]; const jsonld = Array.isArray(graph) ? { @@ -207,11 +234,20 @@ export function filterNeighborhood( (item): item is Record => typeof item === "object" && item !== null && typeof item["@id"] === "string" && - visibleIds.has(item["@id"]), + (visibleIds.has(item["@id"]) || + visibleNodeIds.has(item["@id"]) || + visibleVoiceIds.has(item["@id"])), ), } : payload.jsonld; - return { ...payload, nodes, edges, exact_value_rows, jsonld }; + return { + ...payload, + nodes, + edges, + exact_value_rows, + voice_assignments: visibleVoiceAssignments, + jsonld, + }; } /** @@ -235,13 +271,41 @@ export function accumulateNeighborhoodPages( for (const row of next.exact_value_rows) { rows.set(row.edge_id, row); } + const voiceAssignments = new Map( + (current.voice_assignments ?? []).map((assignment) => [ + `${assignment.post_id}:${assignment.voice_type_code}`, + assignment, + ]), + ); + for (const assignment of next.voice_assignments ?? []) { + voiceAssignments.set(`${assignment.post_id}:${assignment.voice_type_code}`, assignment); + } const graphItems = new Map>(); for (const payload of [current, next]) { const graph = payload.jsonld["@graph"]; if (!Array.isArray(graph)) continue; for (const item of graph) { if (typeof item === "object" && item !== null && typeof item["@id"] === "string") { - graphItems.set(item["@id"], item as Record); + const incoming = item as Record; + const existing = graphItems.get(item["@id"]); + if (!existing) { + graphItems.set(item["@id"], incoming); + continue; + } + const merged = { ...existing, ...incoming }; + for (const key of Object.keys(incoming)) { + if (Array.isArray(existing[key]) && Array.isArray(incoming[key])) { + const values = [...existing[key], ...incoming[key]]; + const seen = new Set(); + merged[key] = values.filter((value) => { + const serialized = JSON.stringify(value); + if (seen.has(serialized)) return false; + seen.add(serialized); + return true; + }); + } + } + graphItems.set(item["@id"], merged); } } } @@ -250,6 +314,7 @@ export function accumulateNeighborhoodPages( nodes: [...nodes.values()], edges: [...edges.values()], exact_value_rows: [...rows.values()], + voice_assignments: [...voiceAssignments.values()], jsonld: { ...next.jsonld, "@graph": [...graphItems.values()], diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 453e55592..becb2d890 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -93,6 +93,7 @@ --space-close-inset: 0.75rem; --space-control-gap: 0.35rem; --size-control-min: 24px; + --size-touch-target: 44px; --radius-chip: 999px; --radius-control: 6px; --font-size-close: 1.5rem; diff --git a/frontend/src/voicePerspective.ts b/frontend/src/voicePerspective.ts new file mode 100644 index 000000000..befac9b10 --- /dev/null +++ b/frontend/src/voicePerspective.ts @@ -0,0 +1,15 @@ +import type { PostDetail } from "./api"; + +export function postPrimaryVoiceLabel( + post: Pick, + knowledgeCutoff?: string | null, +): string { + return post.voice_types?.find((voice) => voice.is_primary)?.label ?? + (knowledgeCutoff + ? "Perspective unavailable at this cutoff" + : post.voc_type_label ?? post.voc_type_code); +} + +export function canAuthorVoice(canExtract: boolean, knowledgeCutoff?: string | null): boolean { + return canExtract && !knowledgeCutoff; +} diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index 848948600..cb07daae0 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -223,6 +223,45 @@ class OntologyGraphEdge: evidence_references: tuple[str, ...] +@dataclass(frozen=True) +class OntologyVoiceAssignment: + """One authorized, qualified Voice-of-X assignment for a visible post.""" + + post_id: str + voice_type_code: str + voice_type_iri: str + voice_type_label: str + is_primary: bool + truth_status_code: str + recorded_at: datetime + provenance_reference: str + evidence_post_id: str | None = None + + def __post_init__(self) -> None: + """Reject ungoverned or incomplete assignments at the export boundary.""" + if not all( + value.strip() + for value in ( + self.post_id, + self.voice_type_code, + self.voice_type_iri, + self.voice_type_label, + self.provenance_reference, + ) + ): + raise OntologyNeighborhoodError( + "invalid_voice_assignment", "voice assignment fields must be non-empty" + ) + if self.truth_status_code not in TRUTH_STATUS_CODES: + raise OntologyNeighborhoodError( + "unknown_truth_status", "voice assignment truth status is not governed" + ) + if self.recorded_at.tzinfo is None: + raise OntologyNeighborhoodError( + "naive_timestamp", "voice assignment recorded_at must be offset-aware" + ) + + @dataclass(frozen=True) class OntologyNeighborhood: """Bounded, deterministic neighborhood payload.""" @@ -234,6 +273,7 @@ class OntologyNeighborhood: truncated: bool next_cursor: str | None limitation_code: str | None + voice_assignments: tuple[OntologyVoiceAssignment, ...] = () def exact_value_rows(self) -> tuple[dict[str, str], ...]: """Keyboard/print/CSV rows for the same visible graph.""" @@ -259,6 +299,39 @@ def exact_value_rows(self) -> tuple[dict[str, str], ...]: "evidence_count": str(len(edge.evidence_references)), } ) + post_labels = { + node.node_id: node.display_label + for node in self.nodes + if node.node_type_code == NODE_POST + } + for assignment in self.voice_assignments: + source_label = post_labels.get(assignment.post_id) + if source_label is None: + raise OntologyNeighborhoodError( + "dangling_endpoint", "voice assignment references a missing post" + ) + rows.append( + { + "edge_id": _voice_assignment_id(assignment), + "source_node_id": assignment.post_id, + "source_label": source_label, + "source_type_code": NODE_POST, + "property_code": "hasVoiceAssignment", + "property_label": "Voice carried by this post", + "ontology_property_iri": str(LW.hasVoiceAssignment), + "target_node_id": assignment.voice_type_code, + "target_label": assignment.voice_type_label, + "target_type_code": "node_voice_type", + "truth_status_code": assignment.truth_status_code, + "recorded_at": assignment.recorded_at.isoformat(), + "valid_from": "", + "valid_to": "", + "evidence_count": "1" if assignment.evidence_post_id or assignment.is_primary else "0", + "evidence_post_id": assignment.evidence_post_id or ( + assignment.post_id if assignment.is_primary else "" + ), + } + ) return tuple(rows) def jsonld_document(self) -> dict[str, object]: @@ -311,9 +384,73 @@ def jsonld_document(self) -> dict[str, object]: } _add_jsonld_times(item, edge.recorded_at, edge.valid_from, edge.valid_to) graph.append(item) + assignments_by_post: dict[str, list[OntologyVoiceAssignment]] = {} + for assignment in self.voice_assignments: + assignments_by_post.setdefault(assignment.post_id, []).append(assignment) + for post_id, assignments in assignments_by_post.items(): + graph.append( + { + "@id": ontology_node_iri(NODE_POST, post_id), + str(LW.hasVoiceAssignment): [ + {"@id": _voice_assignment_iri(assignment)} + for assignment in assignments + ], + } + ) + for assignment in self.voice_assignments: + post_iri = ontology_node_iri(NODE_POST, assignment.post_id) + assignment_iri = _voice_assignment_iri(assignment) + evidence_post_id = assignment.evidence_post_id + evidence_iri = ( + ontology_node_iri(NODE_POST, evidence_post_id) + if evidence_post_id is not None + else post_iri if assignment.is_primary else None + ) + provenance = ( + { + str(LW.voiceAssignmentEvidence): {"@id": evidence_iri}, + "prov:wasDerivedFrom": {"@id": evidence_iri}, + } + if evidence_iri is not None + else {} + ) + graph.append( + { + "@id": assignment_iri, + "@type": str(LW.VoiceAssignment), + str(LW.assignedVoiceType): {"@id": assignment.voice_type_iri}, + str(LW.primaryVoiceAssignment): { + "@value": assignment.is_primary, + "@type": "xsd:boolean", + }, + **provenance, + "lw:truthStatus": assignment.truth_status_code, + "prov:generatedAtTime": { + "@value": assignment.recorded_at.isoformat(), + "@type": "xsd:dateTimeStamp", + }, + } + ) + graph.append( + { + "@id": assignment.voice_type_iri, + "@type": "skos:Concept", + "skos:prefLabel": assignment.voice_type_label, + } + ) return {"@context": JSONLD_CONTEXT, "@graph": graph} +def _voice_assignment_id(assignment: OntologyVoiceAssignment) -> str: + """Return the deterministic exact-row id for one voice assignment.""" + return f"voice-assignment:{assignment.post_id}:{assignment.voice_type_code}" + + +def _voice_assignment_iri(assignment: OntologyVoiceAssignment) -> str: + """Return the canonical qualified-assignment IRI.""" + return str(LW[f"voice-assignment/{assignment.post_id}/{assignment.voice_type_code}"]) + + def _add_jsonld_times( item: dict[str, object], recorded_at: datetime | None, @@ -825,13 +962,7 @@ def _fact_sort_key(fact: NeighborhoodFact) -> tuple[str, str, str, str, str]: "DEFAULT_MAXIMUM_NODES", "INSTANCE_PROPERTY_CODES", "JSONLD_CONTEXT", - "NeighborhoodFact", "NODE_SHAPE", - "OntologyGraphEdge", - "OntologyGraphNode", - "OntologyNodeMetadata", - "OntologyNeighborhood", - "OntologyNeighborhoodError", "PROPERTY_AFFILIATED_WITH", "PROPERTY_CO_MENTIONED_WITH", "PROPERTY_MENTIONS", @@ -849,6 +980,13 @@ def _fact_sort_key(fact: NeighborhoodFact) -> tuple[str, str, str, str, str]: "TRUTH_REJECTED", "TRUTH_STATUS_CODES", "TRUTH_SUPERSEDED", + "NeighborhoodFact", + "OntologyGraphEdge", + "OntologyGraphNode", + "OntologyNeighborhood", + "OntologyNeighborhoodError", + "OntologyNodeMetadata", + "OntologyVoiceAssignment", "assemble_ontology_neighborhood", "canonicalize_property_code", "fact_from_knowledge_graph_edge", diff --git a/lineageweave/voc_evidence.py b/lineageweave/voc_evidence.py index de50e11be..7301349ab 100644 --- a/lineageweave/voc_evidence.py +++ b/lineageweave/voc_evidence.py @@ -1,10 +1,10 @@ """Extractive VOC evidence: the sentences that actually name an org. -A post's ``voc_type_code`` is a closed lookup (Voice of Customer / Market -/ ...). The buyer-felt evidence for that label is not a second LLM -guess -- it is the span in the post that mentions a classified -counterparty or a Keyman's affiliated organization (ACE mention extent; -Doddington et al., 2004). A name that never appears yields no excerpt: +A post's ``voc_type_code`` is a governed Voice-of-X lookup (ADR 0246). +The operator-visible evidence for that label is not a second LLM guess -- +it is the span in the post that mentions a classified counterparty or a +Keyman's affiliated organization (ACE mention extent; Doddington et al., +2004). A name that never appears yields no excerpt: a missing mention is not a fabricated quote. """ diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql new file mode 100644 index 000000000..a010a830c --- /dev/null +++ b/migrations/0237_source_post_voice_combination.sql @@ -0,0 +1,185 @@ +-- ADR 0256: normalized, evidence-bearing Voice-of-X combinations. +-- source_post.voc_type_code remains the imported primary voice. Additional +-- voices require a normalized PROV-O assertion instead of keyword inference. + +begin; + +create table if not exists source_post_voice ( + voice_assignment_id uuid primary key default gen_random_uuid(), + post_id uuid not null references source_post (post_id) on delete cascade, + voice_type_code text not null references common_lookup_value (lookup_code), + is_primary boolean not null default false, + truth_status_code text not null references common_lookup_value (lookup_code), + provenance_assertion_id uuid references provenance_assertion (assertion_id), + effective_from timestamptz not null default now(), + effective_to timestamptz, + recorded_at timestamptz not null default now(), + check (is_primary or provenance_assertion_id is not null), + constraint source_post_voice_effective_interval_check + check (effective_to is null or effective_to >= effective_from) +); + +alter table source_post_voice + add column if not exists voice_assignment_id uuid not null default gen_random_uuid(); +alter table source_post_voice + add column if not exists effective_from timestamptz not null default now(); +alter table source_post_voice + add column if not exists effective_to timestamptz; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass + and conname = 'source_post_voice_effective_interval_check' + ) then + alter table source_post_voice + add constraint source_post_voice_effective_interval_check + check (effective_to is null or effective_to >= effective_from); + end if; +end; +$$; + +do $$ +begin + if exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass + and contype = 'p' + and pg_get_constraintdef(oid) <> 'PRIMARY KEY (voice_assignment_id)' + ) then + alter table source_post_voice drop constraint source_post_voice_pkey; + end if; + if not exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass and contype = 'p' + ) then + alter table source_post_voice + add constraint source_post_voice_pkey primary key (voice_assignment_id); + end if; +end; +$$; + +drop index if exists source_post_voice_primary_idx; +create unique index if not exists source_post_voice_current_primary_idx + on source_post_voice (post_id) where is_primary and effective_to is null; + +create unique index if not exists source_post_voice_current_type_idx + on source_post_voice (post_id, voice_type_code) where effective_to is null; + +create index if not exists source_post_voice_type_idx + on source_post_voice (voice_type_code, post_id); + +create or replace function validate_source_post_voice_codes() +returns trigger +language plpgsql +as $$ +begin + if not exists ( + select 1 + from common_lookup_value + where lookup_category = 'voc_type' + and lookup_code = new.voice_type_code + ) then + raise exception 'source_post_voice requires a voc_type lookup code' + using errcode = '23514'; + end if; + if not exists ( + select 1 + from common_lookup_value + where lookup_category = 'ontology_truth_status' + and lookup_code = new.truth_status_code + ) then + raise exception 'source_post_voice requires an ontology_truth_status lookup code' + using errcode = '23514'; + end if; + if new.is_primary then + perform 1 from source_post where post_id = new.post_id for update; + if exists ( + select 1 + from source_post_voice existing + where existing.post_id = new.post_id + and existing.is_primary + and existing.voice_assignment_id <> new.voice_assignment_id + and tstzrange(existing.effective_from, existing.effective_to, '[)') + && tstzrange(new.effective_from, new.effective_to, '[)') + ) then + raise exception 'source_post_voice primary intervals must not overlap' + using errcode = '23P01'; + end if; + end if; + return new; +end; +$$; + +drop trigger if exists source_post_voice_type_guard on source_post_voice; +create trigger source_post_voice_type_guard +before insert or update on source_post_voice +for each row execute function validate_source_post_voice_codes(); + +update source_post_voice voice + set is_primary = true, + truth_status_code = 'truth_observed', + provenance_assertion_id = null + from source_post post + where voice.post_id = post.post_id + and voice.voice_type_code = post.voc_type_code + and voice.effective_to is null + and not voice.is_primary; + +insert into source_post_voice + (post_id, voice_type_code, is_primary, truth_status_code, effective_from) +select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at + from source_post post + where not exists ( + select 1 from source_post_voice voice + where voice.post_id = post.post_id + and voice.voice_type_code = post.voc_type_code + and voice.effective_to is null + ); + +create or replace function synchronize_source_post_primary_voice() +returns trigger +language plpgsql +as $$ +declare + change_at timestamptz := clock_timestamp(); +begin + update source_post_voice + set effective_to = change_at + where post_id = new.post_id + and is_primary + and effective_to is null + and voice_type_code <> new.voc_type_code; + + insert into source_post_voice + (post_id, voice_type_code, is_primary, truth_status_code, effective_from) + values ( + new.post_id, + new.voc_type_code, + true, + 'truth_observed', + case when tg_op = 'INSERT' then new.created_at else change_at end + ) + on conflict (post_id, voice_type_code) where effective_to is null do update + set is_primary = true, + truth_status_code = 'truth_observed', + provenance_assertion_id = null, + effective_from = excluded.effective_from; + return new; +end; +$$; + +drop trigger if exists source_post_primary_voice_sync on source_post; +drop trigger if exists source_post_primary_voice_sync_insert on source_post; +create trigger source_post_primary_voice_sync_insert +after insert on source_post +for each row execute function synchronize_source_post_primary_voice(); + +create trigger source_post_primary_voice_sync +after update of voc_type_code on source_post +for each row +when (old.voc_type_code is distinct from new.voc_type_code) +execute function synchronize_source_post_primary_voice(); + +commit; diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 680b78d83..5f672b035 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -455,3 +455,20 @@ def test_post_voice_additions_do_not_invent_counterparty_relationships() -> None """ADR 0246 keeps source-post voice and named-organization relations distinct.""" for code in ("rel_voe", "rel_vob", "rel_vor", "rel_voi", "rel_voso", "rel_vops"): assert iri_for_lookup_code(code) is None + + +def test_voice_combinations_use_qualified_assignments() -> None: + """ADR 0256 composes atomic voices without Cartesian-product terms.""" + graph = load_ontology() + + assert (LW.VoiceAssignment, RDF.type, OWL.Class) in graph + assert ( + LW.VoiceAssignment, + RDFS.subClassOf, + URIRef("http://www.w3.org/ns/prov#Entity"), + ) in graph + assert (LW.hasVoiceAssignment, RDFS.domain, LW.Post) in graph + assert (LW.hasVoiceAssignment, RDFS.range, LW.VoiceAssignment) in graph + assert (LW.assignedVoiceType, RDFS.domain, LW.VoiceAssignment) in graph + assert (LW.assignedVoiceType, RDFS.range, SKOS.Concept) in graph + assert (LW.primaryVoiceAssignment, RDFS.range, XSD.boolean) in graph diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index ca1772a96..3f6165745 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timedelta, timezone import pytest @@ -24,6 +25,7 @@ ) from lineageweave.ontology import LW, ontology_node_iri from lineageweave.ontology_neighborhood import ( + HARD_MAXIMUM_NODES, PROPERTY_AFFILIATED_WITH, PROPERTY_CO_MENTIONED_WITH, PROPERTY_MENTIONS, @@ -39,12 +41,12 @@ TRUTH_INFERRED, TRUTH_OBSERVED, TRUTH_PROPOSED, - HARD_MAXIMUM_NODES, NeighborhoodFact, OntologyGraphEdge, - OntologyNodeMetadata, OntologyNeighborhood, OntologyNeighborhoodError, + OntologyNodeMetadata, + OntologyVoiceAssignment, assemble_ontology_neighborhood, canonicalize_property_code, fact_from_knowledge_graph_edge, @@ -894,6 +896,69 @@ def test_node_bound_truncation_drops_cursor_and_jsonld_rejects_dangling() -> Non assert rows == () +def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: + """One authorized post exports the same qualified voice through both projections.""" + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + ) + assignment = OntologyVoiceAssignment( + post_id=POST_ID, + voice_type_code="vops", + voice_type_iri=str(LW.voiceOfProcessType), + voice_type_label="Voice of Process", + is_primary=False, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + provenance_reference="Evidence-backed additional voice", + evidence_post_id=POST_ID, + ) + neighborhood = replace(neighborhood, voice_assignments=(assignment,)) + + row = neighborhood.exact_value_rows()[0] + assert row["property_code"] == "hasVoiceAssignment" + assert row["target_label"] == "Voice of Process" + assert row["evidence_post_id"] == POST_ID + assert row["evidence_count"] == "1" + graph = neighborhood.jsonld_document()["@graph"] + assignment_iri = str(LW[f"voice-assignment/{POST_ID}/vops"]) + projected = next(item for item in graph if item.get("@id") == assignment_iri) + post_projection = next( + item + for item in graph + if item.get("@id") == ontology_node_iri(NODE_POST, POST_ID) + and str(LW.hasVoiceAssignment) in item + ) + assert post_projection[str(LW.hasVoiceAssignment)] == [{"@id": assignment_iri}] + assert projected[str(LW.assignedVoiceType)] == {"@id": str(LW.voiceOfProcessType)} + assert projected[str(LW.voiceAssignmentEvidence)] == { + "@id": ontology_node_iri(NODE_POST, POST_ID) + } + assert projected["prov:wasDerivedFrom"] == { + "@id": ontology_node_iri(NODE_POST, POST_ID) + } + + hidden_evidence = replace(assignment, evidence_post_id=None) + hidden_row = replace( + neighborhood, voice_assignments=(hidden_evidence,) + ).exact_value_rows()[0] + assert hidden_row["evidence_post_id"] == "" + assert hidden_row["evidence_count"] == "0" + hidden_projection = next( + item + for item in replace(neighborhood, voice_assignments=(hidden_evidence,)) + .jsonld_document()["@graph"] + if item.get("@id") == assignment_iri + ) + assert str(LW.voiceAssignmentEvidence) not in hidden_projection + assert "prov:wasDerivedFrom" not in hidden_projection + + with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): + replace(assignment, recorded_at=T0.replace(tzinfo=None)) + + def test_node_bound_truncation_keeps_nearer_hop_over_farther_alphabetically_earlier_type() -> None: """Trim by BFS distance, not by the raw "type:id" key string. diff --git a/tests/test_ontology_neighborhood_cutoff_and_ids.py b/tests/test_ontology_neighborhood_cutoff_and_ids.py index ef75d87b7..164e9e875 100644 --- a/tests/test_ontology_neighborhood_cutoff_and_ids.py +++ b/tests/test_ontology_neighborhood_cutoff_and_ids.py @@ -97,6 +97,7 @@ def fake_assemble(**kwargs: object) -> object: monkeypatch.setattr(ingestion, "_load_skos_facts", fake_empty) monkeypatch.setattr(ingestion, "_load_labels", fake_labels) monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "_load_voice_assignments", fake_empty) monkeypatch.setattr(ingestion, "assemble_ontology_neighborhood", fake_assemble) result = asyncio.run( diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 5f6278c3c..333d91df9 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -12,6 +12,7 @@ _load_labels, _load_node_metadata, _load_skos_facts, + _load_voice_assignments, focus_catalog_exists, neighborhood_error_detail, neighborhood_error_http_status, @@ -33,11 +34,11 @@ NODE_TEAM, ) from lineageweave.ontology_neighborhood import ( + PROPERTY_AFFILIATED_WITH, + TRUTH_OBSERVED, NeighborhoodFact, OntologyNeighborhoodError, OntologyNodeMetadata, - PROPERTY_AFFILIATED_WITH, - TRUTH_OBSERVED, assemble_ontology_neighborhood, fact_from_knowledge_graph_edge, ) @@ -921,6 +922,18 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: "person_affiliation affiliation": [post_row], "post_team_mention": [post_row], "select post_id from source_post where post_id = any": [{"post_id": POST_ID}], + "from source_post_voice voice": [ + { + "post_id": POST_ID, + "voice_type_code": "voc", + "lookup_label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": False, + "evidence_post_id": None, + } + ], } post_neighborhood = asyncio.run( visible_ontology_neighborhood( @@ -940,6 +953,10 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: ) ) assert person_neighborhood.focus_node_id == PERSON_ID + assert [ + assignment.voice_type_code + for assignment in person_neighborhood.voice_assignments + ] == ["voc"] corp_neighborhood = asyncio.run( visible_ontology_neighborhood( ScriptedConn({**shared_labels, "select 1 from corporate_entity": {"ignored": 1}}), @@ -974,6 +991,60 @@ def test_load_labels_ignores_unknown_node_types() -> None: assert labels == {} +def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() -> None: + """Qualified voices load only from the authorized post and hide assertion ids.""" + conn = ScriptedConn( + { + "from source_post_voice voice": [ + { + "post_id": POST_ID, + "voice_type_code": "voc", + "lookup_label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": False, + "evidence_post_id": None, + }, + { + "post_id": POST_ID, + "voice_type_code": "vops", + "lookup_label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": True, + "evidence_post_id": POST_ID, + }, + ] + } + ) + + assignments = asyncio.run( + _load_voice_assignments(conn, [POST_ID], knowledge_cutoff=T0, snapshot_at=T0) + ) + + assert [assignment.voice_type_code for assignment in assignments] == ["voc", "vops"] + assert assignments[0].provenance_reference == "Imported primary voice" + assert assignments[1].provenance_reference == "Evidence-backed additional voice" + assert assignments[1].evidence_post_id == POST_ID + assert "evidence.node_id = any($1::uuid[])" in conn.calls[0][0] + assert "voice.is_primary or evidence.node_id = any($1::uuid[])" in conn.calls[0][0] + assert "voice.effective_from <= $2" in conn.calls[0][0] + assert "voice.recorded_at <= $3" in conn.calls[0][0] + assert conn.calls[0][1] == ([POST_ID], T0, T0) + + +def test_load_voice_assignments_skips_database_for_no_visible_posts() -> None: + """A non-post-only neighborhood does not issue an empty-array query.""" + conn = ScriptedConn({}) + + assert asyncio.run( + _load_voice_assignments(conn, [], knowledge_cutoff=None, snapshot_at=T0) + ) == () + assert conn.calls == [] + + def test_payload_serializes_optional_validity() -> None: fact = NeighborhoodFact( source_node_type_code=NODE_PERSON, diff --git a/tests/test_ontology_neighborhood_visibility_batch.py b/tests/test_ontology_neighborhood_visibility_batch.py index 7336f205a..42bc769aa 100644 --- a/tests/test_ontology_neighborhood_visibility_batch.py +++ b/tests/test_ontology_neighborhood_visibility_batch.py @@ -179,6 +179,9 @@ async def fake_visible_nodes( async def fake_no_skos(*_args: object) -> list[object]: return [] + async def fake_no_voices(*_args: object, **_kwargs: object) -> list[object]: + return [] + async def fake_labels( *_args: object, **_kwargs: object ) -> dict[tuple[str, str], str]: @@ -203,6 +206,7 @@ async def fetchval(self, _sql: str, *_args: object) -> str: monkeypatch.setattr(ingestion, "_load_skos_facts", fake_no_skos) monkeypatch.setattr(ingestion, "_load_labels", fake_labels) monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "_load_voice_assignments", fake_no_voices) neighborhood = asyncio.run( ingestion.visible_ontology_neighborhood( diff --git a/tests/test_ontology_neighborhood_windowing.py b/tests/test_ontology_neighborhood_windowing.py index 646b24382..846e7f5e5 100644 --- a/tests/test_ontology_neighborhood_windowing.py +++ b/tests/test_ontology_neighborhood_windowing.py @@ -447,6 +447,7 @@ def fake_mint(**kwargs): monkeypatch.setattr(ingestion, "_load_skos_facts", fake_skos) monkeypatch.setattr(ingestion, "_load_labels", fake_labels) monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "_load_voice_assignments", fake_skos) monkeypatch.setattr(ingestion, "verify_source_cursor", fake_verify) monkeypatch.setattr(ingestion, "mint_source_cursor", fake_mint) diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py index 6c74ebc63..f16a94d8a 100644 --- a/tests/test_ontology_shapes.py +++ b/tests/test_ontology_shapes.py @@ -19,9 +19,9 @@ from pathlib import Path import pytest +from pyshacl import validate as shacl_validate from rdflib import Graph, Literal, Namespace, URIRef from rdflib.namespace import RDF, XSD -from pyshacl import validate as shacl_validate from lineageweave.ontology import project_project_mention_rdf @@ -73,6 +73,11 @@ def _representative_projection() -> Graph: Literal("2026-08-25T01:23:45+00:00", datatype=XSD.dateTime), ) ) + voice_assignment = URIRef(LW + "voice-assignment/post-alpha/voc") + data.add((voice_assignment, RDF.type, LWn.VoiceAssignment)) + data.add((voice_assignment, LWn.assignedVoiceType, LWn.voiceOfCustomerType)) + data.add((voice_assignment, LWn.primaryVoiceAssignment, Literal(True))) + data.add((voice_assignment, LWn.voiceAssignmentEvidence, post)) person = URIRef(LW + "person-okonkwo") data.add((person, RDF.type, LWn.Person)) data.add((person, LWn.personName, Literal("Sam Okonkwo"))) @@ -104,6 +109,19 @@ def _representative_projection() -> Graph: return data +def test_voice_assignment_requires_source_evidence() -> None: + """A projected Voice assignment without its authorized source post fails closed.""" + data = _representative_projection() + LWn = Namespace(LW) + assignment = URIRef(LW + "voice-assignment/post-alpha/voc") + data.remove((assignment, LWn.voiceAssignmentEvidence, None)) + + conforms, report = _conforms(data) + + assert conforms is False + assert "voice assignment evidence" in report.lower() + + def test_shipped_shapes_conform_to_shacl_specification() -> None: """The shapes artifact itself must be valid SHACL before it may gate anything else -- validated with no data graph attached to it. diff --git a/tests/test_post_filter_options.py b/tests/test_post_filter_options.py index 076f0ec25..6ac4dc570 100644 --- a/tests/test_post_filter_options.py +++ b/tests/test_post_filter_options.py @@ -56,7 +56,8 @@ def test_post_filter_options_use_one_authorized_source_scan() -> None: query, args = conn.calls[0] assert "cross join lateral" in query assert "('post_visibility', post.visibility_code)" in query - assert "('voc_type', post.voc_type_code)" in query + assert "left join source_post_voice voice" in query + assert "('voc_type', coalesce(voice.voice_type_code, post.voc_type_code))" in query assert "post.corporate_entity_id::text = any($1::text[])" in query assert "post.process_unit_id::text = any($2::text[])" in query assert "nullif(btrim(post.source_draft_code), '') is null" in query diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py new file mode 100644 index 000000000..75a0c48d0 --- /dev/null +++ b/tests/test_source_post_voice_ingestion.py @@ -0,0 +1,113 @@ +"""Evidence-bearing additional Voice persistence tests (ADR 0256).""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from backend.app.source_post_voice_ingestion import ( + PrimaryVoiceAssignmentError, + persist_additional_voice_assignment, +) + + +class _Connection: + """Record the ordered SQL contract without requiring a live database.""" + + def __init__( + self, *, primary_conflict: bool = False, existing_evidence: bool = False + ) -> None: + self.primary_conflict = primary_conflict + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.fetchvals = iter( + ["evidence-resource", "assignment-resource", "assertion"] + if existing_evidence + else [ + None, + "evidence-resource", + "evidence-resource", + "assignment-resource", + "assertion", + ] + ) + + @asynccontextmanager + async def transaction(self): + """Expose the async transaction protocol used by asyncpg.""" + yield + + async def execute(self, query: str, *args: object) -> None: + """Record an execute call.""" + self.calls.append((query, args)) + + async def fetchval(self, query: str, *args: object) -> Any: + """Record and return the next scripted scalar.""" + self.calls.append((query, args)) + return next(self.fetchvals) + + async def fetchrow(self, query: str, *args: object) -> dict[str, str] | None: + """Return no row only when the imported primary blocks the write.""" + self.calls.append((query, args)) + return None if self.primary_conflict else {"voice_type_code": str(args[1])} + + +def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> None: + """The write derives an assignment from a bound evidence Post resource.""" + conn = _Connection() + + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + sql = "\n".join(query for query, _args in conn.calls) + assert "prov_was_derived_from" in sql + assert "where effective_to is null" in sql + assert "where not source_post_voice.is_primary" in sql + assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( + conn.calls + ) + + +def test_additional_voice_cannot_demote_imported_primary() -> None: + """The current primary remains owned by source_post.voc_type_code.""" + conn = _Connection(primary_conflict=True) + + with pytest.raises(PrimaryVoiceAssignmentError): + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="voc", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + +def test_existing_evidence_binding_is_typed_as_a_prov_entity() -> None: + """A legacy Post binding gains the type required by PROV range checks.""" + conn = _Connection(existing_evidence=True) + + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + assert any( + "provenance_resource_type" in query and args == ("evidence-resource",) + for query, args in conn.calls + ) diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py new file mode 100644 index 000000000..05265bf10 --- /dev/null +++ b/tests/test_source_post_voice_schema.py @@ -0,0 +1,53 @@ +"""Static contract tests for ADR 0256's normalized Voice-of-X associations.""" + +from __future__ import annotations + +from pathlib import Path + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0237_source_post_voice_combination.sql" +) + + +def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: + """Additional voices require provenance while the imported primary remains mirrored.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "create table if not exists source_post_voice" in sql + assert "voice_assignment_id uuid primary key" in sql + assert "effective_to timestamptz" in sql + assert "where is_primary and effective_to is null" in sql + assert "where effective_to is null" in sql + assert "check (is_primary or provenance_assertion_id is not null)" in sql + assert "truth_status_code text not null" in sql + assert "effective_from timestamptz not null" in sql + assert "true, 'truth_observed'" in sql + assert "where is_primary" in sql + assert "select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at" in sql + assert "change_at timestamptz := clock_timestamp()" in sql + assert "case when tg_op = 'insert' then new.created_at else change_at end" in sql + assert "after insert on source_post" in sql + assert "after update of voc_type_code on source_post" in sql + assert "when (old.voc_type_code is distinct from new.voc_type_code)" in sql + assert "on conflict (post_id, voice_type_code) where effective_to is null do update" in sql + assert "and not voice.is_primary" in sql + assert "set effective_to = change_at" in sql + assert "delete from source_post_voice" not in sql + assert "primary intervals must not overlap" in sql + assert "using errcode = '23p01'" in sql + assert "before insert or update on source_post_voice" in sql + assert "where lookup_category = 'voc_type'" in sql + assert "where lookup_category = 'ontology_truth_status'" in sql + assert "errcode = '23514'" in sql + assert sql.count("truth_status_code = 'truth_observed'") == 2 + assert sql.count("provenance_assertion_id = null") == 2 + + +def test_voice_combination_migration_uses_no_compound_or_inferred_voice_codes() -> None: + """Composition reuses governed atomic codes instead of minting pair codes or heuristics.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "insert into common_lookup_value" not in sql + assert "confidence" not in sql diff --git a/tests/test_source_state_serialization.py b/tests/test_source_state_serialization.py index 4eee0f5de..661c4e2c5 100644 --- a/tests/test_source_state_serialization.py +++ b/tests/test_source_state_serialization.py @@ -1,6 +1,7 @@ -from datetime import datetime, timezone +import asyncio +from datetime import UTC, datetime -from backend.app.main import _serialize_post +from backend.app.main import _load_post_voice_types, _serialize_post def test_source_state_codes_are_serialized_without_inference() -> None: @@ -21,7 +22,7 @@ def test_source_state_codes_are_serialized_without_inference() -> None: "source_sales_pool_code": "POOL-1", "source_customer_code": "CUSTOMER-1", "source_project_code": "PROJECT-1", - "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "created_at": datetime(2026, 1, 1, tzinfo=UTC), }, {"voc": "Voice of Customer", "public": "Public"}, ) @@ -34,3 +35,77 @@ def test_source_state_codes_are_serialized_without_inference() -> None: assert payload["source_author_code"] == "author-1" assert payload["source_customer_code"] == "CUSTOMER-1" assert payload["source_project_code"] == "PROJECT-1" + + +def test_voice_combinations_are_serialized_without_internal_assertion_ids() -> None: + """A post exposes qualified voice evidence state, not provenance primary keys.""" + voice_types = [ + { + "code": "voc", + "label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "evidence_available": False, + }, + { + "code": "vops", + "label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + }, + ] + payload = _serialize_post( + { + "post_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "post_title": "Synthetic combined signal", + "voc_type_code": "voc", + "visibility_code": "public", + "voice_types": voice_types, + "created_at": datetime(2026, 1, 1, tzinfo=UTC), + } + ) + + assert payload["voice_types"] == voice_types + assert all("provenance_assertion_id" not in voice for voice in payload["voice_types"]) + + +def test_voice_loader_projects_evidence_availability_not_assertion_ids() -> None: + """The read boundary returns a boolean evidence cue and keeps internal ids private.""" + + class Connection: + async def fetch( + self, query: str, post_id: str, effective_cutoff: datetime + ) -> list[dict[str, object]]: + assert "provenance_assertion_id is not null as evidence_available" in query + assert "voice.effective_from <= $2" in query + assert "$2 < voice.effective_to" in query + assert post_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + assert effective_cutoff == datetime(2026, 1, 1, tzinfo=UTC) + return [ + { + "voice_type_code": "vops", + "lookup_label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + } + ] + + rows = asyncio.run( + _load_post_voice_types( # type: ignore[arg-type] + Connection(), + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + datetime(2026, 1, 1, tzinfo=UTC), + ) + ) + + assert rows == [ + { + "code": "vops", + "label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + } + ]