diff --git a/backend/app/demo_scope.py b/backend/app/demo_scope.py index 811a0e673..964d89cd6 100644 --- a/backend/app/demo_scope.py +++ b/backend/app/demo_scope.py @@ -40,6 +40,10 @@ async def has_real_source_context( or nullif(btrim(source_post.source_process_unit_name), '') is not null or nullif(btrim(source_post.source_sales_pool_code), '') is not null or nullif(btrim(source_post.source_sales_pool_name), '') is not null + or nullif(btrim(source_post.source_order_pool_code), '') is not null + or nullif(btrim(source_post.source_sales_order_code), '') is not null + or source_post.source_sales_order_item_number is not null + or nullif(btrim(source_post.source_inspection_point_code), '') is not null or nullif(btrim(source_post.source_customer_code), '') is not null or nullif(btrim(source_post.source_customer_name), '') is not null or nullif(btrim(source_post.source_project_code), '') is not null @@ -72,6 +76,10 @@ async def fetch_demo_corporate_entity_ids(conn: asyncpg.Connection) -> set[str]: or nullif(btrim(real_post.source_process_unit_name), '') is not null or nullif(btrim(real_post.source_sales_pool_code), '') is not null or nullif(btrim(real_post.source_sales_pool_name), '') is not null + or nullif(btrim(real_post.source_order_pool_code), '') is not null + or nullif(btrim(real_post.source_sales_order_code), '') is not null + or real_post.source_sales_order_item_number is not null + or nullif(btrim(real_post.source_inspection_point_code), '') is not null or nullif(btrim(real_post.source_customer_code), '') is not null or nullif(btrim(real_post.source_customer_name), '') is not null or nullif(btrim(real_post.source_project_code), '') is not null diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py index 736a8ecd6..99ab0766f 100644 --- a/backend/app/five_w1h_ingestion.py +++ b/backend/app/five_w1h_ingestion.py @@ -35,7 +35,8 @@ async def load_five_w1h_slots( linked_titles: list[str] = [] if candidate_ids: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + "select post_id, post_title, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code " "from source_post where post_id = any($1::uuid[])", candidate_ids, ) diff --git a/backend/app/global_ask_history.py b/backend/app/global_ask_history.py index d0fd52309..a509d45d9 100644 --- a/backend/app/global_ask_history.py +++ b/backend/app/global_ask_history.py @@ -112,7 +112,8 @@ async def _visible_post_ids( rows = await conn.fetch( f""" select relation.{id_column}::text as post_id, relation.{ordinal_column} as ordinal, - post.post_title, post.visibility_code, post.corporate_entity_id + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code from {table} relation join source_post post on post.post_id = relation.{id_column} where relation.global_ask_session_id = $1 diff --git a/backend/app/issue_ticket_ingestion.py b/backend/app/issue_ticket_ingestion.py index 021a3c398..4831322be 100644 --- a/backend/app/issue_ticket_ingestion.py +++ b/backend/app/issue_ticket_ingestion.py @@ -139,6 +139,7 @@ async def fetch_upcoming_commitments(conn: asyncpg.Connection) -> list[dict[str, "issue_ticket.commitment_summary, issue_ticket.created_at, " "issue_ticket.updated_at, " "p.post_title, p.visibility_code, p.corporate_entity_id, " + "p.author_account_id, p.source_detail_state_code, " f"({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context " "from issue_ticket " "join source_post p on p.post_id = issue_ticket.post_id " @@ -154,6 +155,8 @@ async def fetch_upcoming_commitments(conn: asyncpg.Connection) -> list[dict[str, "post_title": row["post_title"], "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), } for row in rows diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 82e57b636..f8fc38915 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -35,6 +35,7 @@ select_related_nodes, ) from lineageweave.ontology import ontology_annotations, semantic_predicate_annotations +from lineageweave.source_lineage_hints import source_lineage_hints _GRAPH_PROJECTION_LOCK_KEY = "lineageweave:knowledge_graph_projection" @@ -60,6 +61,7 @@ "quality_assessment": "https://contextualwisdomlab.github.io/lineageweave/ontology#QualityAssessment", "risk_statement": "https://contextualwisdomlab.github.io/lineageweave/ontology#RiskStatement", "organization_context": "https://contextualwisdomlab.github.io/lineageweave/ontology#OrganizationContext", + "source_observation": "https://contextualwisdomlab.github.io/lineageweave/ontology#Observation", } @@ -283,7 +285,8 @@ async def visible_mention_post_ids( # Safe SQL: the eligibility predicate is an immutable schema fragment; person id is bound. rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select post.post_id, post.visibility_code, post.corporate_entity_id + select post.post_id, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code from combined_post_person_mention mention join source_post post on post.post_id = mention.post_id where mention.person_id = $1 @@ -304,7 +307,8 @@ async def visible_affiliation_post_ids( rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" select distinct post.post_id, post.visibility_code, - post.corporate_entity_id, post.created_at + post.corporate_entity_id, post.author_account_id, + post.source_detail_state_code, post.created_at from source_post post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} and post.post_id in ( @@ -334,7 +338,8 @@ async def visible_team_mention_post_ids( # Safe SQL: the eligibility predicate is an immutable schema fragment; team id is bound. rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select post.post_id, post.visibility_code, post.corporate_entity_id + select post.post_id, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code from post_team_mention mention join source_post post on post.post_id = mention.post_id where mention.team_id = $1 @@ -666,6 +671,81 @@ def semantic_key(node_type: str, name: str) -> str: "evidence_post_ids": [post_id], } ) + + source_row = await conn.fetchrow( + """ + select source_customer_code, source_order_pool_code, + source_sales_order_code, source_sales_order_item_number, + source_stage_code, source_detail_state_code, + source_inspection_point_code, source_deleted_flag + from source_post + where post_id = $1 + """, + post_id, + ) + if source_row is not None: + source_values = dict(source_row) + observed_source_fields = any( + value is not None and (not isinstance(value, str) or bool(value.strip())) + for value in source_values.values() + ) + if observed_source_fields: + source_hints = source_lineage_hints( + customer_code=source_row["source_customer_code"], + order_pool_code=source_row["source_order_pool_code"], + sales_order_code=source_row["source_sales_order_code"], + sales_order_item_number=source_row["source_sales_order_item_number"], + stage_code=source_row["source_stage_code"], + detail_state_code=source_row["source_detail_state_code"], + inspection_point_code=source_row["source_inspection_point_code"], + deleted_flag=source_row["source_deleted_flag"], + ) + source_observations = [ + ( + "commercial_context", + f"Commercial context: {source_hints['commercial_context_code']}", + "combination=" + f"{source_hints['combination_code']}; " + f"present_fields={','.join(source_hints['present_fields']) or 'none'}; " + "inference=inferred; provenance=source_post.field_presence", + ), + ( + "lifecycle_vector", + f"Lifecycle vector: {source_hints['lifecycle_vector']}", + "raw_codes_only; provenance=source_post.lifecycle_fields", + ), + ] + predicate = semantic_predicate_annotations("prov_was_derived_from") + for kind, label, evidence_text in source_observations: + digest = hashlib.sha256( + f"{post_id}\0{kind}\0{label}".encode() + ).hexdigest()[:16] + observation_key = f"source-observation:{post_id}:{digest}" + nodes.setdefault( + observation_key, + { + "id": observation_key, + "node_type_code": "semantic_source_observation", + "node_id": observation_key, + "label": label, + "ontology_iri": _SEMANTIC_NODE_CLASS_IRIS["source_observation"], + "ontology_label": "Observation", + "is_focus": False, + "is_evidence_text_node": True, + }, + ) + edges.append( + { + "source": observation_key, + "target": node_key(NODE_POST, post_id), + "edge_type_code": "prov_was_derived_from", + "ontology_iri": predicate.get("ontology_iri"), + "ontology_label": predicate.get("ontology_label", "Was derived from"), + "confidence": 1.0, + "evidence_text": evidence_text, + "evidence_post_ids": [post_id], + } + ) return { "post_id": post_id, "nodes": list(nodes.values()), diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index f1e76d495..21b8e88d7 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -98,7 +98,8 @@ async def visible_lineage_graph( """ posts = await conn.fetch( "select post_id, post_title, voc_type_code, visibility_code, " - "corporate_entity_id, process_unit_id, thread_group_key, created_at " + "corporate_entity_id, author_account_id, source_detail_state_code, " + "process_unit_id, thread_group_key, created_at " f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}" ) visible_all = [row for row in posts if can_see_post(row)] diff --git a/backend/app/main.py b/backend/app/main.py index 17bccb4ea..eddc9a7e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,9 @@ ABAC gate, evaluated per row on top of the RBAC gate: a source_post is visible if it is public, or if it is private and the requesting account is -affiliated with the post's owning corporate_entity_id. abac_policy's +affiliated with the post's owning corporate_entity_id. A W source-detail row +is raw-source-visible only to its author or post_admin; derived readers use a +separate eligibility boundary that excludes W. abac_policy's condition_expression column is reserved for a future, richer per-policy DSL (documented in migrations/0001_initial_schema.sql); Phase 1 implements exactly this one fixed rule directly, since it is the only rule the @@ -85,6 +87,7 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints +from lineageweave.source_lineage_hints import source_lineage_hints from lineageweave.ontology import LW from lineageweave.rankweave_client import build_rankweave_client @@ -192,7 +195,10 @@ ) from backend.app.post_eligibility import ( SOURCE_POST_ELIGIBILITY_SQL, - SOURCE_POST_VISIBILITY_SQL, + SOURCE_POST_READER_ELIGIBILITY_SQL, + WRITING_SOURCE_DETAIL_STATE_CODE, + normalize_source_detail_state_code, + source_post_state_visibility_sql, ) from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, @@ -427,12 +433,24 @@ def _rankweave_client(): def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: - """ABAC: public rows are visible; private rows require same-corp affiliation.""" + """ABAC: W is author/admin-only; other rows use public/corp visibility.""" + state_code = normalize_source_detail_state_code(post.get("source_detail_state_code")) + if state_code == WRITING_SOURCE_DETAIL_STATE_CODE: + return account.has_permission(_POST_ADMIN) or str(post["author_account_id"]) == account.user_account_id if post["visibility_code"] == "public": return True return str(post["corporate_entity_id"]) in account.corporate_entity_ids +def _can_use_post_for_analysis(account: CurrentAccount, post: asyncpg.Record) -> bool: + """Derived features consume only non-W authorized source posts.""" + return ( + normalize_source_detail_state_code(post.get("source_detail_state_code")) + != WRITING_SOURCE_DETAIL_STATE_CODE + and _can_see_post(account, post) + ) + + def _is_synthetic_demo_member(member: dict[str, Any], demo_entity_ids: set[str]) -> bool: """Identify one pure seed row without hiding real rows sharing its entity.""" return bool(demo_entity_ids) and member["corporate_entity_id"] in demo_entity_ids and not bool( @@ -456,7 +474,9 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) "visibility_code": visibility, "visibility_label": resolved.get(visibility, visibility), "source_stage_code": post.get("source_stage_code"), - "source_detail_state_code": post.get("source_detail_state_code"), + "source_detail_state_code": normalize_source_detail_state_code( + post.get("source_detail_state_code") + ), "source_draft_code": post.get("source_draft_code"), "source_deleted_flag": post.get("source_deleted_flag"), "publication_state_code": _publication_state_code(post), @@ -469,12 +489,26 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) "source_process_unit_catalog_name": post.get("source_process_unit_catalog_name"), "source_sales_pool_code": post.get("source_sales_pool_code"), "source_sales_pool_name": post.get("source_sales_pool_name"), + "source_order_pool_code": post.get("source_order_pool_code"), + "source_sales_order_code": post.get("source_sales_order_code"), + "source_sales_order_item_number": post.get("source_sales_order_item_number"), + "source_inspection_point_code": post.get("source_inspection_point_code"), "source_customer_code": post.get("source_customer_code"), "source_customer_name": post.get("source_customer_name"), "source_project_code": post.get("source_project_code"), "source_project_name": post.get("source_project_name"), "source_system_code": post.get("source_system_code"), "source_record_key": post.get("source_record_key"), + "source_lineage_hints": source_lineage_hints( + customer_code=post.get("source_customer_code"), + order_pool_code=post.get("source_order_pool_code"), + sales_order_code=post.get("source_sales_order_code"), + sales_order_item_number=post.get("source_sales_order_item_number"), + stage_code=post.get("source_stage_code"), + detail_state_code=post.get("source_detail_state_code"), + inspection_point_code=post.get("source_inspection_point_code"), + deleted_flag=post.get("source_deleted_flag"), + ), "post_body_excerpt": post.get("post_body_excerpt"), "post_body_truncated": post.get("post_body_truncated", False), "project_evidence": project_evidence, @@ -554,9 +588,12 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor async def _post_filter_options( - conn: asyncpg.Connection, corporate_entity_ids: frozenset[str] -) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + conn: asyncpg.Connection, account: CurrentAccount +) -> tuple[list[dict[str, str]], list[dict[str, str]], list[dict[str, str]]]: """Return every authorized filter value, not only values on the current page.""" + state_visibility_sql = source_post_state_visibility_sql( + "post", corporate_param=1, account_param=2, admin_param=3 + ) visibility_sql = f""" select distinct post.visibility_code as code, coalesce(lookup.lookup_label, post.visibility_code) as label, @@ -565,8 +602,8 @@ async def _post_filter_options( left join common_lookup_value lookup on lookup.lookup_category = 'post_visibility' and lookup.lookup_code = post.visibility_code - where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ type_sql = f""" @@ -577,20 +614,45 @@ async def _post_filter_options( left join common_lookup_value lookup on lookup.lookup_category = 'voc_type' and lookup.lookup_code = post.voc_type_code - where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ + detail_state_sql = f""" + select distinct upper(btrim(post.source_detail_state_code)) as code, + upper(btrim(post.source_detail_state_code)) as label + from source_post post + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} + and nullif(btrim(post.source_detail_state_code), '') is not null + order by code + """ # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - visibility_sql, list(corporate_entity_ids) + visibility_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), ) # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. type_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - type_sql, list(corporate_entity_ids) + type_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), + ) + # Detail-state codes intentionally remain raw here; the UI owns the + # product-language explanation and preserves unknown source codes. + # Safe SQL: a closed lookup statement identical in shape to visibility_sql/type_sql above; entity ids remain asyncpg parameters. + detail_state_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + detail_state_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), ) return ( [{"code": row["code"], "label": row["label"]} for row in type_rows], + [{"code": row["code"], "label": row["label"]} for row in detail_state_rows], [{"code": row["code"], "label": row["label"]} for row in visibility_rows], ) @@ -731,18 +793,40 @@ async def update_me_preferences( } -def _customer_master_scope_facets(row: asyncpg.Record) -> list[str]: +def _customer_master_scope_facets( + row: asyncpg.Record, observed_hierarchy_ids: set[str] +) -> list[str]: """Repeatable, provenance-bearing facets for one Customer Master row.""" facets = [ _AFFILIATION_SCOPE_CODE_TO_FACET[code] for code in row["scope_codes"] if code in _AFFILIATION_SCOPE_CODE_TO_FACET ] + if str(row["corporate_entity_id"]) in observed_hierarchy_ids: + facets.append("observed_hierarchy") if row["is_observed_organization"]: facets.append("observed_organization") return facets +def _observed_hierarchy_ids(rows: list[asyncpg.Record]) -> set[str]: + """Return authorized ancestors of entities observed in visible posts.""" + rows_by_id = {str(row["corporate_entity_id"]): row for row in rows} + hierarchy_ids: set[str] = set() + for row in rows: + if not row["is_observed_organization"] or row["parent_entity_id"] is None: + continue + parent_id = row["parent_entity_id"] + while parent_id is not None: + parent_key = str(parent_id) + if parent_key in hierarchy_ids: + break + hierarchy_ids.add(parent_key) + parent = rows_by_id.get(parent_key) + parent_id = parent["parent_entity_id"] if parent is not None else None + return hierarchy_ids + + @app.get("/api/customer-master") async def read_customer_master( account: CurrentAccount = Depends(get_current_account), @@ -773,7 +857,7 @@ async def read_customer_master( from source_post where (nullif(btrim(source_customer_code), '') is not null or nullif(btrim(source_customer_name), '') is not null) - and {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')} + and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[])) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} ), ranked as ( select scoped.*, @@ -839,7 +923,7 @@ async def read_customer_master( join user_account author on author.user_account_id = post.author_account_id where post.source_author_code is not null and btrim(post.source_author_code) <> '' - and {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} + and (post.visibility_code = 'public' or post.corporate_entity_id = any($1::uuid[])) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} ), ranked as ( select scoped.*, @@ -1013,11 +1097,12 @@ async def read_customer_master( account.user_account_id, list(synthetic_only_entity_ids), ) + observed_hierarchy_ids = _observed_hierarchy_ids(entity_rows) entity_ids = [row["corporate_entity_id"] for row in entity_rows] source_author_affiliations = await _load_account_affiliation_hints( conn, [str(row["author_account_id"]) for row in source_author_rows], - authorized_entity_ids, + [str(entity_id) for entity_id in entity_ids], ) keyman_rows = await conn.fetch( """ @@ -1027,18 +1112,19 @@ async def read_customer_master( affiliation.affiliated_corporate_entity_id, affiliation.role_title, entity.entity_name - from cataloged_person person - join person_affiliation affiliation on affiliation.person_id = person.person_id - left join corporate_entity entity + from cataloged_person person + join person_affiliation affiliation on affiliation.person_id = person.person_id + left join corporate_entity entity on entity.corporate_entity_id = affiliation.affiliated_corporate_entity_id where affiliation.affiliated_corporate_entity_id = any($1::uuid[]) + and person.person_side_code = 'our_side' order by person.person_name, affiliation.affiliated_organization_name """, - authorized_entity_ids, + entity_ids, ) side_labels = await labels_for_codes(conn, [row["person_side_code"] for row in keyman_rows]) entity_level_labels = await labels_for_codes(conn, [row["entity_level_code"] for row in entity_rows]) - relationship_network = await fetch_relationship_network(conn, authorized_entity_ids) + relationship_network = await fetch_relationship_network(conn, entity_ids) keymen_by_id: dict[str, dict[str, Any]] = {} for row in keyman_rows: @@ -1077,11 +1163,10 @@ async def read_customer_master( "entity_level_label": entity_level_labels.get( row["entity_level_code"], row["entity_level_code"] ), - "scope_facets": sorted(row["scope_facets"]), "parent_entity_id": ( str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None ), - "scope_facets": _customer_master_scope_facets(row), + "scope_facets": _customer_master_scope_facets(row, observed_hierarchy_ids), } for row in entity_rows ], @@ -1197,7 +1282,7 @@ async def read_lineage_graph( async with pool.acquire() as conn: return await visible_lineage_graph( conn, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), limit=limit, focus_post_id=post_id, ) @@ -1225,6 +1310,7 @@ async def list_posts( offset: int = Query(0, ge=0), search: str | None = Query(None, max_length=200), voc_type: list[str] | None = Query(None, max_length=80), + source_detail_state: list[str] | None = Query(None, max_length=80), visibility: str | None = Query(None, max_length=80), sort: Literal["newest", "oldest", "title"] = Query("newest"), account: CurrentAccount = Depends(get_current_account), @@ -1233,9 +1319,17 @@ async def list_posts( """List authorized posts, with semantic evidence search when requested.""" _require_post_read(account) search_term = search.strip() if search and search.strip() else None + voc_type_codes = ( + [code.strip() for code in voc_type if code.strip()] if voc_type else None + ) or None + source_detail_state_codes = ( + [code.strip().upper() for code in source_detail_state if code.strip()] + if source_detail_state + else None + ) or None async with pool.acquire() as conn: - voc_type_options, visibility_options = await _post_filter_options( - conn, account.corporate_entity_ids + voc_type_options, source_detail_state_options, visibility_options = await _post_filter_options( + conn, account ) body_search_ids: list[str] = [] if search_term: @@ -1244,7 +1338,8 @@ async def list_posts( f""" select post_id from source_post - where {SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")} + where {source_post_state_visibility_sql("source_post", corporate_param=4, account_param=2, admin_param=3)} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias="source_post")} and (lower(left(source_post_search_text(post_body), 16384)) like '%' || lower($1) || '%' or to_tsvector('simple', source_post_search_text(post_body)) @@ -1259,6 +1354,9 @@ async def list_posts( post_id """, search_term, + account.user_account_id, + account.has_permission(_POST_ADMIN), + list(account.corporate_entity_ids), ) body_search_ids = [str(row["post_id"]) for row in body_rows] # Safe SQL: page SQL is a closed schema query; every request value is an asyncpg parameter. @@ -1272,21 +1370,23 @@ async def list_posts( post.source_company_code, post.source_company_name, post.source_process_unit_code, post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, post.source_sales_order_code, + post.source_sales_order_item_number, post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, post.source_project_name, post.source_system_code, post.source_record_key, - post.corporate_entity_id, post.created_at, + post.corporate_entity_id, post.author_account_id, post.created_at, case when $1::text is null then 0 when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0 - when post.post_id = any($5::uuid[]) then 1 + when post.post_id = any($6::uuid[]) then 1 else 2 end as search_priority, count(*) over() as total_count from source_post post - where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$2')} - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} + where {source_post_state_visibility_sql("post", corporate_param=2, account_param=10, admin_param=11)} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias="post")} and ( $1::text is null or post.post_title ilike '%' || $1 || '%' @@ -1305,6 +1405,10 @@ async def list_posts( post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, @@ -1335,6 +1439,10 @@ async def list_posts( post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, @@ -1343,7 +1451,7 @@ async def list_posts( ) >= 0.45 ) ) - or post.post_id = any($5::uuid[]) + or post.post_id = any($6::uuid[]) or exists ( select 1 from post_project_mention project where project.post_id = post.post_id @@ -1453,19 +1561,20 @@ async def list_posts( ) ) and ($3::text[] is null or post.voc_type_code = any($3::text[])) - and ($4::text is null or post.visibility_code = $4) + and ($4::text[] is null or coalesce(upper(btrim(post.source_detail_state_code)), '') = any($4::text[])) + and ($5::text is null or post.visibility_code = $5) order by search_priority asc, case - when $1::text is not null and post.post_id = any($5::uuid[]) - then array_position($5::uuid[], post.post_id) + when $1::text is not null and post.post_id = any($6::uuid[]) + then array_position($6::uuid[], post.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(post.post_title, '')) end asc, - case when $8::text = 'oldest' then post.created_at end asc, - case when $8::text in ('newest', 'title') then post.created_at end desc, + case when $9::text = 'title' then lower(coalesce(post.post_title, '')) end asc, + case when $9::text = 'oldest' then post.created_at end asc, + case when $9::text in ('newest', 'title') then post.created_at end desc, post.post_id desc - offset $6 - limit $7 + offset $7 + limit $8 ) select page.*, case @@ -1512,21 +1621,24 @@ async def list_posts( case when $1::text is not null then page.search_priority end asc, case when $1::text is not null and page.search_priority = 1 - then array_position($5::uuid[], page.post_id) + then array_position($6::uuid[], page.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(page.post_title, '')) end asc, - case when $8::text = 'oldest' then page.created_at end asc, - case when $8::text in ('newest', 'title') then page.created_at end desc, + case when $9::text = 'title' then lower(coalesce(page.post_title, '')) end asc, + case when $9::text = 'oldest' then page.created_at end asc, + case when $9::text in ('newest', 'title') then page.created_at end desc, page.post_id desc """, search_term, list(account.corporate_entity_ids), - [code.strip() for code in voc_type if code.strip()] if voc_type else None, + voc_type_codes, + source_detail_state_codes, visibility.strip() if visibility and visibility.strip() else None, body_search_ids, offset, limit, sort, + account.user_account_id, + account.has_permission(_POST_ADMIN), ) visible = [row for row in rows if _can_see_post(account, row)] labels = await _lookup_post_labels(conn, visible) @@ -1537,6 +1649,7 @@ async def list_posts( "limit": limit, "offset": offset, "voc_type_options": voc_type_options, + "source_detail_state_options": source_detail_state_options, "visibility_options": visibility_options, } @@ -1576,14 +1689,16 @@ async def read_post( "source_process_unit_code, source_process_unit_name, " "source_process_unit.process_unit_name as source_process_unit_catalog_name, " "source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, " + "source_sales_order_item_number, source_inspection_point_code, " "source_customer_code, source_customer_name, source_project_code, source_project_name, " - "source_system_code, source_record_key, " + "source_system_code, source_record_key, author_account_id, " "source_post.corporate_entity_id, source_post.created_at " "from source_post " "left join process_unit source_process_unit " "on source_process_unit.process_unit_id = source_post.process_unit_id " "and source_process_unit.corporate_entity_id = source_post.corporate_entity_id " - f"where source_post.post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", + f"where source_post.post_id = $1 and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) if row is None: @@ -1773,6 +1888,7 @@ async def _load_visible_post( """ select source_post.post_id, source_post.post_title, source_post.voc_type_code, source_post.visibility_code, source_post.corporate_entity_id, + source_post.source_detail_state_code, source_post.created_at, source_post.author_account_id, source_post.source_process_unit_code, source_post.source_author_code, source_post.source_company_code, source_post.source_customer_code, @@ -1783,13 +1899,18 @@ async def _load_visible_post( on customer.corporate_entity_id = source_post.corporate_entity_id where source_post.post_id = $1 and """ - f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", + f"{SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found") if not _can_see_post(account, row): raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post") + if normalize_source_detail_state_code(row.get("source_detail_state_code")) == WRITING_SOURCE_DETAIL_STATE_CODE: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "Writing-in-progress posts are not analysis targets.", + ) return row @@ -1809,6 +1930,13 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s source_process_unit.process_unit_name as source_process_unit_catalog_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, + post.source_stage_code, + post.source_detail_state_code, + post.source_deleted_flag, post.source_customer_code, post.source_customer_name, source_customer.entity_name as source_customer_catalog_name, @@ -1848,6 +1976,10 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s "source_process_unit_name", "source_sales_pool_code", "source_sales_pool_name", + "source_order_pool_code", + "source_sales_order_code", + "source_sales_order_item_number", + "source_inspection_point_code", "source_customer_code", "source_customer_name", "source_project_code", @@ -1880,6 +2012,13 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s source_process_unit_catalog_name=first["source_process_unit_catalog_name"], source_sales_pool_code=first["source_sales_pool_code"], source_sales_pool_name=first["source_sales_pool_name"], + source_order_pool_code=first["source_order_pool_code"], + source_sales_order_code=first["source_sales_order_code"], + source_sales_order_item_number=first["source_sales_order_item_number"], + source_inspection_point_code=first["source_inspection_point_code"], + source_stage_code=first["source_stage_code"], + source_detail_state_code=first["source_detail_state_code"], + source_deleted_flag=first["source_deleted_flag"], source_customer_code=first["source_customer_code"], source_customer_name=first["source_customer_name"], source_customer_catalog_name=first["source_customer_catalog_name"], @@ -2004,7 +2143,7 @@ async def read_related_keymen( async with pool.acquire() as conn: if not await person_exists(conn, person_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "person not found") - visible_post_ids = await visible_mention_post_ids(conn, person_id, lambda row: _can_see_post(account, row)) + visible_post_ids = await visible_mention_post_ids(conn, person_id, lambda row: _can_use_post_for_analysis(account, row)) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this person") person = await conn.fetchrow( @@ -2034,7 +2173,7 @@ async def read_related_corporate_entity( if not await corporate_entity_exists(conn, entity_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "corporate entity not found") visible_post_ids = await visible_affiliation_post_ids( - conn, entity_id, lambda row: _can_see_post(account, row) + conn, entity_id, lambda row: _can_use_post_for_analysis(account, row) ) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this entity") @@ -2063,7 +2202,7 @@ async def read_related_team( if not await team_exists(conn, team_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found") visible_post_ids = await visible_team_mention_post_ids( - conn, team_id, lambda row: _can_see_post(account, row) + conn, team_id, lambda row: _can_use_post_for_analysis(account, row) ) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") @@ -2336,6 +2475,7 @@ async def read_post_lineage( # Safe SQL: the eligibility predicate is an immutable schema fragment; candidate ids are bound. fetched = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli "select post_id, post_title, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code, " "btrim(left(source_post_search_text(post_body), 420)) as post_body_excerpt, " "char_length(coalesce(post_body, '')) > 420 as post_body_truncated " f"from source_post where post_id = any($1::uuid[]) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", @@ -2352,7 +2492,7 @@ def _visible_summaries(ids: frozenset[str]) -> list[dict[str, Any]]: "post_body_truncated": rows[post_id_].get("post_body_truncated", False), } for post_id_ in ids - if post_id_ in rows and _can_see_post(account, rows[post_id_]) + if post_id_ in rows and _can_use_post_for_analysis(account, rows[post_id_]) ] return { @@ -2489,7 +2629,7 @@ async def compare_period_groupings( members = [ member for member in row["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2518,7 +2658,7 @@ async def list_period_reports( members = [ member for member in summary["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2552,7 +2692,7 @@ async def read_period_reports( members = [ member for member in report["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2560,15 +2700,37 @@ async def read_period_reports( leftover_pairs = [ pair for pair in report.get("leftover_pairs", []) - if _can_see_post(account, pair) + if _can_use_post_for_analysis(account, pair) and not _is_synthetic_demo_member(pair, demo_entity_ids) ] members = [ - {key: value for key, value in member.items() if key != "has_real_source_context"} + { + key: value + for key, value in member.items() + if key + not in { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } + } for member in members ] leftover_pairs = [ - {key: value for key, value in pair.items() if key != "has_real_source_context"} + { + key: value + for key, value in pair.items() + if key + not in { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } + } for pair in leftover_pairs ] visible.append( @@ -2765,7 +2927,7 @@ async def read_post_five_w1h( return await load_five_w1h_slots( conn, post_id, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), ) @@ -2843,7 +3005,7 @@ async def chat_about_post( "Post chat is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) sources = await gather_chat_sources( - conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() + conn, post_id, lambda row: _can_use_post_for_analysis(account, row), vision_client=_vision_client() ) try: with use_llm_metadata(post_metadata): @@ -2917,7 +3079,7 @@ async def read_ask_conversation( conn, account.user_account_id, conversation_id, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), turn_limit=limit, before_turn_ordinal=before_turn, ) @@ -2951,7 +3113,7 @@ async def ask_agent( async with pool.acquire() as conn: sources = await gather_global_chat_sources( conn, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), account.corporate_entity_ids, question=question, ) @@ -3452,7 +3614,7 @@ async def read_calendar( demo_entity_ids: set[str] = set() if commitments and await has_real_source_context(conn, list(account.corporate_entity_ids)): demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) - visible = [c for c in commitments if _can_see_post(account, c)] + visible = [c for c in commitments if _can_use_post_for_analysis(account, c)] # Once real evidence is visible, the synthetic Demo Corp commitments # (ADR 0001 / ADR 0042) stop appearing beside it. if demo_entity_ids: @@ -3460,7 +3622,14 @@ async def read_calendar( c for c in visible if not _is_synthetic_demo_member(c, demo_entity_ids) ] for c in visible: - del c["visibility_code"], c["corporate_entity_id"], c["has_real_source_context"] + for key in ( + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + ): + c.pop(key, None) return { "events": events, "commitments": visible, @@ -3485,7 +3654,7 @@ async def read_rankings( _require_post_read(account) async with pool.acquire() as conn: posts = await load_visible_ranking_posts( - conn, lambda row: _can_see_post(account, row) + conn, lambda row: _can_use_post_for_analysis(account, row) ) return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 3d8b9080d..2c1f434d1 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -19,8 +19,9 @@ import asyncio import re +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from typing import Any import asyncpg @@ -33,6 +34,7 @@ random_walk_with_restart, select_related_nodes, ) +from lineageweave.ontology import ontology_annotations from lineageweave.post_chat import ( CANONICAL_CHAT_QUESTION, CANONICAL_COMMITMENT_QUESTION, @@ -41,9 +43,9 @@ normalize_chat_question, ) from lineageweave.post_content_normalization import normalize_post_body +from lineageweave.source_lineage_hints import source_lineage_hint_facts from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph -from lineageweave.ontology import ontology_annotations @dataclass(frozen=True) @@ -155,6 +157,10 @@ async def _graph_facts_for_posts( ("source_process_unit_name", "source business unit name (PU)"), ("source_sales_pool_code", "source sales pool"), ("source_sales_pool_name", "source sales pool name"), + ("source_order_pool_code", "source order pool"), + ("source_sales_order_code", "source sales order"), + ("source_sales_order_item_number", "source sales order item"), + ("source_inspection_point_code", "source inspection point"), ("source_customer_code", "source customer code"), ("source_customer_name", "source customer name"), ("source_project_code", "source project code"), @@ -175,7 +181,16 @@ def _source_hint_facts(row: Any) -> tuple[str, ...]: facts.append( f"{label}={str(value).strip()} [provenance=source_post.{field_name}; hint_only]" ) - return tuple(facts) + return tuple(facts) + source_lineage_hint_facts( + customer_code=row.get("source_customer_code"), + order_pool_code=row.get("source_order_pool_code"), + sales_order_code=row.get("source_sales_order_code"), + sales_order_item_number=row.get("source_sales_order_item_number"), + stage_code=row.get("source_stage_code"), + detail_state_code=row.get("source_detail_state_code"), + inspection_point_code=row.get("source_inspection_point_code"), + deleted_flag=row.get("source_deleted_flag"), + ) async def _semantic_facts_for_posts( @@ -348,6 +363,8 @@ async def gather_chat_sources( "source_author_code, source_author_name, source_company_code, source_company_name, " "source_process_unit_code, source_process_unit_name, " "source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, source_sales_order_item_number, " + "source_inspection_point_code, source_stage_code, source_detail_state_code, source_deleted_flag, " "source_customer_code, source_customer_name, source_project_code, " "source_project_name from source_post where post_id = $1", post_id, @@ -379,9 +396,12 @@ async def gather_chat_sources( rows = await conn.fetch( "select post_id, post_title, post_body, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code, " "source_system_code, source_record_key, source_author_code, source_author_name, " "source_company_code, source_company_name, source_process_unit_code, " "source_process_unit_name, source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, source_sales_order_item_number, " + "source_inspection_point_code, source_stage_code, source_deleted_flag, " "source_customer_code, source_customer_name, " "source_project_code, source_project_name " "from source_post where post_id = any($1::uuid[]) " @@ -512,6 +532,10 @@ async def gather_global_chat_sources( source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_order_pool_code, source_sales_order_code, + source_sales_order_item_number, + source_inspection_point_code, source_stage_code, + source_deleted_flag, source_customer_code, source_customer_name, source_project_code, source_project_name) ilike '%' || $1 || '%' @@ -650,9 +674,12 @@ async def gather_global_chat_sources( rows = await conn.fetch( """ select post_id, post_title, post_body, visibility_code, corporate_entity_id, + author_account_id, source_detail_state_code, source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_order_pool_code, source_sales_order_code, source_sales_order_item_number, + source_inspection_point_code, source_stage_code, source_deleted_flag, source_customer_code, source_customer_name, source_project_code, source_project_name from source_post diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index 3f0f6fe54..33b86bafd 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -46,6 +46,18 @@ def post_content_api_status(status_code: str | None, *, content_present: bool) - return "unavailable" +def post_content_summary_status_message(status_code: str | None) -> str: + """Return an honest buyer-facing image-evidence status message. + + A terminal ingestion failure is not still processing. Keeping those two + states distinct lets the popup tell the operator to retry the durable + job instead of implying that waiting will resolve a terminal failure. + """ + if status_code == FAILED: + return "Post summary is unavailable: image evidence ingestion failed; contact an administrator to retry the content job" + return "Post summary is unavailable: image evidence is still being processed" + + async def post_content_is_complete( conn: asyncpg.Connection, post_id: str, @@ -476,21 +488,26 @@ async def republish_queued_post_content_jobs( async with pool.acquire() as conn: rows = await conn.fetch( """ - select post_id, source_body_sha256 + select post_content_ingestion_job.post_id, + post_content_ingestion_job.source_body_sha256 from post_content_ingestion_job - where ( - status_code = $1 - and ( - attempt_count = 0 - or queued_at <= now() - $2::interval + join source_post post on post.post_id = post_content_ingestion_job.post_id + where coalesce(upper(btrim(post.source_detail_state_code)), '') <> 'W' + and ( + ( + post_content_ingestion_job.status_code = $1 + and ( + post_content_ingestion_job.attempt_count = 0 + or post_content_ingestion_job.queued_at <= now() - $2::interval + ) + ) + or ( + post_content_ingestion_job.status_code = $3 + and post_content_ingestion_job.started_at is not null + and post_content_ingestion_job.started_at < now() - $4::interval + ) ) - ) - or ( - status_code = $3 - and started_at is not null - and started_at < now() - $4::interval - ) - order by queued_at + order by post_content_ingestion_job.queued_at limit $5 """, QUEUED, diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 873294746..f776ff292 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -57,7 +57,7 @@ async def _claim_job( async with pool.acquire() as conn: async with conn.transaction(): row = await conn.fetchrow( - f""" + """ select p.*, j.source_body_sha256 as job_source_body_sha256, j.status_code as job_status_code, j.attempt_count as job_attempt_count, @@ -67,6 +67,7 @@ async def _claim_job( join source_post p on p.post_id = j.post_id where j.post_id = $1::uuid and j.source_body_sha256 = $2 + and coalesce(upper(btrim(p.source_detail_state_code)), '') <> 'W' for update of j, p """, post_id, @@ -74,6 +75,8 @@ async def _claim_job( ) if row is None: return None + if str(row.get("source_detail_state_code") or "").strip().upper() == "W": + return None status_code = str(row["job_status_code"]) attempt_count = int(row["job_attempt_count"]) if status_code == FAILED: diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 9136961f0..42a284729 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,5 +1,30 @@ """Shared source-post eligibility SQL for reader-facing evidence reads.""" +WRITING_SOURCE_DETAIL_STATE_CODE = "W" + + +def normalize_source_detail_state_code(value: object) -> str | None: + """Return a canonical, case-insensitive source detail state code.""" + if not isinstance(value, str): + return None + normalized = value.strip().upper() + return normalized or None + + +def source_post_state_visibility_sql( + alias: str, *, corporate_param: int, account_param: int, admin_param: int +) -> str: + """Apply public/corp visibility, with an author/admin exception for W.""" + return ( + f"((coalesce(upper(btrim({alias}.source_detail_state_code)), '') = " + f"'{WRITING_SOURCE_DETAIL_STATE_CODE}' " + f"and ({alias}.author_account_id = ${account_param}::uuid " + f"or ${admin_param}::boolean)) " + f"or (coalesce(upper(btrim({alias}.source_detail_state_code)), '') <> " + f"'{WRITING_SOURCE_DETAIL_STATE_CODE}' and ({alias}.visibility_code = 'public' " + f"or {alias}.corporate_entity_id::text = any(${corporate_param}::text[]))))" + ) + SOURCE_CONTEXT_COLUMNS = ( "source_author_code", "source_author_name", @@ -9,6 +34,9 @@ "source_process_unit_name", "source_sales_pool_code", "source_sales_pool_name", + "source_order_pool_code", + "source_sales_order_code", + "source_inspection_point_code", "source_customer_code", "source_customer_name", "source_project_code", @@ -26,17 +54,23 @@ def source_context_present_sql(alias: str) -> str: return " or ".join( - f"nullif(btrim({alias}.{column}), '') is not null" for column in SOURCE_CONTEXT_COLUMNS + [ + *(f"nullif(btrim({alias}.{column}), '') is not null" for column in SOURCE_CONTEXT_COLUMNS), + f"{alias}.source_sales_order_item_number is not null", + ] ) def source_context_missing_sql(alias: str) -> str: return " and ".join( - f"nullif(btrim({alias}.{column}), '') is null" for column in SOURCE_CONTEXT_COLUMNS + [ + *(f"nullif(btrim({alias}.{column}), '') is null" for column in SOURCE_CONTEXT_COLUMNS), + f"{alias}.source_sales_order_item_number is null", + ] ) -SOURCE_POST_ELIGIBILITY_SQL = ( +SOURCE_POST_READER_ELIGIBILITY_SQL = ( "nullif(btrim({alias}.source_draft_code), '') is null " "and nullif(btrim({alias}.source_deleted_flag), '') is null " "and not (" @@ -51,3 +85,11 @@ def source_context_missing_sql(alias: str) -> str: missing_context=source_context_missing_sql("{alias}"), present_context=source_context_present_sql("real_post"), ) + +# Derived readers (ontology, lineage, ranking, reports, Ask, and content +# projections) must never consume a writing-in-progress source. Raw board +# list/detail routes opt into SOURCE_POST_READER_ELIGIBILITY_SQL explicitly. +SOURCE_POST_ELIGIBILITY_SQL = ( + f"({SOURCE_POST_READER_ELIGIBILITY_SQL}) " + "and coalesce(upper(btrim({alias}.source_detail_state_code)), '') <> 'W'" +) diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 1a544a79f..f7287ade3 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -74,12 +74,14 @@ _resolve_affiliated_organization, ) from .knowledge_graph import persist_edges_for_post +from .post_eligibility import normalize_source_detail_state_code from .team_ingestion import upsert_team SUMMARY_SOURCE_BODY_MISSING = ( "Post summary is unavailable: the source post body is empty. " "Re-import the source record with its body before requesting a summary." ) +SUMMARY_TARGET_UNAVAILABLE = "Writing-in-progress posts are not summary targets." def require_summary_source_body(body: str | None) -> str: @@ -89,6 +91,16 @@ def require_summary_source_body(body: str | None) -> str: return body +async def require_summary_target(conn: asyncpg.Connection, post_id: str) -> None: + """Keep W out of both persisted and on-demand summary generation.""" + state_code = await conn.fetchval( + "select source_detail_state_code from source_post where post_id = $1", + post_id, + ) + if normalize_source_detail_state_code(state_code) == "W": + raise ValueError(SUMMARY_TARGET_UNAVAILABLE) + + async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str, @@ -398,6 +410,7 @@ async def persist_post_summary( summary replacement transaction while all post-owned rows still commit or roll back together. """ + await require_summary_target(conn, post_id) if post_body is not None: require_summary_source_body(post_body) diff --git a/backend/app/ranking_ingestion.py b/backend/app/ranking_ingestion.py index 71cd66e57..d76cf0f16 100644 --- a/backend/app/ranking_ingestion.py +++ b/backend/app/ranking_ingestion.py @@ -21,6 +21,7 @@ async def load_visible_ranking_posts( """Read ``source_post`` rows the reader may rank.""" posts = await conn.fetch( "select post_id, post_title, created_at, visibility_code, " - "corporate_entity_id from source_post" + "corporate_entity_id, author_account_id, source_detail_state_code " + "from source_post" ) return [dict(row) for row in posts if can_see_post(row)] diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 5edfff64d..dc31fb13e 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -552,6 +552,7 @@ async def fetch_period_reports( f""" select m.grouping_key, m.post_id, m.theta_eap, m.theta_sd, p.post_title, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context, t.due_date as ticket_due_date, t.ticket_title, t.ticket_status_code from report_member_score m @@ -603,6 +604,7 @@ async def fetch_period_reports( select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, p.post_title, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp join source_post p on p.post_id = lp.post_id @@ -664,6 +666,8 @@ async def fetch_period_reports( "theta_sd": float(row["theta_sd"]), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), "ticket_due_date": ( None @@ -700,6 +704,8 @@ async def fetch_period_reports( "leftover_residual": float(row["leftover_residual"]), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), } for row in leftover_by_group.get(header["grouping_key"], []) @@ -731,7 +737,8 @@ async def list_period_report_summaries( # Safe SQL: the source-context expression is an immutable schema fragment; report keys are bound. members = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select m.grouping_key, m.period_code, p.visibility_code, p.corporate_entity_id + select m.grouping_key, m.period_code, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code , ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_member_score m join source_post p on p.post_id = m.post_id @@ -783,6 +790,8 @@ async def list_period_report_summaries( { "visibility_code": member["visibility_code"], "corporate_entity_id": str(member["corporate_entity_id"]), + "author_account_id": str(member["author_account_id"]), + "source_detail_state_code": member["source_detail_state_code"], "has_real_source_context": bool(member["has_real_source_context"]), } for member in members_by_key.get((row["grouping_key"], row["period_code"]), []) @@ -847,7 +856,8 @@ async def fetch_period_comparison( # Safe SQL: the source-context expression is an immutable schema fragment; grouping filters are bound. members = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select m.grouping_kind, m.grouping_key, p.visibility_code, p.corporate_entity_id + select m.grouping_kind, m.grouping_key, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code , ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_member_score m join source_post p on p.post_id = m.post_id @@ -876,6 +886,8 @@ async def fetch_period_comparison( { "visibility_code": member["visibility_code"], "corporate_entity_id": str(member["corporate_entity_id"]), + "author_account_id": str(member["author_account_id"]), + "source_detail_state_code": member["source_detail_state_code"], "has_real_source_context": bool(member["has_real_source_context"]), } for member in members_by_key.get((row["grouping_kind"], row["grouping_key"]), []) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a87ebe1a3..cd7ed3a8d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -82,6 +82,9 @@ _SOURCE_ORG_NAMED_HINTS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0039_source_org_named_hints.sql" ) +_SOURCE_COMMERCIAL_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0130_source_commercial_context.sql" +) _MEMBER_LOCALE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0044_member_locale_preference.sql" ) @@ -263,6 +266,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SOURCE_RECORD_IDENTITY_MIGRATION.read_text()) cur.execute(_SOURCE_NAMED_HINTS_MIGRATION.read_text()) cur.execute(_SOURCE_ORG_NAMED_HINTS_MIGRATION.read_text()) + cur.execute(_SOURCE_COMMERCIAL_CONTEXT_MIGRATION.read_text()) cur.execute( (Path(__file__).resolve().parents[2] / "migrations" / "0040_post_summary_contract.sql") .read_text() @@ -1239,6 +1243,9 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token, seede assert any( entity["entity_name"] == "Test Corp" for entity in body["corporate_entities"] ) + assert { + row["corporate_entity_code"] for row in body["account_affiliations"] + } == {"TEST-CORP", "GRANTED-CORP"} affiliation = next( row for row in body["account_affiliations"] @@ -1443,7 +1450,7 @@ async def capture_relationship_entity_ids(conn, corporate_entity_ids): # confirm this is a real common_lookup_value label, not the code echoed back. assert entity["entity_level_code"] == "company" assert entity["entity_level_label"] not in ("", "company") - assert entity["scope_facets"] == ["authorized_own", "observed_hierarchy", "observed_organization"] + assert entity["scope_facets"] == ["authorized_own", "observed_organization"] parent = next(item for item in body["corporate_entities"] if item["entity_name"] == "Test Group") assert parent["scope_facets"] == ["authorized_granted", "observed_hierarchy"] granted = next(item for item in body["corporate_entities"] if item["entity_name"] == "Granted Corp") @@ -1550,7 +1557,7 @@ def test_customer_master_scope_facets_reflect_authorization_and_observed_evidenc ) cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " - "values ('GRANTED-CORP', 'Case Granted Corp', 'company') returning corporate_entity_id" + "values ('CASE-GRANTED-CORP', 'Case Granted Corp', 'company') returning corporate_entity_id" ) granted_corp_id = cur.fetchone()[0] cur.execute( @@ -1760,10 +1767,127 @@ 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 payload["source_detail_state_options"] == [] 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" +def test_post_list_filters_and_lists_source_detail_state_codes( + client, demo_analyst_token, seeded_db +) -> None: + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute( + "select user_account_id from user_account " + "where email_address = 'other.analyst@example.test'" + ) + other_account_id = cur.fetchone()[0] + cur.execute( + """ + update source_post + set source_detail_state_code = case post_title + when 'Public post' then ' W ' + when 'Own-corp private post' then ' D ' + when 'Late own-corp private post' then ' A ' + when 'Edited own-corp private post' then ' W ' + else source_detail_state_code + end + where post_title in ( + 'Public post', 'Own-corp private post', + 'Late own-corp private post', 'Edited own-corp private post' + ) + """ + ) + cur.execute( + "update source_post set author_account_id = %s where post_title = %s", + (other_account_id, "Edited own-corp private post"), + ) + cur.execute( + "update source_post set corporate_entity_id = %s, visibility_code = 'private' " + "where post_title = %s", + (seeded_db["other_corp_id"], "Public post"), + ) + conn.commit() + finally: + conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + listed = client.get("/api/posts", headers=headers) + assert listed.status_code == 200, listed.text + assert { + option["code"] for option in listed.json()["source_detail_state_options"] + } == {"A", "D", "W"} + assert {post["post_title"] for post in listed.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + blank_filter = client.get("/api/posts?source_detail_state=", headers=headers) + assert blank_filter.status_code == 200, blank_filter.text + assert {post["post_title"] for post in blank_filter.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + blank_voc_filter = client.get("/api/posts?voc_type=", headers=headers) + assert blank_voc_filter.status_code == 200, blank_voc_filter.text + assert {post["post_title"] for post in blank_voc_filter.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + filtered = client.get("/api/posts?source_detail_state=D", headers=headers) + assert filtered.status_code == 200, filtered.text + assert [post["post_title"] for post in filtered.json()["posts"]] == [ + "Own-corp private post" + ] + + writing = client.get("/api/posts?source_detail_state=W", headers=headers) + assert writing.status_code == 200, writing.text + assert {post["post_title"] for post in writing.json()["posts"]} == {"Public post"} + + authored_detail = client.get( + f"/api/posts/{seeded_db['public_post_id']}", headers=headers + ) + assert authored_detail.status_code == 200, authored_detail.text + summary = client.get( + f"/api/posts/{seeded_db['public_post_id']}/summary", headers=headers + ) + assert summary.status_code == 422 + assert "not analysis targets" in summary.json()["detail"] + for derived_path in ( + "content", + "five-w1h", + "keymen", + "counterparties", + "lineage", + "knowledge-graph", + "evaluation", + "chat", + ): + derived = client.get( + f"/api/posts/{seeded_db['public_post_id']}/{derived_path}", headers=headers + ) + assert derived.status_code == 422, (derived_path, derived.text) + + hidden_detail = client.get( + f"/api/posts/{seeded_db['edited_own_post_id']}", headers=headers + ) + assert hidden_detail.status_code == 403 + + _grant_post_admin(seeded_db["dsn"]) + admin_list = client.get("/api/posts?source_detail_state=W", headers=headers) + assert admin_list.status_code == 200, admin_list.text + assert {post["post_title"] for post in admin_list.json()["posts"]} == { + "Public post", + "Edited own-corp private post", + } + + def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, seeded_db) -> None: response = client.get( "/api/posts?limit=1&offset=1", @@ -4545,6 +4669,8 @@ def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( assert commitments[0]["commitment_summary"] == "Send the revised quote" assert "visibility_code" not in commitments[0] assert "corporate_entity_id" not in commitments[0] + assert "author_account_id" not in commitments[0] + assert "source_detail_state_code" not in commitments[0] def test_calendar_keeps_real_ticket_when_demo_code_is_shared( @@ -5211,6 +5337,21 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture( first = report["members"][0] assert first["post_title"] in decorated, first["post_title"] assert not first["post_title"].startswith(("High-band", "Low-band")) + assert not { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } & first.keys() + for pair in report.get("leftover_pairs", []): + assert not { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } & pair.keys() threads = client.get("/api/reports/thread_group/2026-W02", headers=headers) a100 = next(report for report in threads.json()["reports"] if report["grouping_key"] == "A-100") diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index e76d3c051..125b89506 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0040-source-state-provenance.md b/docs/adr/0040-source-state-provenance.md index 33c70e923..1bf743154 100644 --- a/docs/adr/0040-source-state-provenance.md +++ b/docs/adr/0040-source-state-provenance.md @@ -33,3 +33,30 @@ The product can inspect original state evidence without losing distinctions between lifecycle dimensions. Until a source codebook is provided, users see codes rather than invented labels and the Board remains complete rather than silently excluding records. + +## Product display mapping + +For the current Board workflow, the product owner supplied a display +interpretation for the observed detail-state codes: + +- `W` — Writing in progress (`작성 중`) +- `D` — Pending approval (`결재 중`) +- `A` — Approved (`결재 완료`) + +This is a reader-facing explanation, not a rewrite of the raw source field or +an assertion that the source system's full codebook has been verified. The API +continues to return the raw code, unknown codes remain visible as unmapped, and +`source_draft_code` remains a separate signal. The Board may filter by the raw +detail-state code while showing this mapping beside it. + +## Writing-state access and derivation boundary + +W is an original-source record that is still being written. It is not a +service target. The author account and post_admin may open the raw source +record so the author can continue reviewing their own work, but W is excluded +from all derived reads and writes: summaries, 5W1H, ontology/Keyman and +relationship extraction, knowledge-graph projections, lineage, rankings, +reports, calendar commitments, chat/Ask sources, and content-analysis +projections. A persisted summary does not make W eligible; the API refuses +analysis requests and the summary backfill query excludes W. D and A remain +the service summary targets. diff --git a/docs/adr/0130-source-commercial-context-hints.md b/docs/adr/0130-source-commercial-context-hints.md new file mode 100644 index 000000000..b972342f2 --- /dev/null +++ b/docs/adr/0130-source-commercial-context-hints.md @@ -0,0 +1,33 @@ +# ADR 0130: Source commercial-context combination hints + +## Status + +Accepted + +## Decision + +The import boundary accepts explicit mappings for source customer, order-pool, +sales-order, sales-order-item, and inspection/status-point fields. Raw values +are retained on `source_post` with their source-state fields; the importer does +not infer a catalog identity or a lifecycle label from a code. + +The product computes a small combination code from field presence. For the +sales-order-item field, a positive numeric value is present and the source +zero sentinel is absent. Combination labels such as +`customer_only_candidate` and `no_sales_identifier_candidate` are explicitly +inferred candidates, not facts. The exact raw lifecycle vector remains visible +alongside the inference. + +The same bounded hint is passed to contextual-orchestrator, Ask Agent evidence, +and the post knowledge-graph view. Customer-name resolution continues through +the existing corroborated customer-hint path; a raw customer code never creates +a catalog entity by itself. + +## Rationale + +Independent null rates cannot distinguish a customer-only record from an +order-pool record or an order item. The current source distribution shows that +the four presence bits form multiple materially different populations. Keeping +the combination deterministic, provenance-bearing, and weakly labeled gives +lineage reconstruction and readers the useful distinction without turning +unknown SAP codes into invented semantics. diff --git a/docs/adr/0131-explicit-organization-project-relations.md b/docs/adr/0131-explicit-organization-project-relations.md new file mode 100644 index 000000000..d0785f2bd --- /dev/null +++ b/docs/adr/0131-explicit-organization-project-relations.md @@ -0,0 +1,27 @@ +# ADR 0131: Explicit organization-to-project semantic relations + +## Status + +Accepted + +## Decision + +The semantic relationship contract may persist an organization-to-project +`lw_supports` relation only when the source explicitly assigns that +organization work, ownership, contracting, or support for the named project. +`lw_supports` is a LineageWeave profile property with `prov:Agent` domain and +`prov:Entity` range. It is not a PROV alias and does not create an inferred +inverse, ownership, or causal edge. + +Explicit organization membership uses W3C Organization Vocabulary +`org_member_of` (`org:memberOf`). A project mention, affiliation, or shared +meeting does not create either relation. Unresolved names remain text in the +qualified semantic table and its evidence-bearing navigation projection. + +## Consequences + +- A post can show distinct organization-to-project responsibilities instead + of collapsing every organization under one project label. +- The graph remains a navigation projection; evidence and confidence stay in + `post_summary_semantic_relationship`. +- Attendance-only actors remain event clues, not role/responsibility rows. diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 69d7bcf99..d978a1848 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -717,9 +717,10 @@ rdfs:label "responsible for"@en . :supports a owl:ObjectProperty ; - rdfs:domain :RoleActorAgent ; - rdfs:range :SemanticAssertion ; - rdfs:label "supports"@en . + rdfs:domain prov:Agent ; + rdfs:range prov:Entity ; + rdfs:label "supports"@en ; + rdfs:comment "LineageWeave profile relation for an explicit source statement that an agent supports an entity such as a named project. It is not inferred from co-occurrence or role evidence."@en . :subjectName a owl:DatatypeProperty ; rdfs:domain :SemanticRelationship ; diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f433fb99d..ccb928d5d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,16 +1,35 @@ # Product & Technical Gap Baseline -> Audit scope: the current `feat/customer-master-scope-facets` worktree and -> open PR #366, compared with its exact base `feat/lineage-dag-regression`, the UI/UX Standard Guide v3.0 supplied for this +> Audit scope: the current LineageWeave buyer-surface/source-context worktree +> and open PR #384, compared with its exact base and the UI/UX Standard Guide v3.0 supplied for this > product, ADR 0118, the accepted TEPP PRD/contracts, and the > contextual-orchestrator architecture. Real source identifiers are deliberately > replaced with case labels; they must not enter repository artifacts. ## 1. Exact-head evidence -Audit anchor: the exact source state carried by this documentation commit at -2026-08-21; record the final PR head with `git rev-parse HEAD` during -acceptance. +### 1.1 Current continuation head + +The current buyer-surface/source-context implementation head for PR #384 is +`d2d4b9209caed81cf4506908207e7b03142a1af6` (the exact head pushed after the +remote fixture cleanup and vendor-skill revert), observed at `2026-08-21T15:35:55Z` +(`2026-08-22` KST). Its base is +`83ace331edc982208c290763cb0d389c1884e21b` (`docs/customer-master-scope-adr`). +The repaired implementation includes the customer-master scope-facet base +and preserves the source-detail-state fixes. Local acceptance evidence is +backend `127 passed, 6 skipped`, frontend `199 passed`, lint, TypeScript, and +production build success. The baseline update itself follows this code +commit; hosted checks, formal review, and merge remain unclaimed until the +remote PR is queried at its newly pushed exact head. + +The historical evidence below remains valid only at the exact heads and dates +stated in each entry. It must not be used as proof that the current continuation +head has passed the same checks. + +### 1.2 Historical audit anchor + +Audit anchor: the exact source state carried by this commit at 2026-08-21; +record the final PR head with `git rev-parse HEAD` during acceptance. Current source/test exact head observed before this documentation update: `8bed77e7e7b91b633bb92d3a82d0187c387206af`, the squash merge of PR #364 @@ -49,14 +68,33 @@ next exact head and therefore requires the protected checks to rerun. Devin Review is pending, and no independent approval or merge is claimed. The prior PR #347 merged at `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. -- **Current parsing PR:** PR #367 is open and unmerged at exact head - `4093ac7f` (full SHA is recorded in the PR body), based on +- **Historical parsing PR:** PR #367 subsequently merged at + `7a0d025215fbd9f6510727c7139885b561296149` after exact head + `5194d267b90430d7a27a9752a49d73617cb5756c`, based on `docs/customer-master-scope-adr` at `f66991699506ef14607de5946da1efcfd20ae6da`. It preserves numbered footnotes and empty-cell positions, avoids short-id collisions, and drops table rows made only of empty cells. The focused parser gate is `47 passed`; `compileall` and `git diff --check` passed. Hosted Checks remain queued and no approval or merge is claimed. +### 1.3 Current related PR queue + +The following exact-head states were observed during this continuation and are +part of the acceptance queue, not completion evidence: + +| Repository | PR | Exact head | State | Remaining gate | +| --- | ---: | --- | --- | --- | +| LineageWeave | #384 | `d2d4b9209caed81cf4506908207e7b03142a1af6` | open, mergeable, unstable | hosted checks and review | +| LineageWeave | #383 | `720004942dd155a85020af32da402d320038f46a` | open, blocked | required checks and review | +| LineageWeave | #355 | `b606c2553f877fa85968d90dc46598ce16897fbf` | open, coverage pending | coverage gate and review | +| contextual-orchestrator | #765 | `d19e3492192e21e4a040fa3fc13a0793443731bf` | open, blocked | required checks and review | +| governance-risk-compliance | #50 | `ba78e4790f3e361826991455ce83634004f2875d` | open, mergeable, unstable | central OSV provenance gate | +| ContextualWisdomLab/.github | #1158 | `6e93fd0b65c159c7b168d83579e5b8282096480e` | open, behind | required checks and review | + +GRC stack PRs #20 and #21 were merged in order before #50. These merge SHAs do +not make #50 or the other listed PRs merged; each remains subject to its own +exact-head protected gate. + ## 2. UI/UX Standard Guide v3.0 comparison ### 2.1 Satisfied or substantially present @@ -100,6 +138,15 @@ next exact head and therefore requires the protected checks to rerun. `LanguageSwitcher` now renders inside `.app-header-top-menu` (`App.tsx`) instead of the GNB row; the now-unused `WorkspaceNav` `tools` prop and `.workspace-gnb-tools` CSS were removed. +- **Authorized corp/PU scope — fixed in this worktree:** `/api/me` remains the + only source for GNB scope values, and that response is built from the + authenticated account's DB-backed `account_affiliation` rows. The header now + presents a compact code summary with a keyboard-operable disclosure for the + complete corporation/business-unit list, keeps corporation-only affiliations, + and omits the scope when no affiliation is authorized. Desktop and 390px + mobile Playwright checks cover the disclosure, no-unassigned-code behavior, + and no horizontal overflow; the external Keyverse/OIDC runtime gate remains + open below. - **Locale document metadata — substantially present:** `i18n.ts` synchronizes `document.documentElement.lang` after locale selection and `i18n.test.ts` covers the supported locales. `frontend/index.html` remains an English @@ -203,20 +250,21 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to | Site map / utility menu | `SiteMapUtility`, accessible toggle/region, Escape and destination-close behavior, responsive CSS contract, locale coverage | source + unit; authenticated browser evidence open | | Noto Sans, palette, table/form/button conventions, modal 50% mask and keyboard semantics | ADR 0118, token CSS, popup dialog implementation, frontend tests | source + unit | | Keyverse/OIDC login with real account | `auth.py`, OIDC discovery/JWKS boundary, local redirect check | source + local-integration; Keyverse open | -| Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU` and header displays them | source + local-integration | -| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, shared `SOURCE_POST_VISIBILITY_SQL`, API authorization tests, aggregate-only runtime checks | source + unit + local-integration | +| Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU`, the GNB disclosure is covered by `App.test.tsx`, and desktop/390px Playwright QA verifies the rendered scope | source + unit + local-integration + browser-mocked | +| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post` plus W author/admin raw-source exception, analysis eligibility excluding W, API authorization tests, aggregate-only runtime checks | source + local-integration | | React product surface and PostgreSQL boundary | React routes/components, asyncpg API, Compose stack | source + local-integration | | Authorized PostgreSQL export import mapping | `scripts/import_postgresql_posts.py`, ADR 0121, hash-verified RFC 2557 MHTML resolver, and synthetic preflight/import tests; authorized relation has artifact-path metadata but no body/content/HTML field | source + unit + local-integration partial; operator artifact files and authorized live import open | | Bounded large-body search migration | `0035_body_search_prefix.sql`, `0036_normalized_body_search.sql`; live replay completed after bounded rendered-text indexing | source + local-integration | | Public Compose liveness and tenant settings boundary | health-probe regression test, `0103_tenant_settings.sql`, replayed existing volume, one tenant-settings row, canonical `tenant_settings_id` after `0104`, rebuilt backend `/healthz` and authenticated `/api/settings` HTTP 200 | source + unit + local-integration | | Two-word snake_case database identifiers | ADR 0120, idempotent migration `0104`, live public-schema audit, zero invalid indexes | source + unit + local-integration | -| Post list/detail popup, Korean summary, 5W1H, R&R, tickets/calendar | API routes, popup panels, backend/frontend tests | source + unit | +| Post list/detail popup, Korean summary, 5W1H, R&R, tickets/calendar | API routes, popup panels, backend/frontend tests; W is raw-source-only for author/admin and is excluded from summary and derived analysis targets | source + unit | | Keyman on both sides, titles, affiliations, related KG nodes | Keyman/affiliate-tree/related-node routes and popup | source + unit; live extraction open | | Ontology, semantic layer, provenance, W3C PROV-O projection | normalized schema, SKOS operational vocabulary concepts, `ontology_annotations` label fallback, ADR 0124, provenance modules, ADRs, evidence UI | source + unit; corpus verification open | | Branching Event Lineage DAG with evidence trail | `LineageDag.tsx`, Storybook story, Figma frames, accessible node-kind names for screen readers/tooltips, frontend tests; runtime cases include both a rendered DAG and honest empty states, while current corpus coverage remains sparse | source + unit + local-integration partial | | Customer master and hierarchy tree | `/api/customer-master`, `scope_facets`, visible `post_organization_mention` enrichment, affiliate tree, migration `0105`, scope filter | source + unit + local-integration partial; authorized own/granted/unclassified facets, visible observed organizations, and admitted observed hierarchy facets are implemented, while authoritative scope backfill and broader hierarchy traversal remain open | | VOC/VOM/VOP/VOCC/VOCO/VOS role classification | common lookup values and relationship APIs | source + unit; live classification open | | Evidence-grounded chat and source navigation | `/chat`, `/ask`, citation/evidence UI | source + unit; synthetic orchestrator judge route verified, corpus chat/runtime evidence open | +| OpenTelemetry across LineageWeave, contextual-orchestrator, Valkey, and GRC | LineageWeave PR #383 adds API/Valkey/session spans; contextual-orchestrator PR #765 carries session/provider telemetry; governance-risk-compliance PR #50 adds request telemetry, W3C trace context, OTLP export, and ADR 0009 | source + PR; protected merge and end-to-end collector evidence open | | PU/team/project weekly/monthly reports | report API/UI and grouping controls | source + unit; TEPP-backed live report open | | TEPP calibrated measurement, dichotomous items, multilevel/MMM/time model | published import/REST boundary and TEPP ADR/PRD references | boundary-only; live-external open | | contextual-orchestrator routing, VISION, embedding, schema repair | clients and provenance/session boundary; synthetic authenticated route returned a judge score of `0.98`, OCR succeeded, and region location returned five regions | source + local-integration partial; corpus backfill, capability/readiness evidence, and schema-repair workflow open | @@ -230,8 +278,8 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to | APA 7 doctoring and Zotero OA records | baseline bibliography, local Zotero API reachable, known metadata found | source + local-integration; OA attachment audit open | | Browser E2E from login through evidence | authenticated local OIDC login, protected list, drawer/search, popup sweep, and aggregate evidence checks at 390x958; post-migration fresh session had `htmlLang=zh`, localized protected shell, zero console errors/warnings, no horizontal overflow, popup, summary, and Event Lineage | source + local-integration; external provider/runtime evidence remains open | | Storybook scenes/edge events and design-token coverage | `LineageDag.stories.tsx`, inventory, Storybook build | source + unit | -| External email/project lineage package boundary | PR #343 publishes strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; exact-head hosted gates, independent review, and immutable release open | -| Naruon calendar projection boundary | PR #337 is closed as superseded; draft PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, restack, and review open | +| External email/project lineage package boundary | PR #343 merged at `125a8069a1554874d8067a15047e19d780ea6b7b` with strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; immutable release open | +| Naruon calendar projection boundary | PR #337 is closed as superseded; PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, and provider conformance remain open | | Hourly PR review/repair/merge loop | Central `ContextualWisdomLab/.github` scheduler owns `*/15 * * * *` sweep and `0 * * * *` heartbeat; no duplicate repo-local scheduler is required | boundary accepted; current-head runtime open | | 100% coverage/docstrings/edge-case/release gates | current local checks pass, but repository-wide coverage/docstring reports, hosted checks, independent review, and release evidence are not complete on PR #366 | open | @@ -288,7 +336,7 @@ or an explicit unavailable result. `rel_voc`) together with a *job title* ("연구원") that has no home of its own on `RoleResponsibility` the way `last_known_job_title` does on `Keyman`. Fixing this needs a new field plus an - `POST_SUMMARY_CONTRACT_VERSION` bump (currently `12`) and an extraction- + `POST_SUMMARY_CONTRACT_VERSION` bump (currently `13`) and an extraction- prompt change — not a display-layer patch, and not something to implement without review given every future extraction depends on the contract version. @@ -378,8 +426,9 @@ or an explicit unavailable result. distinguishes genuinely isolated posts from missing extraction or grouping evidence before presenting a reader-facing branching DAG as complete. - **Cross-repository email/project lineage — provider boundary implemented, - consumer open:** PR #343 provides the store-agnostic LineageWeave contract but - remains unmerged and unreleased. Naruon issue #1437 still needs a disabled-by- + consumer open:** PR #343 merged at + `125a8069a1554874d8067a15047e19d780ea6b7b`, but the contract remains + unreleased. Naruon issue #1437 still needs a disabled-by- default admission policy, durable idempotent analysis job, immutable artifact pin, result projection, accept/correct/reject audit, and integration into the existing email/thread/project surfaces. No draft branch, direct SQL, shared ORM, diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..2888ae0ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run --no-file-parallelism --maxWorkers=1", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index 82da605d0..7fd0925ba 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -233,14 +233,112 @@ } .app-account-scope { - max-width: 22rem; - overflow: hidden; + position: relative; + max-width: min(30rem, 34vw); color: var(--text-muted); font-size: 0.75rem; +} + +.app-account-scope > summary { + display: flex; + min-width: 0; + min-height: var(--size-control-min); + align-items: center; + gap: 0.35rem; + overflow: hidden; + padding: 0.2rem 0.45rem; + border: 1px solid transparent; + border-radius: var(--radius-control); + cursor: pointer; + list-style: none; +} + +.app-account-scope > summary::-webkit-details-marker { + display: none; +} + +.app-account-scope > summary::after { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + margin-left: 0.15rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + content: ""; + transform: translateY(-0.12rem) rotate(45deg); +} + +.app-account-scope[open] > summary::after { + transform: translateY(0.12rem) rotate(225deg); +} + +.app-account-scope > summary:hover, +.app-account-scope > summary:focus-visible { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + color: var(--text-h); +} + +.app-account-scope-summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.app-account-scope-more { + flex: 0 0 auto; + color: var(--color-primary); + font-weight: 700; + white-space: nowrap; +} + +.app-account-scope-panel { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: var(--z-gnb-pulldown); + width: min(24rem, calc(100vw - 2rem)); + max-height: min(24rem, 60vh); + overflow: auto; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: 0 10px 28px rgba(16, 24, 40, 0.14); + color: var(--text-h); +} + +.app-account-scope-heading { + margin: 0 0 0.55rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.app-account-scope-panel ul { + display: grid; + gap: 0.35rem; + max-height: 18rem; + overflow: auto; + list-style: none; + padding: 0; + margin: 0; +} + +.app-account-scope-panel li { + overflow-wrap: anywhere; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface-muted); + font-family: var(--mono); + font-size: 0.76rem; +} + /* Drawer Menu Trigger (Mobile) */ .mobile-drawer-trigger { display: none; @@ -1285,22 +1383,32 @@ a:focus-visible { text-transform: none; } -.board-voc-type-option { - min-height: 2rem; +.board-voc-type-option, +.board-choice-option { + min-height: var(--size-control-min); padding: 0.3rem 0.45rem; border: 1px solid transparent; border-radius: var(--radius-chip); } -.board-voc-type-option:hover { +.board-voc-type-option:hover, +.board-choice-option:hover { background: var(--color-accent-background); } -.board-voc-type-option:focus-within { +.board-voc-type-option:focus-within, +.board-choice-option:focus-within { border-color: var(--color-accent-border); box-shadow: 0 0 0 3px var(--color-focus-ring); } +.board-source-detail-state-help { + flex: 0 0 100%; + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; +} + .board-voc-type-filter input { width: 1rem; height: 1rem; @@ -1399,6 +1507,21 @@ a:focus-visible { line-height: 1.4; } +.source-lineage-presence { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.source-lineage-presence-item { + color: var(--text); +} + +.source-lineage-presence-item.is-missing { + color: var(--text-muted); +} + .post-card-badges { max-width: 12rem; display: flex; @@ -1504,15 +1627,8 @@ a:focus-visible { position: relative; background: var(--surface); color: var(--text); - width: min(90%, 820px); + width: min(90%, 1180px); max-height: 85vh; - /* Wide desktop viewports get more than the 820px mobile-safe cap so - multi-column sections (popup-analysis-grid, popup-secondary-grid) - have room to sit side by side instead of forcing a narrow single - column on a 1920px-wide shell. */ - @media (min-width: 1280px) { - width: min(85%, 1180px); - } overflow-y: auto; padding: clamp(1.25rem, 3vw, 2rem); border: 1px solid var(--border); @@ -2030,6 +2146,63 @@ a:focus-visible { font-size: 0.8rem; } +.source-lineage-hint { + margin-top: 0.9rem; + padding: 0.75rem 0.9rem; + border-inline-start: 3px solid var(--color-primary); + background: var(--color-accent-background); +} + +.source-lineage-hint p { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + margin: 0.35rem 0 0; +} + +.source-lineage-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; + padding: 0; + margin: 0.45rem 0 0; + list-style: none; +} + +.source-lineage-fields li { + display: grid; + gap: 0.15rem; + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg); +} + +.source-lineage-fields li span { + color: var(--text-muted); + font-size: 0.72rem; +} + +.source-lineage-fields li strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8rem; +} + +.source-lineage-fields .is-missing strong { + color: var(--text-muted); + font-weight: 500; +} + +@media (max-width: 720px) { + .source-lineage-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .ontology-affiliation-link { font-weight: 600; } @@ -2103,6 +2276,18 @@ a:focus-visible { margin: 0; } +.semantic-relationship-list li { + display: grid; + gap: 0.35rem; +} + +.semantic-relationship-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +} + .verification-verify_pending { background: var(--badge-status-pending-bg); color: var(--badge-status-pending-text); @@ -2327,7 +2512,7 @@ a:focus-visible { } .app-account-scope { - display: none; + max-width: min(24rem, 42vw); } .workspace-gnb { padding: 0 1rem; @@ -2525,11 +2710,30 @@ a:focus-visible { } .app-header-top-menu { + width: 100%; + min-width: 0; + flex: 1 1 100%; flex-wrap: wrap; justify-content: flex-end; row-gap: 0.5rem; } + .app-account-scope { + flex: 1 0 100%; + max-width: 100%; + min-width: 0; + order: -1; + } + + .app-account-scope-panel { + position: fixed; + top: calc(var(--header-height) + 0.5rem); + right: 0.9rem; + left: 0.9rem; + width: auto; + max-height: 60vh; + } + .global-search-panel { position: fixed; top: calc(var(--header-height) + 0.5rem); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c34a86a24..d060cf643 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -90,11 +90,15 @@ describe("App, authenticated", () => { succeededTeppRun?: boolean; pendingTeppRun?: boolean; pluralAffiliations?: boolean; + manyAffiliations?: boolean; + noAffiliations?: boolean; deferMe?: boolean; deferPosts?: boolean; meFailed?: boolean; postBody?: string; vocTypeOptions?: { code: string; label: string }[]; + sourceDetailStateCode?: string; + sourceDetailStateOptions?: { code: string; label: string }[]; manyCustomerHints?: number; customerEntityHierarchy?: boolean; customerScopeFacets?: boolean; @@ -129,6 +133,61 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + const authorizedAffiliations = options?.noAffiliations + ? [] + : options?.manyAffiliations + ? [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP", + entity_name: "Demo Corp", + process_unit_id: "pu-demo", + process_unit_code: "DEMO-PU", + process_unit_name: "Demo PU", + }, + { + corporate_entity_id: "corp-north", + corporate_entity_code: "NORTH-CORP", + entity_name: "North Corp", + process_unit_id: "pu-north", + process_unit_code: "NORTH-PU", + process_unit_name: "North PU", + }, + { + corporate_entity_id: "corp-south", + corporate_entity_code: "SOUTH-CORP", + entity_name: "South Corp", + process_unit_id: "pu-south", + process_unit_code: "SOUTH-PU", + process_unit_name: "South PU", + }, + { + corporate_entity_id: "corp-west", + corporate_entity_code: "WEST-CORP", + entity_name: "West Corp", + process_unit_id: "pu-west", + process_unit_code: "WEST-PU", + process_unit_name: "West PU", + }, + { + corporate_entity_id: "corp-hq", + corporate_entity_code: "HQ-CORP", + entity_name: "HQ Corp", + process_unit_id: null, + process_unit_code: null, + process_unit_name: null, + }, + ] + : [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP", + entity_name: "Demo Corp", + process_unit_id: "pu-demo", + process_unit_code: "DEMO-PU", + process_unit_name: "Demo PU", + }, + ]; let releaseMe = () => {}; let releasePosts = () => {}; @@ -172,16 +231,7 @@ describe("App, authenticated", () => { { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, ] : [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], - account_affiliations: [ - { - corporate_entity_id: "corp-demo", - corporate_entity_code: "DEMO-CORP", - entity_name: "Demo Corp", - process_unit_id: "pu-demo", - process_unit_code: "DEMO-PU", - process_unit_name: "Demo PU", - }, - ], + account_affiliations: authorizedAffiliations, }); }); } @@ -1076,6 +1126,7 @@ describe("App, authenticated", () => { post_title: "Public post", voc_type_code: "voc", voc_type_label: "Voice of Customer", + source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", @@ -1090,6 +1141,7 @@ describe("App, authenticated", () => { { code: "vop", label: "Voice of Partner" }, ]), ], + source_detail_state_options: options?.sourceDetailStateOptions ?? [], visibility_options: [{ code: "public", label: "Public" }], }, ), @@ -1105,6 +1157,7 @@ describe("App, authenticated", () => { post_body: options?.postBody ?? "The full body text.", voc_type_code: "voc", voc_type_label: "Voice of Customer", + source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", project_evidence: [ @@ -2230,6 +2283,96 @@ describe("App, authenticated", () => { ); }); + it("explains and filters source detail state codes", async () => { + const fetchMock = stubBackend({ + sourceDetailStateCode: "D", + sourceDetailStateOptions: [ + { code: "W", label: "W" }, + { code: "D", label: "D" }, + { code: "A", label: "A" }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect( + within(board).getByRole("group", { name: "Filter by source detail state" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "W — Writing in progress" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "D — Pending approval" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "A — Approved" }), + ).toBeInTheDocument(); + expect(within(board).getByText("D", { selector: ".board-source-detail-state-code" })).toBeInTheDocument(); + expect(within(board).getByText("Pending approval", { selector: ".board-source-detail-state-description" })).toBeInTheDocument(); + + await userEvent.click(within(board).getByRole("checkbox", { name: "D — Pending approval" })); + await waitFor(() => + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("source_detail_state=D"))).toBe(true), + ); + + setLocale("ko"); + await waitFor(() => + expect( + within(board).getByRole("checkbox", { name: "D — 결재 중 (Pending approval)" }), + ).toBeInTheDocument(), + ); + }); + + it("does not request derived analysis for a writing-state post", async () => { + const fetchMock = stubBackend({ sourceDetailStateCode: " w " }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByText("Summary is not created for writing posts.")).toBeInTheDocument(); + + const requestedPaths = fetchMock.mock.calls.map(([url]) => + new URL(String(url), "https://backend.test").pathname, + ); + expect(requestedPaths).not.toContain("/api/posts/post-1/summary"); + expect(requestedPaths).not.toContain("/api/posts/post-1/evaluation"); + expect(requestedPaths).not.toContain("/api/posts/post-1/five-w1h"); + expect(requestedPaths).not.toContain("/api/posts/post-1/keymen"); + expect(requestedPaths).not.toContain("/api/posts/post-1/counterparties"); + expect(requestedPaths).not.toContain("/api/posts/post-1/lineage"); + expect(requestedPaths).not.toContain("/api/posts/post-1/knowledge-graph"); + expect(requestedPaths).not.toContain("/api/posts/post-1/affiliate-tree"); + expect(requestedPaths).not.toContain("/api/posts/post-1/voc-evidence"); + expect(requestedPaths).not.toContain("/api/posts/post-1/content"); + }); + + it("does not show an empty source detail state filter", async () => { + const fetchMock = stubBackend({ sourceDetailStateOptions: [] }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect( + within(board).queryByRole("group", { name: "Filter by source detail state" }), + ).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("does not request derived panels for writing posts", async () => { + const fetchMock = stubBackend({ + sourceDetailStateCode: " w ", + sourceDetailStateOptions: [{ code: "W", label: "W" }], + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await screen.findByText("The full body text."); + + const urls = fetchMock.mock.calls.map(([url]) => String(url)); + expect( + urls.some((url) => + /\/api\/posts\/[^/]+\/(five-w1h|keymen|counterparties|lineage|knowledge-graph|affiliate-tree|voc-evidence|evaluation)(?:\?|$)/.test(url), + ), + ).toBe(false); + }); + it("opens a post from a DAG node click", async () => { stubBackend(); render(); @@ -4028,6 +4171,32 @@ describe("App, authenticated", () => { ); }); + it("discloses every authorized corporation and business unit code", async () => { + stubBackend({ manyAffiliations: true }); + render(); + + const scope = await screen.findByLabelText("Authorized scope"); + const summary = scope.querySelector("summary"); + expect(summary).not.toBeNull(); + expect(summary).toHaveTextContent("DEMO-CORP / DEMO-PU"); + expect(summary).toHaveTextContent("+2"); + expect(scope).not.toHaveAttribute("open"); + + await userEvent.click(summary as HTMLElement); + + expect(scope).toHaveAttribute("open", ""); + expect(within(scope).getByText("NORTH-CORP / NORTH-PU")).toBeVisible(); + expect(within(scope).getByText("HQ-CORP")).toBeVisible(); + }); + + it("does not derive GNB scope from an unrelated entity list", async () => { + stubBackend({ noAffiliations: true, pluralAffiliations: true }); + render(); + + await screen.findByRole("region", { name: "Board" }); + expect(screen.queryByLabelText("Authorized scope")).not.toBeInTheDocument(); + }); + it("opens the site map utility and closes it after navigation or Escape", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 127b56b66..7c2ae2ec9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { updateTicketStatus, verifyPostRelations, type ActivityEvent, + type AccountAffiliation, type AskAgentResponse, type AskConversationCursor, type AskConversationSummary, @@ -87,6 +88,7 @@ import { type RankingList, type PersonRoleHistoryEntry, type PostRoleResponsibility, + type PostSemanticRelationship, type RelatedNode, type RelatedNodeType, type VocEvidence, @@ -108,6 +110,12 @@ import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; +import { + SOURCE_LINEAGE_FIELDS, + sourceLineageContextLabel, + sourceLineageFieldIsPresent, + sourceLineageFieldLabel, +} from "./sourceLineageHints"; import { isSupportedLocale, LOCALE_LABELS, @@ -151,6 +159,49 @@ function LanguageSwitcher({ accessToken }: { accessToken?: string }) { ); } +function AuthorizedScope({ affiliations }: { affiliations?: AccountAffiliation[] }) { + const scopeValues = Array.from( + new Set( + (affiliations ?? []) + .map((affiliation) => { + const corporateCode = affiliation.corporate_entity_code.trim(); + if (!corporateCode) return null; + return affiliation.process_unit_code?.trim() + ? `${corporateCode} / ${affiliation.process_unit_code.trim()}` + : corporateCode; + }) + .filter((value): value is string => Boolean(value)), + ), + ); + if (scopeValues.length === 0) return null; + + const visibleScopeValues = scopeValues.slice(0, 3); + const hiddenScopeCount = scopeValues.length - visibleScopeValues.length; + const fullScopeLabel = scopeValues.join(", "); + + return ( +
+ + {t("Authorized scope")}: + + {visibleScopeValues.join(", ")} + + {hiddenScopeCount > 0 ? ( + +{hiddenScopeCount} + ) : null} + +
+

{t("Authorized scope")}

+
    + {scopeValues.map((scopeValue) => ( +
  • {scopeValue}
  • + ))} +
+
+
+ ); +} + function SiteMapUtility({ destination, onChange, @@ -1715,6 +1766,21 @@ function ActivityPanel({ postId, accessToken }: { postId: string; accessToken: s ); } +const SEMANTIC_RELATION_LABELS: Record = { + org_member_of: "Organization member of", + org_unit_of: "Organization unit of", + org_suborganization_of: "Sub-organization of", + lw_supports: "Supports", +}; + +function semanticRelationLabel(relation: PostSemanticRelationship): string { + return t( + SEMANTIC_RELATION_LABELS[relation.predicate_code] ?? + relation.ontology_label ?? + relation.predicate_code, + ); +} + const ROLE_ACTOR_TYPE_RANK: Record = { prov_organization: 0, prov_team: 1, @@ -1814,6 +1880,10 @@ function groupKeyEventsByProject(events: PostKeyEvent[]): KeyEventGroup[] { return groups; } +function isWritingSourceDetailState(code: string | null | undefined): boolean { + return (code ?? "").trim().toUpperCase() === "W"; +} + function PostDetailPopup({ postId, accessToken, @@ -1916,6 +1986,7 @@ function PostDetailPopup({ }, []); function reloadKeymen() { + if (isWritingSourceDetailState(post?.source_detail_state_code)) return; fetchPostKeymen(accessToken, postId) .then((r) => { setKeymen(r.keymen); @@ -1933,6 +2004,7 @@ function PostDetailPopup({ } function reloadCounterparties() { + if (isWritingSourceDetailState(post?.source_detail_state_code)) return; fetchPostCounterparties(accessToken, postId) .then((r) => setCounterparties(r.counterparties)) .catch(() => setCounterparties([])); @@ -1965,7 +2037,45 @@ function PostDetailPopup({ let disposed = false; let contentPollTimer: number | undefined; const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined; - fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); + const loadDerivedPostData = (loadedPost: PostDetail) => { + if (isWritingSourceDetailState(loadedPost.source_detail_state_code)) return; + fetchPostEvaluation(accessToken, postId) + .then((r) => setEvaluation(r.responses)) + .catch(() => setEvaluation([])); + fetchPostFiveW1H(accessToken, postId) + .then(setFiveW1H) + .catch(() => setFiveW1H(null)); + fetchPostKeymen(accessToken, postId) + .then((r) => { + setKeymen(r.keymen); + setSourceAuthorContext(r.source_author_context ?? null); + }) + .catch(() => { + setKeymen([]); + setSourceAuthorContext(null); + }); + fetchPostCounterparties(accessToken, postId) + .then((r) => setCounterparties(r.counterparties)) + .catch(() => setCounterparties([])); + fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); + fetchPostKnowledgeGraph(accessToken, postId) + .then(setKnowledgeGraph) + .catch(() => setKnowledgeGraph(null)); + fetchPostAffiliateTree(accessToken, postId) + .then((r) => setAffiliateTrees(r.trees)) + .catch(() => setAffiliateTrees([])); + fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + }; + fetchPost(accessToken, postId, asOf) + .then((loadedPost) => { + if (disposed) return; + setPost(loadedPost); + loadDerivedPostData(loadedPost); + if (!isWritingSourceDetailState(loadedPost.source_detail_state_code)) { + reloadContent(); + } + }) + .catch((err) => setError(String(err))); const reloadContent = () => fetchPostContent(accessToken, postId) .then((content) => { @@ -1991,38 +2101,11 @@ function PostDetailPopup({ setStructureUnits([]); }); contentReloadRef.current = reloadContent; - reloadContent(); fetchPostBookmark(accessToken, postId) .then((r) => setBookmarked(r.bookmarked)) .catch(() => { setBookmarked(null); }); - fetchPostEvaluation(accessToken, postId) - .then((r) => setEvaluation(r.responses)) - .catch(() => setEvaluation([])); - fetchPostFiveW1H(accessToken, postId) - .then(setFiveW1H) - .catch(() => setFiveW1H(null)); - fetchPostKeymen(accessToken, postId) - .then((r) => { - setKeymen(r.keymen); - setSourceAuthorContext(r.source_author_context ?? null); - }) - .catch(() => { - setKeymen([]); - setSourceAuthorContext(null); - }); - fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); - fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); - fetchPostKnowledgeGraph(accessToken, postId) - .then(setKnowledgeGraph) - .catch(() => setKnowledgeGraph(null)); - fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); return () => { disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); @@ -2037,6 +2120,17 @@ function PostDetailPopup({ setSummary(null); setSummaryError(null); setSummaryLoading(true); + if (!post) { + return () => { + disposed = true; + }; + } + if (isWritingSourceDetailState(post.source_detail_state_code)) { + setSummaryLoading(false); + return () => { + disposed = true; + }; + } fetchPostSummary(accessToken, postId) .then((value) => { if (!disposed) { @@ -2055,7 +2149,7 @@ function PostDetailPopup({ return () => { disposed = true; }; - }, [postId, accessToken, summaryRetry]); + }, [postId, accessToken, summaryRetry, post]); const permanentLink = (() => { const url = new URL(window.location.href); @@ -2165,7 +2259,13 @@ function PostDetailPopup({

{t("Summary")}

- {!summary && (summaryLoading || contentStatus === "processing") ? ( + {isWritingSourceDetailState(post.source_detail_state_code) ? ( + + ) : !summary && (summaryLoading || contentStatus === "processing") ? ( )} + {summary.semantic_relationships && summary.semantic_relationships.length > 0 && ( + <> +

{t("Explicit semantic relationships")}

+
    + {summary.semantic_relationships.map((relation) => ( +
  • +
    + {relation.subject_name} + {semanticRelationLabel(relation)} + {relation.object_name} +
    + + {t("Evidence")}: {relation.evidence_text} · {t("Confidence")}: {Math.round(relation.confidence * 100)}% + +
    + {t("Evidence provenance")} + + {t("Subject type")}: {relation.subject_type} + + + {t("Object type")}: {relation.object_type} + + + {t("Extraction source")}: {relation.extraction_method ?? t("Recorded extraction")} + +
    +
  • + ))} +
+ + )} {summary.major_event_actions && summary.major_event_actions.length > 0 && ( <>

{t("Major event actions")}

@@ -2553,6 +2684,10 @@ function PostDetailPopup({ post.source_process_unit_catalog_name || post.source_sales_pool_code || post.source_sales_pool_name || + post.source_order_pool_code || + post.source_sales_order_code || + (post.source_sales_order_item_number !== null && post.source_sales_order_item_number !== undefined) || + post.source_inspection_point_code || post.source_customer_code || post.source_customer_name || post.source_project_code || @@ -2571,7 +2706,14 @@ function PostDetailPopup({ {post.source_detail_state_code ? ( <>
{t("Source detail state")}
-
{post.source_detail_state_code}
+ {(() => { + const presentation = presentSourceDetailState(post.source_detail_state_code); + return ( +
+ {presentation.code} · {presentation.description} +
+ ); + })()} ) : null} {post.source_draft_code ? ( @@ -2642,6 +2784,30 @@ function PostDetailPopup({
{post.source_sales_pool_name}
) : null} + {post.source_order_pool_code ? ( + <> +
{t("Source order pool")}
+
{post.source_order_pool_code}
+ + ) : null} + {post.source_sales_order_code ? ( + <> +
{t("Source sales order")}
+
{post.source_sales_order_code}
+ + ) : null} + {post.source_sales_order_item_number !== null && post.source_sales_order_item_number !== undefined ? ( + <> +
{t("Source sales order item")}
+
{post.source_sales_order_item_number}
+ + ) : null} + {post.source_inspection_point_code ? ( + <> +
{t("Source inspection point")}
+
{post.source_inspection_point_code}
+ + ) : null} {post.source_customer_code ? ( <>
{t("Source customer code")}
@@ -2680,6 +2846,40 @@ function PostDetailPopup({ ) : null}

{t("Raw source codes are shown; no state label was inferred.")}

+ {post.source_lineage_hints ? ( +
+

{t("Source lineage combination")}

+

+ {sourceLineageContextLabel(post.source_lineage_hints)}{" "} + {t("Combination code")}: {post.source_lineage_hints.combination_code}{" "} + {t("Inferred from field presence")} +

+

{t("Field combination")}

+
    + {SOURCE_LINEAGE_FIELDS.map((field) => { + const values: Record = { + customer: post.source_customer_code || post.source_customer_name || null, + order_pool: post.source_order_pool_code || null, + sales_order: post.source_sales_order_code || null, + sales_order_item: + post.source_sales_order_item_number === null || post.source_sales_order_item_number === undefined + ? null + : String(post.source_sales_order_item_number), + }; + const present = sourceLineageFieldIsPresent(post.source_lineage_hints!, field); + return ( +
  • + {sourceLineageFieldLabel(field)} + {present ? values[field] || t("Present") : t("Not present")} +
  • + ); + })} +
+

+ {t("Lifecycle vector")}: {post.source_lineage_hints.lifecycle_vector} · {t("Raw codes only")} +

+
+ ) : null}
)}
@@ -4031,6 +4231,30 @@ function presentVocType(option: PostFilterOption): { }; } +const SOURCE_DETAIL_STATE_PRESENTATIONS: Record = { + W: "Writing in progress", + D: "Pending approval", + A: "Approved", +}; + +function presentSourceDetailState(code: string): { + code: string; + description: string; + accessibleName: string; +} { + const normalizedCode = code.trim().toUpperCase(); + const englishLabel = SOURCE_DETAIL_STATE_PRESENTATIONS[normalizedCode] ?? "Unmapped source detail state"; + const description = t(englishLabel); + return { + code: normalizedCode || code, + description, + accessibleName: + description === englishLabel + ? `${normalizedCode || code} — ${englishLabel}` + : `${normalizedCode || code} — ${description} (${englishLabel})`, + }; +} + function PostList({ accessToken, showLabPanels = false, @@ -4079,6 +4303,8 @@ function PostList({ const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState([]); const [vocTypeFilterOptions, setVocTypeFilterOptions] = useState([]); + const [sourceDetailStateFilter, setSourceDetailStateFilter] = useState([]); + const [sourceDetailStateFilterOptions, setSourceDetailStateFilterOptions] = useState([]); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); @@ -4227,6 +4453,7 @@ function PostList({ (page - 1) * POST_PAGE_SIZE, query, typeFilter.length > 0 ? typeFilter : undefined, + sourceDetailStateFilter.length > 0 ? sourceDetailStateFilter : undefined, visibilityFilter === "all" ? undefined : visibilityFilter, sort, ); @@ -4234,6 +4461,7 @@ function PostList({ setPosts(response.posts); setTotalPosts(response.total_count); setVocTypeFilterOptions(response.voc_type_options ?? []); + setSourceDetailStateFilterOptions(response.source_detail_state_options ?? []); setVisibilityFilterOptions(response.visibility_options ?? []); setCurrentPage(page); } catch (err) { @@ -4242,7 +4470,7 @@ function PostList({ } finally { if (requestId === postsRequest.current) setLoadingPage(false); } - }, [accessToken, searchQuery, sortOrder, typeFilter, visibilityFilter]); + }, [accessToken, searchQuery, sortOrder, typeFilter, sourceDetailStateFilter, visibilityFilter]); useEffect(() => { void loadPostPage(1); @@ -4311,11 +4539,27 @@ function PostList({ code, label: loadedPosts.find((post) => post.voc_type_code === code)?.voc_type_label ?? code, })); + const sourceDetailStateOptions = sourceDetailStateFilterOptions.length + ? sourceDetailStateFilterOptions + : Array.from( + new Set( + loadedPosts + .map((post) => post.source_detail_state_code) + .filter((code): code is string => Boolean(code?.trim())), + ), + ) + .sort() + .map((code) => ({ code, label: code })); const filteredPosts = loadedPosts .filter((post) => { const matchesType = typeFilter.length === 0 || typeFilter.includes(post.voc_type_code); + const matchesSourceDetailState = + sourceDetailStateFilter.length === 0 || + (post.source_detail_state_code !== null && + post.source_detail_state_code !== undefined && + sourceDetailStateFilter.includes(post.source_detail_state_code)); const matchesVisibility = visibilityFilter === "all" || post.visibility_code === visibilityFilter; - return matchesType && matchesVisibility; + return matchesType && matchesSourceDetailState && matchesVisibility; }) .sort((left, right) => { if (sortOrder === "title") { @@ -4324,7 +4568,12 @@ function PostList({ const direction = sortOrder === "newest" ? -1 : 1; return direction * left.created_at.localeCompare(right.created_at); }); - const hasBoardFilters = Boolean(searchInput.trim()) || Boolean(searchQuery) || typeFilter.length > 0 || visibilityFilter !== "all"; + const hasBoardFilters = + Boolean(searchInput.trim()) || + Boolean(searchQuery) || + typeFilter.length > 0 || + sourceDetailStateFilter.length > 0 || + visibilityFilter !== "all"; const totalPages = Math.max(1, Math.ceil(totalPosts / POST_PAGE_SIZE)); const pageItems: Array = totalPages <= 7 @@ -4374,6 +4623,7 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter([]); + setSourceDetailStateFilter([]); setVisibilityFilter("all"); setSortOrder("newest"); }} @@ -4418,6 +4668,35 @@ function PostList({ ); })} + {sourceDetailStateOptions.length > 0 ? ( +
+ {t("Filter by source detail state")} +

+ {t("W = writing in progress · D = pending approval · A = approved")} +

+ {sourceDetailStateOptions.map((option) => { + const presentation = presentSourceDetailState(option.code); + return ( + + ); + })} +
+ ) : null}