From 9deeffcc9ebbeb2d24ac8421d01e209b62ac45c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 16:58:19 +0900 Subject: [PATCH 01/12] test(arch): reject obsolete direct CalDAV ownership --- tests/test_calendar_authority_fitness.py | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_calendar_authority_fitness.py diff --git a/tests/test_calendar_authority_fitness.py b/tests/test_calendar_authority_fitness.py new file mode 100644 index 000000000..b2d1de13d --- /dev/null +++ b/tests/test_calendar_authority_fitness.py @@ -0,0 +1,25 @@ +"""Architecture fitness for external calendar authority boundaries.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_obsolete_direct_caldav_adapter_and_configuration_are_absent() -> None: + """LineageWeave must not retain a direct pseudo-CalDAV provider boundary.""" + assert not (ROOT / "lineageweave" / "caldav_client.py").exists() + assert not (ROOT / "tests" / "test_caldav_client.py").exists() + + config_source = (ROOT / "backend" / "app" / "config.py").read_text(encoding="utf-8") + env_example = (ROOT / ".env.example").read_text(encoding="utf-8") + compose_source = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "caldav_base_url" not in config_source + assert "CALDAV_BASE_URL" not in config_source + assert "\nCALDAV_BASE_URL=" not in env_example + assert "CALDAV_BASE_URL:" not in compose_source + + # The versioned consumer/ACL remains explicit while the provider authority stays external. + assert "NARUON_CALENDAR_BASE_URL" in config_source + assert "NARUON_CALENDAR_SERVICE_TOKEN" in config_source From d86f5d2f7a821fd1229a4d669ddd64fd547716c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:04:41 +0900 Subject: [PATCH 02/12] refactor(calendar): remove obsolete CalDAV config --- backend/app/config.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 0fea9a591..aa51f0225 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -60,7 +60,6 @@ class Settings: searxng_base_url: str tepp_transport_url: str tepp_api_key: str - caldav_base_url: str naruon_calendar_base_url: str naruon_calendar_service_token: str rankweave_disabled: bool @@ -207,7 +206,6 @@ def load_settings() -> Settings: searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), tepp_api_key=os.environ.get("TEPP_API_KEY", "").strip(), - caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(), naruon_calendar_base_url=os.environ.get("NARUON_CALENDAR_BASE_URL", "").strip(), naruon_calendar_service_token=os.environ.get( "NARUON_CALENDAR_SERVICE_TOKEN", "" From 7d16d75306b9997bce747979821e72df6beadcac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:04:57 +0900 Subject: [PATCH 03/12] refactor(calendar): remove obsolete CalDAV env --- .env.example | 2 -- 1 file changed, 2 deletions(-) diff --git a/.env.example b/.env.example index 7282c5a2e..7af383ea1 100644 --- a/.env.example +++ b/.env.example @@ -52,10 +52,8 @@ LLM_GATEWAY_API_KEY= LLM_GATEWAY_EMBEDDING_MODEL= LLM_API_GATEWAY= LLM_API_KEY= -CALDAV_BASE_URL= # Optional Naruon calendar projection consume (ADR 0203 step 2 / #336). # Empty keeps observed events fail-closed. Never put an end-user bearer here. -# CALDAV_BASE_URL is not a fallback for this audience. NARUON_CALENDAR_BASE_URL= NARUON_CALENDAR_SERVICE_TOKEN= RANKWEAVE_DISABLED= From 72afc303f4b0e8d395a6d2f00d44f3d42cb6af8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:05:39 +0900 Subject: [PATCH 04/12] refactor(calendar): remove obsolete CalDAV wiring --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d0a2422aa..43cbbcf15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -176,7 +176,6 @@ services: SEARXNG_BASE_URL: http://searxng:8080 TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} TEPP_API_KEY: ${TEPP_API_KEY:-} - CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-} NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-} RANKWEAVE_DISABLED: ${RANKWEAVE_DISABLED:-} From 21e2245b63c9072936fcafe7b7a742e604d981fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:05:46 +0900 Subject: [PATCH 05/12] refactor(calendar): delete obsolete CalDAV adapter --- lineageweave/caldav_client.py | 67 ----------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 lineageweave/caldav_client.py diff --git a/lineageweave/caldav_client.py b/lineageweave/caldav_client.py deleted file mode 100644 index 3653cf791..000000000 --- a/lineageweave/caldav_client.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Small independent CalDAV event-consumption port for the organization calendar.""" - -from __future__ import annotations - -from dataclasses import dataclass -from urllib.parse import urlparse - -from lineageweave.http_client import get_json - -CALDAV_UNAVAILABLE_NEXT_ACTION = ( - "Configure CALDAV_BASE_URL to connect the independent CalDAV event source." -) - - -@dataclass(frozen=True) -class CalDavEvent: - """One calendar event read from the configured CalDAV source.""" - - event_id: str - summary: str - starts_at: str - - -class NullCalDavClient: - """Fail-closed CalDAV client used when CALDAV_BASE_URL is unset; never fabricates events.""" - - available = False - - def list_events(self) -> list[CalDavEvent]: - """Always return no events; there is no CalDAV source to query.""" - return [] - - -class HttpCalDavClient: - """CalDAV client backed by a real HTTP events endpoint.""" - - available = True - - def __init__(self, base_url: str) -> None: - parsed = urlparse(base_url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise ValueError("CALDAV_BASE_URL must be an http(s) URL with a hostname") - self._events_url = f"{base_url.rstrip('/')}/events" - - def list_events(self) -> list[CalDavEvent]: - """Fetch and parse events from the configured CalDAV endpoint.""" - payload = get_json( - self._events_url, timeout=10, service_peer_name="caldav" - ) - rows = payload.get("events") - if not isinstance(rows, list): - return [] - events: list[CalDavEvent] = [] - for row in rows: - if not isinstance(row, dict): - continue - values = (row.get("event_id"), row.get("summary"), row.get("starts_at")) - if not all(isinstance(value, str) and value.strip() for value in values): - continue - events.append(CalDavEvent(*(value.strip() for value in values))) - return events - - -def build_caldav_client(base_url: str) -> NullCalDavClient | HttpCalDavClient: - """Return the real source only when explicitly configured.""" - normalized = base_url.strip() - return HttpCalDavClient(normalized) if normalized else NullCalDavClient() From a56280cecd6a5cf38d59c04f430759b217de8854 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:05:53 +0900 Subject: [PATCH 06/12] test(calendar): drop obsolete CalDAV adapter tests --- tests/test_caldav_client.py | 58 ------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 tests/test_caldav_client.py diff --git a/tests/test_caldav_client.py b/tests/test_caldav_client.py deleted file mode 100644 index f6453231d..000000000 --- a/tests/test_caldav_client.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import pytest - -from lineageweave.caldav_client import ( - CalDavEvent, - HttpCalDavClient, - NullCalDavClient, - build_caldav_client, -) - - -def test_missing_base_url_drops_only_the_optional_caldav_channel() -> None: - client = build_caldav_client("") - - assert isinstance(client, NullCalDavClient) - assert not client.available - assert client.list_events() == [] - - -def test_http_client_reads_valid_events_and_ignores_malformed_rows(monkeypatch) -> None: - received = {} - - def fake_get_json(url: str, *, timeout: float, **kwargs) -> dict: - received.update(url=url, timeout=timeout, **kwargs) - return { - "events": [ - { - "event_id": "event-1", - "summary": "Review", - "starts_at": "2026-08-19T09:00:00Z", - }, - { - "event_id": "event-2", - "summary": "", - "starts_at": "2026-08-19T10:00:00Z", - }, - "not-an-event", - ] - } - - monkeypatch.setattr("lineageweave.caldav_client.get_json", fake_get_json) - client = build_caldav_client("https://calendar.example/caldav/") - - assert isinstance(client, HttpCalDavClient) - assert client.list_events() == [ - CalDavEvent("event-1", "Review", "2026-08-19T09:00:00Z") - ] - assert received == { - "url": "https://calendar.example/caldav/events", - "timeout": 10, - "service_peer_name": "caldav", - } - - -def test_invalid_caldav_url_is_rejected() -> None: - with pytest.raises(ValueError, match="CALDAV_BASE_URL"): - build_caldav_client("file:///tmp/events") From 82a91c0df2cd8c427ab7f41565e531849c024437 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:06:48 +0900 Subject: [PATCH 07/12] test(calendar): keep legacy CalDAV env ignored --- backend/tests/test_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index f37d89f32..f1d2fd49a 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -106,21 +106,21 @@ def test_local_keycloak_discovery_uses_backend_reachable_base_url(monkeypatch) - assert settings.keyverse_claim_binding_required is False -def test_naruon_calendar_audience_defaults_empty_and_is_not_caldav(monkeypatch) -> None: - """Missing Naruon settings keep the observed-event channel dropped.""" +def test_naruon_calendar_audience_defaults_empty_and_ignores_legacy_caldav_env(monkeypatch) -> None: + """Only the audience-scoped Naruon consumer can configure calendar observations.""" monkeypatch.delenv("NARUON_CALENDAR_BASE_URL", raising=False) monkeypatch.delenv("NARUON_CALENDAR_SERVICE_TOKEN", raising=False) monkeypatch.setenv("CALDAV_BASE_URL", "https://calendar.example/caldav/") settings = load_settings() assert settings.naruon_calendar_base_url == "" assert settings.naruon_calendar_service_token == "" - assert settings.caldav_base_url == "https://calendar.example/caldav/" + assert not hasattr(settings, "caldav_base_url") monkeypatch.setenv("NARUON_CALENDAR_BASE_URL", "https://naruon.example/projection") monkeypatch.setenv("NARUON_CALENDAR_SERVICE_TOKEN", "service-secret") wired = load_settings() assert wired.naruon_calendar_base_url == "https://naruon.example/projection" assert wired.naruon_calendar_service_token == "service-secret" - assert wired.naruon_calendar_service_token != wired.caldav_base_url + assert not hasattr(wired, "caldav_base_url") def test_rankweave_disabled_defaults_off(monkeypatch) -> None: From 6bb19596bae0c31595587b28a5b49a9ea8c4bf7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:06:59 +0900 Subject: [PATCH 08/12] docs(calendar): record obsolete boundary cleanup --- CHANGELOG.d/remove-obsolete-caldav-boundary.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.d/remove-obsolete-caldav-boundary.md diff --git a/CHANGELOG.d/remove-obsolete-caldav-boundary.md b/CHANGELOG.d/remove-obsolete-caldav-boundary.md new file mode 100644 index 000000000..6f3a09487 --- /dev/null +++ b/CHANGELOG.d/remove-obsolete-caldav-boundary.md @@ -0,0 +1,3 @@ +## Changed + +- Removed the superseded direct pseudo-CalDAV adapter and `CALDAV_BASE_URL` runtime/deployment configuration. External calendar observations remain available only through the strict audience-scoped Naruon projection consumer defined by ADR 0203; historical ADR text describing the retired `/events` experiment remains as migration evidence. From 232f6c5da7e2aa33c469034f7396fbc40052dcaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:51:27 +0900 Subject: [PATCH 09/12] test(calendar): reject stale CalDAV backend imports --- tests/test_calendar_authority_fitness.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_calendar_authority_fitness.py b/tests/test_calendar_authority_fitness.py index b2d1de13d..d0b9da911 100644 --- a/tests/test_calendar_authority_fitness.py +++ b/tests/test_calendar_authority_fitness.py @@ -12,11 +12,15 @@ def test_obsolete_direct_caldav_adapter_and_configuration_are_absent() -> None: assert not (ROOT / "tests" / "test_caldav_client.py").exists() config_source = (ROOT / "backend" / "app" / "config.py").read_text(encoding="utf-8") + main_source = (ROOT / "backend" / "app" / "main.py").read_text(encoding="utf-8") env_example = (ROOT / ".env.example").read_text(encoding="utf-8") compose_source = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert "caldav_base_url" not in config_source assert "CALDAV_BASE_URL" not in config_source + assert "lineageweave.caldav_client" not in main_source + assert "CALDAV_UNAVAILABLE_NEXT_ACTION" not in main_source + assert "build_caldav_client" not in main_source assert "\nCALDAV_BASE_URL=" not in env_example assert "CALDAV_BASE_URL:" not in compose_source From 14c00f7059f482ece7b04c55f483f39a74f9bbb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:55:47 +0900 Subject: [PATCH 10/12] fix(calendar): remove stale CalDAV backend import --- backend/app/main.py | 2680 +------------------------------------------ 1 file changed, 6 insertions(+), 2674 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..4db197885 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -197,10 +197,6 @@ ContextualOrchestratorAdjudicationClient, NullAdjudicationClient, ) -from lineageweave.caldav_client import ( - CALDAV_UNAVAILABLE_NEXT_ACTION, - build_caldav_client, -) from lineageweave.commitment_extraction import ( ContextualOrchestratorCommitmentExtractionClient, NullCommitmentExtractionClient, @@ -788,8 +784,7 @@ async def _post_filter_options( and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by option.lookup_category, display_order, option.code """ - # Safe SQL: this is a closed lookup statement; entity ids remain asyncpg parameters. - option_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + option_rows = await conn.fetch( options_sql, list(corporate_entity_ids), list(process_unit_ids) ) return ( @@ -811,7 +806,6 @@ async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): - """Return the tenant's current brand name, defaulting to "LineageWeave" if unset.""" async with pool.acquire() as conn: row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") if not row: @@ -824,8 +818,6 @@ async def update_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): - """Admin-only: upsert the tenant's brand name and return the stored value.""" - # Only admins can change settings _require_post_admin(account) brand_name = payload.get("brandName", "LineageWeave") async with pool.acquire() as conn: @@ -839,7 +831,6 @@ async def update_tenant_settings( @app.get("/healthz") async def healthz() -> dict[str, str]: - """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -848,11 +839,6 @@ async def read_me( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Return the provisioned account and the corps this token may walk. - - Multi-affiliation operators need those names to choose which entity - ``POST /api/analysis-runs`` should cover. - """ entities: list[dict[str, str]] = [] if account.corporate_entity_ids: async with pool.acquire() as conn: @@ -888,7 +874,6 @@ async def operations_dashboard( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Show quantified operational cases backed by visible source evidence.""" _require_post_read(account) async with pool.acquire() as conn: try: @@ -904,14 +889,10 @@ async def operations_dashboard( class LocalePreferenceRequest(BaseModel): - """Body of a PATCH /api/me/preferences request.""" - preferred_locale: Literal["en", "ko", "zh", "ja", "vi"] class CustomerHintResolveRequest(BaseModel): - """Body of a POST /api/customer-master/resolve-hint request.""" - hint_code: str @@ -921,7 +902,6 @@ async def update_me_preferences( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, str]: - """Persist member preferences without putting them in browser-only state.""" async with pool.acquire() as conn: await conn.execute( "update user_account set preferred_locale = $1 where user_account_id = $2", @@ -936,7 +916,6 @@ async def read_customer_master( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Return the authorized customer catalog and its cataloged Keymen.""" _require_post_read(account) if not account.corporate_entity_ids: return { @@ -948,8 +927,7 @@ async def read_customer_master( } async with pool.acquire() as conn: - # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound. - source_customer_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + source_customer_rows = await conn.fetch( f""" with scoped as ( select post_id, post_title, created_at, @@ -1012,8 +990,7 @@ async def read_customer_master( list(account.corporate_entity_ids), list(account.process_unit_ids), ) - # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound. - source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + source_author_rows = await conn.fetch( f""" with scoped as ( select post.post_id, post.post_title, post.created_at, @@ -1304,15 +1281,6 @@ async def resolve_customer_master_hint( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Resolve one observed customer-hint code to a real corporate_entity. - - Gated by post_admin, not post_read: this is a write action with a - real LLM-call cost, same discipline as extract-keymen/verify-relations. - Only an externally-corroborated proposed name ever creates or binds an - entity (`backend.app.customer_hint_ingestion`) -- an unresolved or - uncorroborated hint is returned as such, never guessed into the - catalog. - """ _require_post_admin(account) async with pool.acquire() as conn: try: @@ -1323,16 +1291,11 @@ async def resolve_customer_master_hint( request.hint_code, ) except (HttpClientError, OSError) as exc: - # resolve_and_verify_organization_name's resolution/verification - # calls raise on a failed request rather than silently returning - # "unresolved" -- a failed call is not the same claim as "the - # model looked and found nothing" (same discipline as - # verify-relations' identical try/except). raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", @@ -1352,7 +1315,6 @@ async def read_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """ABAC-filtered reconstruct graph bounded for browser rendering.""" _require_post_read(account) async with pool.acquire() as conn: return await visible_lineage_graph( @@ -1370,25 +1332,15 @@ async def rebuild_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Run reconstruct over every source_post and persist post_lineage_edge. - - post_admin only: this is a corpus-wide write. Reads stay ABAC-gated. - """ _require_post_admin(account) try: edges = await rebuild_lineage_from_pool(pool, llm=_adjudication_client()) except ChannelWeightsNotEstimated as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Channel weights are not estimated yet. Run " - "scripts/estimate_channel_weights.py, then rebuild again.", + "Channel weights are not estimated yet. Run scripts/estimate_channel_weights.py, then rebuild again.", ) from exc except (AdjudicationClientError, HttpClientError, OSError) as exc: - # This can issue up to MAXIMUM_LIVE_LLM_PAIR_EVALUATIONS sequential - # adjudication calls across the whole corpus (lineage_ingestion.py); - # a transient orchestrator hiccup on any one of them must not - # discard the rest of the reconstruction as a raw 500 -- same - # discipline as this file's other orchestrator call sites. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Lineage rebuild is unavailable: the orchestrator did not respond", @@ -1396,2624 +1348,4 @@ async def rebuild_lineage_graph( return {"edge_count": len(edges)} -@app.get("/api/posts") -async def list_posts( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - search: str | None = Query(None, max_length=200), - voc_type: 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), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """List authorized posts, with semantic evidence search when requested.""" - _require_post_read(account) - search_term = search.strip() if search and search.strip() else None - async with pool.acquire() as conn: - voc_type_options, visibility_options = await _post_filter_options( - conn, account.corporate_entity_ids, account.process_unit_ids - ) - voice_type_catalog = [ - {"code": row["lookup_code"], "label": row["lookup_label"]} - for row in await conn.fetch( - """ - select lookup_code, lookup_label - from common_lookup_value - where lookup_category = 'voc_type' - order by display_order, lookup_code - """ - ) - ] - body_search_ids: list[str] = [] - if search_term: - # Safe SQL: search SQL is a closed schema query; search_term is bound through $1. - body_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - f""" - select post_id - from source_post - where {SOURCE_POST_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)) - @@ plainto_tsquery('simple', $1)) - order by - case when lower(left(source_post_search_text(post_body), 16384)) - like '%' || lower($1) || '%' then 0 else 1 end, - ts_rank( - to_tsvector('simple', source_post_search_text(post_body)), - plainto_tsquery('simple', $1) - ) desc, - post_id - """, - search_term, - ) - 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. - rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - f""" - with page as ( - select post.post_id, post.post_title, post.voc_type_code, post.visibility_code, - post.source_stage_code, post.source_detail_state_code, - post.source_draft_code, post.source_deleted_flag, - post.source_author_code, post.source_author_name, - 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_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.process_unit_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 - else 2 - end as search_priority, - count(*) over() as total_count - from source_post post - where (post.visibility_code = 'public' - or (post.corporate_entity_id::text = any($2::text[]) - and (cardinality($9::text[]) = 0 - or post.process_unit_id::text = any($9::text[])))) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} - and ( - $1::text is null - or post.post_title ilike '%' || $1 || '%' - or post.thread_group_key ilike '%' || $1 || '%' - or post.secondary_grouping_key ilike '%' || $1 || '%' - or concat_ws(' ', - post.source_stage_code, - post.source_detail_state_code, - post.source_draft_code, - post.source_deleted_flag, - post.source_author_code, - post.source_author_name, - 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_customer_code, - post.source_customer_name, - post.source_project_code, - post.source_project_name, - post.source_system_code, - post.source_record_key - ) ilike '%' || $1 || '%' - or replace(post.post_id::text, '-', '') ilike '%' || lower($1) || '%' - or ( - char_length($1) >= 3 - and ( - similarity(replace(post.post_id::text, '-', ''), lower($1)) >= 0.78 - or similarity(lower(coalesce(post.source_record_key, '')), lower($1)) >= 0.78 - or word_similarity(lower($1), lower(post.post_title)) >= 0.45 - or word_similarity(lower($1), lower(post.secondary_grouping_key)) >= 0.45 - or word_similarity( - lower($1), - lower(concat_ws(' ', - post.source_stage_code, - post.source_detail_state_code, - post.source_draft_code, - post.source_deleted_flag, - post.source_author_code, - post.source_author_name, - 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_customer_code, - post.source_customer_name, - post.source_project_code, - post.source_project_name - )) - ) >= 0.45 - ) - ) - or post.post_id = any($5::uuid[]) - or exists ( - select 1 from post_project_mention project - where project.post_id = post.post_id - and (project.project_name ilike '%' || $1 || '%' - or project.evidence_text ilike '%' || $1 || '%' - or project.ontology_iri ilike '%' || $1 || '%' - or (char_length($1) >= 3 and word_similarity(lower($1), lower(project.project_name)) >= 0.45)) - ) - or exists ( - select 1 from post_summary_role role - where role.post_id = post.post_id - and (role.actor_name ilike '%' || $1 || '%' - or role.responsibility ilike '%' || $1 || '%' - or coalesce(role.affiliated_organization_name, '') ilike '%' || $1 || '%' - or (char_length($1) >= 3 and word_similarity(lower($1), lower(role.actor_name)) >= 0.45)) - ) - or exists ( - select 1 - from post_person_mention mention - join cataloged_person person on person.person_id = mention.person_id - where mention.post_id = post.post_id - and ( - person.person_name ilike '%' || $1 || '%' - or (char_length($1) >= 3 and word_similarity(lower($1), lower(person.person_name)) >= 0.45) - ) - ) - or exists ( - select 1 from post_summary_result summary - where summary.post_id = post.post_id - and summary.korean_summary ilike '%' || $1 || '%' - ) - or exists ( - select 1 from post_summary_event event - where event.post_id = post.post_id - and event.event_text ilike '%' || $1 || '%' - ) - or exists ( - select 1 from corporate_entity customer - where customer.corporate_entity_id = post.corporate_entity_id - and (customer.entity_name ilike '%' || $1 || '%' - or customer.corporate_entity_code ilike '%' || $1 || '%') - ) - or exists ( - select 1 from process_unit process - where process.process_unit_id = post.process_unit_id - and (process.process_unit_name ilike '%' || $1 || '%' - or process.process_unit_code ilike '%' || $1 || '%') - ) - or exists ( - select 1 from user_account author - where author.user_account_id = post.author_account_id - and (author.display_name ilike '%' || $1 || '%' - or author.email_address ilike '%' || $1 || '%') - ) - or exists ( - select 1 - from account_affiliation affiliation - join corporate_entity affiliated - on affiliated.corporate_entity_id = affiliation.corporate_entity_id - where affiliation.user_account_id = post.author_account_id - and (affiliated.entity_name ilike '%' || $1 || '%' - or affiliated.corporate_entity_code ilike '%' || $1 || '%') - ) - ) - and ($3::text[] is null or exists ( - select 1 from source_post_voice voice_filter - where voice_filter.post_id = post.post_id - and voice_filter.effective_to is null - and voice_filter.voice_type_code = any($3::text[]) - )) - and ($4::text is null or post.visibility_code = $4) - order by - search_priority asc, - case - when $1::text is not null and post.post_id = any($5::uuid[]) - then array_position($5::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, - post.post_id desc - offset $6 - limit $7 - ) - select page.*, - case - when $1::text is not null - and strpos(lower(source_post_search_text(post.post_body)), lower($1)) > 0 - then btrim(substring( - source_post_search_text(post.post_body) - from greatest( - 1, - strpos(lower(source_post_search_text(post.post_body)), lower($1)) - 140 - ) for 420 - )) - else btrim(left(source_post_search_text(post.post_body), 420)) - end as post_body_excerpt, - char_length(coalesce(post.post_body, '')) > 420 as post_body_truncated, - coalesce(projects.project_evidence, '[]'::json) as project_evidence, - coalesce(voices.voice_types, '[]'::json) as voice_types - from page - join source_post post on post.post_id = page.post_id - left join lateral ( - select json_agg( - json_build_object( - 'project_key', project.project_key, - 'project_name', project.project_name, - 'evidence', project.evidence_text, - 'confidence', project.confidence, - 'ontology_iri', project.ontology_iri, - 'ontology_label', 'Project', - 'extraction_method', project.extraction_method, - 'resolution_status', 'semantic_candidate', - 'provenance', 'post_project_mention.evidence_text' - ) - order by project.confidence desc, project.project_name, project.project_key - ) as project_evidence - from ( - select project_key, project_name, evidence_text, confidence, - ontology_iri, extraction_method - from post_project_mention - where post_id = page.post_id - order by confidence desc, project_name, project_key - limit 5 - ) project - ) projects on true - left join lateral ( - select json_agg( - json_build_object( - 'code', voice.voice_type_code, - 'label', lookup.lookup_label, - 'is_primary', voice.is_primary, - 'truth_status_code', voice.truth_status_code, - 'evidence_available', voice.provenance_assertion_id is not null - ) - order by voice.is_primary desc, lookup.display_order, - voice.voice_type_code - ) as voice_types - from source_post_voice voice - join common_lookup_value lookup - on lookup.lookup_category = 'voc_type' - and lookup.lookup_code = voice.voice_type_code - where voice.post_id = page.post_id - and voice.effective_to is null - ) voices on true - order by - case when $1::text is not null then page.search_priority end asc, - case - when $1::text is not null and page.search_priority = 1 - then array_position($5::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, - 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, - visibility.strip() if visibility and visibility.strip() else None, - body_search_ids, - offset, - limit, - sort, - list(account.process_unit_ids), - ) - visible = [row for row in rows if _can_see_post(account, row)] - labels = await _lookup_post_labels(conn, visible) - total_count = int(rows[0]["total_count"]) if rows else 0 - return { - "posts": [_serialize_post(row, labels) for row in visible], - "total_count": total_count, - "limit": limit, - "offset": offset, - "voc_type_options": voc_type_options, - "voice_type_catalog": voice_type_catalog, - "visibility_options": visibility_options, - } - - -@app.get("/api/posts/{post_id}") -async def read_post( - post_id: str, - as_of: str | None = None, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Return one source_post, or 404 / 403 if it is missing or out of scope. - - ``as_of`` adds ``known_at`` when a ``source_post_revision`` covers that - clock (ADR 0025). The live ``post_body`` stays the live row. A missing - cover is omitted -- never a fabricated cutoff sentence. Next action: - pass the analysis-run cutoff, then compare ``known_at`` with the live - body before treating the live text as reconstructed evidence. - """ - _require_post_read(account) - settings = load_settings() - as_of_clock = None - if as_of is not None: - try: - as_of_clock = parse_as_of_clock(as_of) - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "as_of must be an ISO-8601 timestamp. Use the run cutoff, " - "then compare the known body with the live body.", - ) from exc - async with pool.acquire() as conn: - # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. - row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - "select post_id, post_title, post_body, voc_type_code, visibility_code, " - "source_stage_code, source_detail_state_code, source_draft_code, source_deleted_flag, " - "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_customer_code, source_customer_name, source_project_code, source_project_name, " - "source_system_code, source_record_key, " - "corporate_entity_id, process_unit_id, created_at " - f"from source_post where post_id = $1 and {SOURCE_POST_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") - labels = await _lookup_post_labels(conn, [row]) - project_evidence = await _load_project_evidence( - conn, post_id, row["source_project_code"], row["source_project_name"] - ) - voice_types = await _load_post_voice_types(conn, post_id, as_of_clock) - if as_of_clock is None: - occupational_construct_assertions = ( - await load_occupational_construct_assertions(conn, post_id) - ) - occupational_construct_evidence_status = ( - await load_occupational_construct_evidence_status( - conn, - post_id, - evidence_configured=bool( - settings.orchestrator_base_url - and settings.orchestrator_api_key - ), - ) - ) - else: - occupational_construct_assertions = [] - occupational_construct_evidence_status = "historical_unavailable" - known_at = None - if as_of_clock is not None: - known_at = await fetch_known_at_revision(conn, post_id, as_of_clock) - payload = { - **_serialize_post(row, labels), - "post_body": row["post_body"], - "project_evidence": project_evidence, - "voice_types": voice_types, - "occupational_construct_assertions": occupational_construct_assertions, - } - if known_at is not None: - payload["known_at"] = known_at - return payload - - -class CreatePostVoiceAssignmentRequest(BaseModel): - """Evidence and governed truth state for one additional Voice assignment.""" - - voice_type_code: str - truth_status_code: str - evidence_post_id: UUID - - -@app.post( - "/api/posts/{post_id}/voice-assignments", - status_code=status.HTTP_201_CREATED, -) -async def create_post_voice_assignment( - post_id: str, - request: CreatePostVoiceAssignmentRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Attach one additional Voice using an authorized evidence Post.""" - _require_post_admin(account) - await _load_visible_post(post_id, account, pool) - evidence_post_id = str(request.evidence_post_id) - if evidence_post_id != post_id: - await _load_visible_post(evidence_post_id, account, pool) - async with pool.acquire() as conn: - try: - await persist_additional_voice_assignment( - conn, - post_id=post_id, - voice_type_code=request.voice_type_code, - truth_status_code=request.truth_status_code, - evidence_post_id=evidence_post_id, - ) - except PrimaryVoiceAssignmentError as exc: - raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc - except ( - asyncpg.CheckViolationError, - asyncpg.ForeignKeyViolationError, - ) as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "voice_type_code and truth_status_code must use governed lookup values", - ) from exc - assignments = await _load_post_voice_types(conn, post_id) - assignment = next( - item for item in assignments if item["code"] == request.voice_type_code - ) - await publish_activity_event( - valkey, - post_id, - "voice_assignment_added", - account.user_account_id, - "Additional Voice evidence connected", - ) - return assignment - - -@app.get("/api/posts/{post_id}/content") -async def read_post_content( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Return persisted content evidence; never derive or invent buyer copy.""" - await _load_visible_post(post_id, account, pool) - queue_event: tuple[str, str] | None = None - async with pool.acquire() as conn: - unit_rows = await conn.fetch( - """ - select unit.unit_index, unit.unit_kind_code, unit.unit_label, unit.unit_text, - coalesce(structure.indent_level, 0) as indent_level, - structure.decision_source_code, structure.confidence, - structure.evidence_text - from post_content_unit unit - left join post_content_unit_structure structure - on structure.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = $1 - order by unit.unit_index - """, - post_id, - ) - content_status = post_content_api_status( - None, - content_present=bool(unit_rows), - ) - body_row = await conn.fetchrow( - "select post_body from source_post where post_id = $1", post_id - ) - raw_body = None if body_row is None else body_row["post_body"] - if isinstance(raw_body, str) and raw_body.strip(): - content_present = bool(unit_rows) - content_complete = await post_content_is_complete( - conn, - post_id, - require_embedding=bool( - load_settings().orchestrator_base_url - and load_settings().orchestrator_api_key - ), - require_structure=bool( - load_settings().orchestrator_base_url - and load_settings().orchestrator_api_key - ), - ) - async with conn.transaction(): - job = await ensure_post_content_job( - conn, - post_id, - raw_body, - content_complete=content_complete, - ) - content_status = post_content_api_status( - job.status_code, - content_present=content_present, - ) - if job.should_publish: - queue_event = (job.post_id, job.source_body_sha256) - rows = await conn.fetch( - """ - select image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption, - coalesce( - array_agg(tag.tag_text order by tag.tag_text) - filter (where tag.tag_text is not null), - '{}'::text[] - ) as tags - from post_content_unit unit - join post_content_image image - on image.post_content_unit_id = unit.post_content_unit_id - left join post_content_image_tag tag - on tag.post_content_image_id = image.post_content_image_id - where unit.post_id = $1 - group by image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption - order by unit.unit_index - """, - post_id, - ) - region_rows = await conn.fetch( - """ - select image.post_content_image_id, region.region_index, - region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption, - coalesce( - array_agg(tag.tag_text order by tag.tag_text) - filter (where tag.tag_text is not null), - '{}'::text[] - ) as tags - from post_content_image image - join post_content_image_region region - on region.post_content_image_id = image.post_content_image_id - left join post_content_image_region_tag tag - on tag.post_content_image_region_id = region.post_content_image_region_id - where image.post_content_image_id = any($1::uuid[]) - group by image.post_content_image_id, region.region_index, - region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption - order by image.post_content_image_id, region.region_index - """, - [row["post_content_image_id"] for row in rows], - ) if rows else [] - if queue_event is not None: - await publish_post_content_event( - valkey, - post_id=queue_event[0], - source_body_digest=queue_event[1], - ) - regions_by_image: dict[str, list[dict[str, Any]]] = {} - for row in region_rows: - regions_by_image.setdefault(str(row["post_content_image_id"]), []).append( - { - "region_index": row["region_index"], - "x_ratio": row["x_ratio"], - "y_ratio": row["y_ratio"], - "width_ratio": row["width_ratio"], - "height_ratio": row["height_ratio"], - "status_code": row["description_status_code"], - "extracted_text": row["extracted_text"], - "caption": row["caption"], - "tags": list(row["tags"] or []), - } - ) - return { - "status": content_status, - "units": [ - { - "unit_index": row["unit_index"], - "unit_kind_code": row["unit_kind_code"], - "unit_label": row["unit_label"], - "unit_text": row["unit_text"], - "indent_level": row["indent_level"], - "indent_source_code": row["decision_source_code"] or "unresolved", - "indent_confidence": float(row["confidence"] or 0), - "indent_evidence": row["evidence_text"] or "", - } - for row in unit_rows - ], - "images": [ - { - "unit_index": row["unit_index"], - "mime_type": row["mime_type"], - "status_code": row["description_status_code"], - "extracted_text": row["extracted_text"], - "caption": row["caption"], - "tags": list(row["tags"] or []), - "regions": regions_by_image.get(str(row["post_content_image_id"]), []), - } - for row in rows - ] - } - - -async def _load_visible_post( - post_id: str, - account: CurrentAccount, - pool: asyncpg.Pool, -) -> asyncpg.Record: - """Load one post the account may see, or raise 404 / 403.""" - _require_post_read(account) - async with pool.acquire() as conn: - # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. - row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - """ - select source_post.post_id, source_post.post_title, source_post.post_body, - source_post.voc_type_code, - source_post.visibility_code, source_post.corporate_entity_id, - source_post.process_unit_id, 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, - source_post.source_project_code, source_post.source_sales_pool_code, - customer.corporate_entity_code - from source_post - left join corporate_entity customer - 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')}", - 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") - return row - - -@app.get("/api/posts/{post_id}/similar-voc") -async def read_similar_voc( - post_id: str, - offset: int = Query(0, ge=0), - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Return authorized, semantically adjudicated prior VOC evidence. - - Persisted ``repeat_issue`` classifications narrow the candidate corpus - without lexical matching. contextual-orchestrator then establishes each - pair; event time orders the display and is not a relevance score. - """ - focal = await _load_visible_post(post_id, account, pool) - client = _similar_voc_client() - if client is None: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "similar VOC inference is unavailable; configure contextual-orchestrator and retry", - ) - async with pool.acquire() as conn: - # Safe SQL: the sole interpolation is the closed eligibility fragment; request and identity values remain asyncpg parameters. - rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - f""" - select post.post_id, post.post_title, post.post_body, - post.visibility_code, post.corporate_entity_id, post.process_unit_id, - coalesce(post.event_occurred_at, post.created_at) as occurred_at - from operations_case_classification classification - join source_post post on post.post_id = classification.post_id - where classification.case_kind_code = 'repeat_issue' - and post.post_id <> $1 - and post.post_body <> '' - and (post.visibility_code = 'public' - or (post.corporate_entity_id::text = any($2::text[]) - and (cardinality($3::text[]) = 0 - or post.process_unit_id::text = any($3::text[])))) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} - order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id - offset $4 limit $5 - """, - post_id, - list(account.corporate_entity_ids), - list(account.process_unit_ids), - offset, - _SIMILAR_VOC_PAGE_SIZE + 1, - ) - candidates = [row for row in rows[:_SIMILAR_VOC_PAGE_SIZE] if _can_see_post(account, row)] - - async def _adjudicate(candidate: asyncpg.Record): - with use_llm_metadata(build_post_llm_metadata(post_id, focal)): - return await asyncio.to_thread( - client.analyze, - focal["post_title"], - focal["post_body"], - str(candidate["post_id"]), - candidate["post_title"], - candidate["post_body"], - ) - - try: - results = await asyncio.wait_for( - asyncio.gather(*(_adjudicate(candidate) for candidate in candidates), return_exceptions=True), - timeout=_SIMILAR_VOC_REQUEST_TIMEOUT_SECONDS, - ) - except TimeoutError: - results = () - items = [] - for candidate, evidence in zip(candidates, results): - if evidence is None or isinstance(evidence, BaseException): - continue - items.append( - { - "post_id": evidence.candidate_post_id, - "post_title": candidate["post_title"], - "issue_summary": evidence.issue_summary, - "focal_evidence_text": evidence.focal_evidence_text, - "candidate_evidence_text": evidence.candidate_evidence_text, - "customer_cohort_text": evidence.customer_cohort_text, - "action_history": evidence.action_history, - "occurred_at": candidate["occurred_at"].isoformat(), - } - ) - return { - "items": items, - "next_offset": offset + _SIMILAR_VOC_PAGE_SIZE - if len(rows) > _SIMILAR_VOC_PAGE_SIZE - else None, - } - - -async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> str: - """Render author, business-unit, sales-pool, and customer hints without treating them as proof.""" - rows = await conn.fetch( - """ - select author.user_account_id as author_account_id, - author.display_name as author_name, - post.source_author_code, - post.source_author_name, - post.source_company_code, - post.source_company_name, - source_company.entity_name as source_company_catalog_name, - post.source_process_unit_code, - post.source_process_unit_name, - source_process_unit.process_unit_name as source_process_unit_catalog_name, - post.source_sales_pool_code, - post.source_sales_pool_name, - post.source_customer_code, - post.source_customer_name, - source_customer.entity_name as source_customer_catalog_name, - post.source_project_code, - post.source_project_name, - post.secondary_grouping_key as project_field, - customer.entity_name as customer_name, - affiliated.entity_name as author_affiliation_name - from source_post post - join user_account author on author.user_account_id = post.author_account_id - left join corporate_entity customer on customer.corporate_entity_id = post.corporate_entity_id - left join corporate_entity source_company - on source_company.corporate_entity_code = nullif(btrim(post.source_company_code), '') - left join process_unit source_process_unit - on source_process_unit.process_unit_code = nullif(btrim(post.source_process_unit_code), '') - left join corporate_entity source_customer - on source_customer.corporate_entity_code = nullif(btrim(post.source_customer_code), '') - left join account_affiliation account_aff - on account_aff.user_account_id = post.author_account_id - left join corporate_entity affiliated - on affiliated.corporate_entity_id = account_aff.corporate_entity_id - where post.post_id = $1 - """, - post_id, - ) - if not rows: - return "no structured hints available" - first = rows[0] - source_context_present = any( - first[field] is not None - for field in ( - "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_customer_code", - "source_customer_name", - "source_project_code", - "source_project_name", - ) - ) - source_author_name = first["source_author_name"] - if source_author_name and source_author_name == first["source_author_code"]: - source_author_name = None - return format_semantic_hints( - author_name=source_author_name or first["author_name"], - author_account_id=str(first["author_account_id"]), - author_account_name=first["author_name"], - author_affiliations=( - str(row["author_affiliation_name"]) - for row in rows - if row["author_affiliation_name"] - ), - order_pool_code=first["source_sales_pool_code"], - order_pool_name=first["source_sales_pool_name"], - project_field=first["project_field"], - customer_name=first["customer_name"], - source_author_code=first["source_author_code"], - source_author_name=source_author_name, - source_company_code=first["source_company_code"], - source_company_name=first["source_company_name"], - source_company_catalog_name=first["source_company_catalog_name"], - source_business_unit_code=first["source_process_unit_code"], - source_process_unit_name=first["source_process_unit_name"], - 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_customer_code=first["source_customer_code"], - source_customer_name=first["source_customer_name"], - source_customer_catalog_name=first["source_customer_catalog_name"], - source_project_code=first["source_project_code"], - source_project_name=first["source_project_name"], - source_context_present=source_context_present, - ) - - -async def _load_account_affiliation_hints( - conn: asyncpg.Connection, - account_ids: list[str], - corporate_entity_ids: list[str], -) -> dict[str, list[dict[str, Any]]]: - """Load authorized account affiliations as non-binding Keyman context.""" - if not account_ids or not corporate_entity_ids: - return {} - rows = await conn.fetch( - """ - select affiliation.user_account_id, - entity.corporate_entity_id, - entity.entity_name, - process.process_unit_code, - process.process_unit_name - from account_affiliation affiliation - join corporate_entity entity - on entity.corporate_entity_id = affiliation.corporate_entity_id - left join process_unit process - on process.process_unit_id = affiliation.process_unit_id - where affiliation.user_account_id = any($1::uuid[]) - and affiliation.corporate_entity_id = any($2::uuid[]) - order by entity.entity_name, process.process_unit_code - """, - account_ids, - corporate_entity_ids, - ) - affiliations: dict[str, list[dict[str, Any]]] = {} - for row in rows: - account_id = str(row["user_account_id"]) - affiliations.setdefault(account_id, []).append( - { - "corporate_entity_id": str(row["corporate_entity_id"]), - "entity_name": row["entity_name"], - "process_unit_code": row["process_unit_code"], - "process_unit_name": row["process_unit_name"], - } - ) - return affiliations - - -async def _load_source_author_context( - conn: asyncpg.Connection, - post_id: str, - corporate_entity_ids: list[str], -) -> dict[str, Any] | None: - """Return source-author/account context without binding a cataloged person.""" - row = await conn.fetchrow( - """ - select post.author_account_id, - author.display_name as account_display_name, - nullif(btrim(post.source_author_code), '') as source_author_code, - nullif(btrim(post.source_author_name), '') as source_author_name - from source_post post - join user_account author on author.user_account_id = post.author_account_id - where post.post_id = $1 - """, - post_id, - ) - if row is None: - return None - account_id = str(row["author_account_id"]) - affiliations = ( - await _load_account_affiliation_hints(conn, [account_id], corporate_entity_ids) - ).get(account_id, []) - source_author_name = row["source_author_name"] - if source_author_name and source_author_name.casefold() == str(row["source_author_code"] or '').casefold(): - source_author_name = None - return { - "author_account_id": account_id, - "account_display_name": row["account_display_name"], - "source_author_code": row["source_author_code"], - "source_author_name": source_author_name, - "account_affiliations": affiliations, - "resolution_status": ( - "our_side_context_only" if affiliations else "source_author_hint_only" - ), - "provenance": ( - "source_post.author_account_id/user_account.display_name/" - "account_affiliation.corporate_entity_id/source_post.source_author_code/source_post.source_author_name" - ), - } - - -@app.get("/api/posts/{post_id}/keymen") -async def read_post_keymen( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """People mentioned on one visible post, with their N:N affiliations.""" - post = await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - keymen = await fetch_post_keymen(conn, post_id) - source_author_context = await _load_source_author_context( - conn, post_id, list(account.corporate_entity_ids) - ) - return { - "post_id": str(post["post_id"]), - "keymen": keymen, - "source_author_context": source_author_context, - } - - -@app.get("/api/keymen/{person_id}/related") -async def read_related_keymen( - person_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """RWR-ranked related nodes from one person, hiding unseen posts.""" - _require_post_read(account) - 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)) - if not visible_post_ids: - raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this person") - person = await conn.fetchrow( - "select person_id, person_name, person_side_code from cataloged_person where person_id = $1", - person_id, - ) - related = await related_for_person(conn, person_id, visible_post_ids) - role_history = await fetch_person_role_history(conn, person_id, visible_post_ids) - return { - "person_id": str(person["person_id"]), - "person_name": person["person_name"], - "person_side_code": person["person_side_code"], - "related": related, - "role_history": role_history, - } - - -@app.get("/api/corporate-entities/{entity_id}/related") -async def read_related_corporate_entity( - entity_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """RWR-ranked related nodes from one corporate entity, hiding unseen posts.""" - _require_post_read(account) - async with pool.acquire() as conn: - 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) - ) - if not visible_post_ids: - raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this entity") - entity = await conn.fetchrow( - "select corporate_entity_id, entity_name from corporate_entity " - "where corporate_entity_id = $1", - entity_id, - ) - related = await related_for_entity(conn, entity_id, visible_post_ids) - return { - "corporate_entity_id": str(entity["corporate_entity_id"]), - "entity_name": entity["entity_name"], - "related": related, - } - - -@app.get("/api/teams/{team_id}/related") -async def read_related_team( - team_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """RWR-ranked related nodes from one cataloged team, hiding unseen posts.""" - _require_post_read(account) - async with pool.acquire() as conn: - 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) - ) - if not visible_post_ids: - raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") - team = await conn.fetchrow( - "select team_id, team_name from cataloged_team where team_id = $1", - team_id, - ) - related = await related_for_team(conn, team_id, visible_post_ids) - return { - "team_id": str(team["team_id"]), - "team_name": team["team_name"], - "related": related, - } - - -@app.get("/api/ontology/neighborhood") -async def read_ontology_neighborhood( - focus_node_type: str = Query(..., min_length=1), - focus_node_id: str = Query(..., min_length=1), - maximum_depth: int = Query(DEFAULT_MAXIMUM_DEPTH, ge=1, le=HARD_MAXIMUM_DEPTH), - maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES, ge=1, le=HARD_MAXIMUM_NODES), - maximum_edges: int = Query(DEFAULT_MAXIMUM_EDGES, ge=1, le=HARD_MAXIMUM_EDGES), - allowed_property_codes: list[str] | None = Query(None), - knowledge_cutoff: str | None = Query(None), - cursor: str | None = Query(None), - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Typed ontology/KG neighborhood, distinct from Event Lineage.""" - _require_post_read(account) - cutoff_clock = None - if knowledge_cutoff: - try: - cutoff_clock = parse_as_of_clock(knowledge_cutoff) - except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - try: - async with pool.acquire() as conn: - neighborhood = await visible_ontology_neighborhood( - conn, - focus_node_type_code=focus_node_type, - focus_node_id=focus_node_id, - can_see_post=lambda row: _can_see_post(account, row), - maximum_depth=maximum_depth, - maximum_nodes=maximum_nodes, - maximum_edges=maximum_edges, - allowed_property_codes=parse_allowed_property_query(allowed_property_codes), - knowledge_cutoff=cutoff_clock, - cursor=cursor, - source_cursor_secret=load_settings().ontology_source_cursor_secret, - source_cursor_scope=account.user_account_id, - ) - payload = neighborhood_to_payload(neighborhood) - except OntologyNeighborhoodError as exc: - raise HTTPException(neighborhood_error_http_status(exc), neighborhood_error_detail(exc)) from None - return payload - - -@app.get("/api/ontology/worker-functions/{domain}/{rank}") -async def read_worker_function_psychology( - domain: str, - rank: int, - account: CurrentAccount = Depends(get_current_account), -) -> dict[str, Any]: - """I/O-Psychology demand profile for one DOT/FJA worker function (ADR 0251). - - Serves the grounded cognitive, affective, and behavioral construct - relations declared in the published ontology. An undeclared domain/rank - pair is an honest 404 -- never a fabricated profile. Unrecognized - domains are client errors. - """ - _require_post_read(account) - if rank < 0 or rank > 100 or domain not in {"data", "people", "things"}: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "domain must be one of data, people, things; rank within the published table extents", - ) - payload = worker_function_profile_payload(domain, rank) - if payload is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "undeclared worker function") - return payload - - -@app.get("/api/ontology/worker-function-constructs") -async def read_worker_function_construct_catalog( - account: CurrentAccount = Depends(get_current_account), -) -> dict[str, Any]: - """Cognitive, affective, and behavioral construct catalog (ADR 0251). - - Returns the deterministic typed construct groups and their nomological - relations from the published ontology, for ontology/evidence surfaces. - """ - _require_post_read(account) - return construct_catalog_payload() - - -@app.get("/api/occupations/{onetsoc_code}/ratings") -async def read_occupation_ratings( - onetsoc_code: str = Path(..., pattern=r"^[0-9]{2}-[0-9]{4}\.[0-9]{2}$"), - data_release_code: str = Query( - ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" - ), - source_table_code: str = Query( - ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" - ), - limit: int = Query(100, ge=1, le=500), - offset: int = Query(0, ge=0, le=10000), - _account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, object]: - """Return one authenticated, provenance-bearing occupation source profile.""" - async with pool.acquire() as conn: - return await fetch_occupation_ratings( - conn, - data_release_code=data_release_code, - source_table_code=source_table_code, - onetsoc_code=onetsoc_code, - limit=limit, - offset=offset, - ) - - -@app.get("/api/occupation-rating-sources") -async def read_occupation_rating_sources( - _account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, list[dict[str, object]]]: - """Return the authenticated catalog of imported occupation-rating sources.""" - async with pool.acquire() as conn: - return await fetch_occupation_rating_sources(conn) - - -@app.get("/api/occupation-rating-occupations") -async def read_rating_source_occupations( - data_release_code: str = Query( - ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" - ), - source_table_code: str = Query( - ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" - ), - _account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, object]: - """Return occupations represented in one imported rating source.""" - async with pool.acquire() as conn: - return await fetch_rating_source_occupations( - conn, - data_release_code=data_release_code, - source_table_code=source_table_code, - ) - - -@app.get("/api/occupational-constructs/search") -async def search_occupational_constructs( - q: str = Query(..., min_length=1), - family: str | None = Query(None), - knowledge_cutoff: str | None = Query(None), - cursor: str | None = Query(None), - limit: int | None = Query(None), - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Assertion-backed catalog matches the reviewer may already open.""" - _require_post_read(account) - cutoff_clock = None - if knowledge_cutoff: - try: - cutoff_clock = parse_as_of_clock(knowledge_cutoff) - except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - try: - async with pool.acquire() as conn: - page = await search_visible_occupational_constructs( - conn, - query=q, - family_code=family, - knowledge_cutoff=cutoff_clock, - cursor=cursor, - limit=limit, - can_see_post=lambda row: _can_see_post(account, row), - ) - except OccupationalConstructSearchError as exc: - raise HTTPException( - occupational_construct_search_http_status(exc), - occupational_construct_search_error_detail(exc), - ) from None - return search_page_to_payload(page) - -@app.get("/api/projects/{project_key}/history") -async def read_project_history( - project_key: str, - focus_post_id: UUID | None = Query(None), - knowledge_cutoff: str | None = Query(None), - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Return one authorization-bounded project-history evidence projection.""" - - _require_post_read(account) - try: - cutoff = parse_as_of_clock(knowledge_cutoff) if knowledge_cutoff else datetime.now(timezone.utc) - async with pool.acquire() as conn: - return await fetch_project_history_projection( - conn, - project_key=project_key, - focus_post_id=str(focus_post_id) if focus_post_id else None, - knowledge_cutoff=cutoff, - corporate_entity_ids=sorted(account.corporate_entity_ids), - process_unit_ids=sorted(account.process_unit_ids), - ) - except ProjectHistoryRequestError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - except ProjectHistoryNotFound: - raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from None - - -@app.get("/api/posts/{post_id}/counterparties") -async def read_post_counterparties( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Classified counterparty orgs for one visible post. - - A name that resolves to a cataloged ``corporate_entity`` carries - that id so the popup can start the same related walk as an - affiliate-tree org click. Unresolved names stay ``null``. - """ - post = await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - counterparties = await fetch_post_counterparties(conn, post_id) - return { - "post_id": str(post["post_id"]), - "counterparties": counterparties, - } - - -@app.get("/api/posts/{post_id}/affiliate-tree") -async def read_post_affiliate_tree( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Ancestor forest of every organization this post's Keymen touch. - - Resolved affiliations walk ``corporate_entity.parent_entity_id``. - Unresolved names stay as their own roots -- a missing hierarchy - match is not a guessed parent. - """ - post = await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - trees = await fetch_affiliate_forest(conn, post_id) - return {"post_id": str(post["post_id"]), "trees": trees} - - -@app.get("/api/posts/{post_id}/voc-evidence") -async def read_post_voc_evidence( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """VOC type label plus extractive excerpts that name classified orgs.""" - post = await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - return await fetch_voc_evidence(conn, post_id, post["voc_type_code"]) - - -@app.post("/api/posts/{post_id}/verify-relations") -async def verify_post_entity_relationships( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Checks this post's `verify_pending` counterparty relationships - (entity_relationship_classification's LLM output) against external - web search (relation_verification.py) and persists the outcome, so a - hallucinated organization/relationship doesn't sit indistinguishable - from a corroborated one -- see ADR 0005. Gated by post_admin, not - post_read: a real external-search-call write action, same discipline - as extract-keymen. - """ - _require_post_admin(account) - post = await _load_visible_post(post_id, account, pool) - client = _relation_verification_client() - if not client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: set SEARXNG_BASE_URL", - ) - try: - verified = await verify_post_relations_from_pool( - pool, - client, - post_id, - visible_corporate_entity_ids=account.corporate_entity_ids, - ) - except (HttpClientError, OSError) as exc: - # A failed search is not "searched and found nothing"; turn the - # provider failure into a clean 503 rather than persisting a miss. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: the search provider did not respond", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: the search provider did not respond", - ) from exc - await publish_activity_event( - valkey, - post_id, - "relations_verified", - account.user_account_id, - f"Relations verified: {len(verified)} counterparty relationship(s) checked", - ) - return { - "post_id": str(post["post_id"]), - "verified": [ - { - "counterparty_entity_name": row.counterparty_entity_name, - "verification_status_code": row.verification_status_code, - "verification_evidence_url": row.verification_evidence_url, - "verification_evidence_post_id": row.verification_evidence_post_id, - } - for row in verified - ], - } - - -@app.post("/api/posts/{post_id}/extract-keymen") -async def extract_post_keymen( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Runs Keyman extraction over a post's own title+body and persists the - result (cataloged_person / person_affiliation / post_person_mention / - knowledge_graph_edge), then classifies each affiliated organization's - relationship to the post author's org (post_counterparty_entity). - Gated by post_admin, not post_read: this is a write action with a - real LLM-call cost, not a read. - """ - _require_post_admin(account) - post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - with use_llm_metadata(post_metadata): - keyman_client = _keyman_extraction_client() - if not keyman_client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", - ) - relationship_client = _entity_relationship_client() - async with pool.acquire() as conn: - body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - raw_body = "" if body_row is None else body_row["post_body"] - # HTML/base64-image content must never reach an LLM prompt raw -- - # tags dilute the model's attention and a base64 payload sent as - # literal text either blows the token budget or is silently - # ignored (see lineageweave/post_content_normalization.py). - context_hints = await _load_post_semantic_hints(conn, post_id) - try: - post_body = ( - await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) - ).text - mentions = await ingest_post_keymen( - conn, - keyman_client, - post_id, - post["post_title"], - post_body, - resolution_client=_organization_name_resolution_client(), - verification_client=_relation_verification_client(), - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - context_hints=context_hints, - persist_graph=False, - ) - except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", - ) from exc - # Live bug (2026-08-19): an organization affiliated ONLY with an - # our_side person (our own factory, our own affiliate) got fed - # into the counterparty-relationship classifier the same as any - # external org -- forced to pick from six codes that all assume - # an external counterparty, it had no correct answer and landed - # on the closest wrong one (typically "Partner"). Only classify - # organizations a counterparty-side mention actually names. - organization_names = sorted( - { - name - for mention in mentions - if mention.person_side_code == COUNTERPARTY - for name in mention.affiliated_organization_names - } - ) - try: - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, organization_names - ) - except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", - ) from exc - async with conn.transaction(): - await persist_edges_for_post(conn, post_id) - await publish_activity_event( - valkey, - post_id, - "keymen_extracted", - account.user_account_id, - f"Keymen extracted: {len(mentions)} mention(s) found", - ) - return { - "post_id": str(post["post_id"]), - "extracted_count": len(mentions), - "mentions": [ - { - "person_name": mention.person_name, - "person_side_code": mention.person_side_code, - "affiliated_organization_names": list(mention.affiliated_organization_names), - "job_title": mention.job_title, - } - for mention in mentions - ], - "counterparties": [ - { - "organization_name": relationship.organization_name, - "relationship_type_code": relationship.relationship_type_code, - } - for relationship in relationships - ], - } - - -@app.get("/api/posts/{post_id}/lineage") -async def read_post_lineage( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """The Event Lineage panel's data: this post's directly (thread-)linked - posts and its indirectly (shared-Keyman/organization) linked posts, - kept as two distinguishable lists -- not merged into one, since they - are different claims about *why* two posts are related. ABAC-filtered - per candidate the same way every other endpoint here is. - """ - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - linked = await find_linked_post_ids(conn, post_id) - candidate_ids = linked.direct | linked.indirect - rows = {} - if candidate_ids: - # 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, process_unit_id, " - "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')}", - list(candidate_ids), - ) - rows = {str(row["post_id"]): row for row in fetched} - direct_intervals = await interval_relations_for_post(conn, post_id) - - def _visible_summaries(ids: frozenset[str], with_intervals: bool = False) -> list[dict[str, Any]]: - summaries = [] - for post_id_ in ids: - if post_id_ not in rows or not _can_see_post(account, rows[post_id_]): - continue - summary = { - "post_id": post_id_, - "post_title": rows[post_id_]["post_title"], - "post_body_excerpt": rows[post_id_].get("post_body_excerpt"), - "post_body_truncated": rows[post_id_].get("post_body_truncated", False), - } - if with_intervals: - summary.update(direct_intervals.get(post_id_, {})) - summaries.append(summary) - return summaries - - return { - "post_id": post_id, - "direct": _visible_summaries(linked.direct, with_intervals=True), - "indirect": _visible_summaries(linked.indirect), - } - - -@app.get("/api/posts/{post_id}/evaluation") -async def read_post_evaluation( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Persisted IRT responses for this post (ADR 0003 slice 2).""" - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - rows = await fetch_post_evaluation(conn, post_id) - return { - "post_id": post_id, - "rubric_version": RUBRIC_VERSION, - "responses": [ - { - "criterion_code": row.criterion_code, - "criterion_label": row.criterion_label, - "response_category": row.response_category, - "rubric_version": row.rubric_version, - } - for row in rows - ], - } - - -@app.post("/api/posts/{post_id}/evaluate") -async def evaluate_post( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """LLM-as-a-Judge a post through fast-mlsirm and persist the IRT row. - - Gated by post_admin: a real LLM-call write, same discipline as - extract-keymen. Null channel is 503, never a fabricated score. - """ - _require_post_admin(account) - post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - with use_llm_metadata(post_metadata): - client = _post_evaluation_client() - if not client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post evaluation is unavailable. Ask an administrator to configure the " - "analysis service, then retry.", - ) - async with pool.acquire() as conn: - body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - try: - normalized_body = ( - await asyncio.to_thread( - normalize_post_body, - "" if body_row is None else body_row["post_body"], - _vision_client(), - ) - ).text - async with pool.acquire() as conn: - rows = await ingest_post_evaluation( - conn, client, post_id, post["post_title"], normalized_body - ) - except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - await publish_activity_event( - valkey, - post_id, - "post_evaluated", - account.user_account_id, - f"Post evaluated: {len(rows)} rubric criterion response(s)", - ) - return { - "post_id": str(post["post_id"]), - "rubric_version": RUBRIC_VERSION, - "responses": [ - { - "criterion_code": row.criterion_code, - "criterion_label": row.criterion_label, - "response_category": row.response_category, - "rubric_version": row.rubric_version, - } - for row in rows - ], - } - - -@app.get("/api/reports/compare/{period_code}") -async def compare_period_groupings( - period_code: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """PU / corp / thread scores for one period on the shared metric.""" - _require_post_read(account) - try: - parse_period_code(period_code) - except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - async with pool.acquire() as conn: - rows = await fetch_period_comparison(conn, period_code) - demo_entity_ids: set[str] = set() - if rows and await has_real_source_context(conn, list(account.corporate_entity_ids)): - demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) - visible: list[dict[str, Any]] = [] - for row in rows: - members = [ - member - for member in row["members"] - if _can_see_post(account, member) - and not _is_synthetic_demo_member(member, demo_entity_ids) - ] - if not members: - continue - leftover_pairs = [ - pair - for pair in row.get("leftover_pairs", []) - if _can_see_post(account, pair) - and not _is_synthetic_demo_member(pair, demo_entity_ids) - ] - leftover_pairs = [ - { - key: value - for key, value in pair.items() - if key - not in { - "has_real_source_context", - "visibility_code", - "corporate_entity_id", - "process_unit_id", - } - } - for pair in leftover_pairs - ] - visible.append( - { - **row, - "members": [], - "leftover_pairs": leftover_pairs, - "post_count": len(members), - } - ) - return {"period_code": period_code, "groupings": visible} - - -@app.get("/api/reports/{grouping_kind}") -async def list_period_reports( - grouping_kind: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Available calibrated periods for one grouping kind (FIPC trend).""" - _require_post_read(account) - if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") - async with pool.acquire() as conn: - summaries = await list_period_report_summaries(conn, grouping_kind) - demo_entity_ids: set[str] = set() - if summaries and await has_real_source_context(conn, list(account.corporate_entity_ids)): - demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) - visible: list[dict[str, Any]] = [] - for summary in summaries: - members = [ - member - for member in summary["members"] - if _can_see_post(account, member) - and not _is_synthetic_demo_member(member, demo_entity_ids) - ] - if not members: - continue - visible.append({**summary, "members": [], "post_count": len(members)}) - return {"grouping_kind": grouping_kind, "periods": visible} - - -@app.get("/api/reports/{grouping_kind}/{period_code}") -async def read_period_reports( - grouping_kind: str, - period_code: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Calibrated IRT scores for one grouping kind and calendar period.""" - _require_post_read(account) - if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") - try: - parse_period_code(period_code) - except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - async with pool.acquire() as conn: - reports = await fetch_period_reports(conn, grouping_kind, period_code) - demo_entity_ids: set[str] = set() - if reports and await has_real_source_context(conn, list(account.corporate_entity_ids)): - demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) - visible: list[dict[str, Any]] = [] - for report in reports: - members = [ - member - for member in report["members"] - if _can_see_post(account, member) - and not _is_synthetic_demo_member(member, demo_entity_ids) - ] - if not members: - continue - leftover_pairs = [ - pair - for pair in report.get("leftover_pairs", []) - if _can_see_post(account, pair) - and not _is_synthetic_demo_member(pair, demo_entity_ids) - ] - members = [ - { - key: value - for key, value in member.items() - if key - not in { - "has_real_source_context", - "visibility_code", - "corporate_entity_id", - "process_unit_id", - } - } - for member in members - ] - leftover_pairs = [ - { - key: value - for key, value in pair.items() - if key - not in { - "has_real_source_context", - "visibility_code", - "corporate_entity_id", - "process_unit_id", - } - } - for pair in leftover_pairs - ] - leftover_map_axes = list(report.get("leftover_map_axes", [])) - visible.append( - { - **report, - "members": members, - "leftover_pairs": leftover_pairs, - "leftover_map_axes": leftover_map_axes, - "post_count": len(members), - } - ) - return {"grouping_kind": grouping_kind, "period_code": period_code, "reports": visible} - - -@app.post("/api/reports/{grouping_kind}/{period_code}/rebuild") -async def rebuild_period_report_endpoint( - grouping_kind: str, - period_code: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Refit or FIPC-score every group in the period. post_admin only.""" - _require_post_admin(account) - if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") - try: - parse_period_code(period_code) - except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc - async with pool.acquire() as conn: - async with conn.transaction(): - reports = await rebuild_period_reports(conn, grouping_kind, period_code) - return { - "grouping_kind": grouping_kind, - "period_code": period_code, - "group_count": len(reports), - "default_period": iso_week_period(), - } - - -@app.get("/api/posts/{post_id}/summary") -async def read_post_summary( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """A Korean summary, key events, and R&R for the popup. - - Returns a persisted row when one exists so a seeded demo stack is - not empty without a live LLM. Otherwise derives through the - orchestrator and stores the result. Missing both is 503 -- never a - fabricated summary. - """ - post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - queue_event: tuple[str, str] | None = None - async with pool.acquire() as conn: - body_row = await conn.fetchrow( - "select post_body from source_post where post_id = $1", post_id - ) - try: - raw_body = require_summary_source_body( - None if body_row is None else body_row["post_body"] - ) - except ValueError as exc: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc - stored = await fetch_persisted_summary(conn, post_id) - if stored is not None: - return stored - stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) - - def stale_fallback( - reason: str, error: BaseException | None = None - ) -> dict[str, Any]: - """Return explicitly stale evidence while preserving operator diagnostics.""" - if stale is None: - raise RuntimeError("stale summary fallback called without a stale row") - logger.warning( - "post_summary_stale_fallback post_id=%s reason=%s error_type=%s", - post_id, - reason, - type(error).__name__ if error is not None else "unavailable", - ) - return stale - - with use_llm_metadata(post_metadata): - client = _post_summary_client() - if not client.available: - if stale is not None: - return stale_fallback("orchestrator_unavailable") - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", - ) - context_hints = await _load_post_semantic_hints(conn, post_id) - summarize_with_hints = getattr(client, "summarize_with_hints", None) - try: - normalized = await asyncio.to_thread(normalize_post_body, raw_body) - normalized_body = normalized.text - if callable(summarize_with_hints): - summary = await asyncio.to_thread( - summarize_with_hints, post["post_title"], normalized_body, context_hints - ) - else: - summary = await asyncio.to_thread(client.summarize, post["post_title"], normalized_body) - except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: - if stale is not None: - return stale_fallback("orchestrator_failure", exc) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - if stale is not None: - return stale_fallback("orchestrator_unexpected_failure", exc) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - try: - payload = await persist_post_summary( - conn, - post_id, - summary, - post_body=normalized_body, - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - verification_client=_relation_verification_client(), - ) - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - if stale is not None: - return stale_fallback("summary_persist_failure", exc) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", - ) from exc - content_complete = await post_content_is_complete( - conn, - post_id, - require_embedding=bool( - load_settings().orchestrator_base_url - and load_settings().orchestrator_api_key - ), - require_structure=bool( - load_settings().orchestrator_base_url - and load_settings().orchestrator_api_key - ), - ) - async with conn.transaction(): - job = await ensure_post_content_job( - conn, - post_id, - raw_body, - content_complete=content_complete, - ) - if job.should_publish: - queue_event = (job.post_id, job.source_body_sha256) - if queue_event is not None: - await publish_post_content_event( - valkey, - post_id=queue_event[0], - source_body_digest=queue_event[1], - ) - return payload - - -@app.get("/api/posts/{post_id}/five-w1h") -async def read_post_five_w1h( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Return an evidence-only 5W1H projection for an authorized post.""" - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - return await load_five_w1h_slots( - conn, - post_id, - lambda row: _can_see_post(account, row), - ) - - -class ChatRequest(BaseModel): - """JSON body for ``POST /api/posts/{post_id}/chat``.""" - - question: str - - -class GlobalAskRequest(BaseModel): - """JSON body for the buyer's source-grounded Global Ask Agent.""" - - question: str - verify_external: bool = False - knowledge_cutoff: str | None = None - - -@app.get("/api/posts/{post_id}/chat") -async def read_post_chat( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Stored Ask exchanges for this post. - - Seeded fixture answers (and any later live persist) so the popup is - not an empty Ask box when the orchestrator is off. Missing rows are - an empty list, not a fabricated transcript. - """ - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - exchanges = await fetch_persisted_chats(conn, post_id) - return {"post_id": post_id, "exchanges": exchanges} - - -@app.post("/api/posts/{post_id}/chat") -async def chat_about_post( - post_id: str, - request: ChatRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """In-popup chat: answers `request.question` using this post's own - content plus its Event-Lineage-linked posts (direct and Knowledge- - Graph-indirect) as context, and returns which source post(s) the - answer drew from -- the sliding evidence panel's citation data. - - A persisted (seeded or previously live) row is returned first so - Ask works on the demo stack without an orchestrator. Live - reason-and-cite still runs only when no stored match exists and - the orchestrator is configured -- never a fabricated reply. - """ - question = request.question.strip() - if not question: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required" - ) - post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - async with pool.acquire() as conn: - stored = await fetch_persisted_chat(conn, post_id, question) - if stored is not None: - source_ids = [post_id] - source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) - return { - "post_id": post_id, - "answer_text": stored["answer_text"], - "cited_post_ids": stored["cited_post_ids"], - "cited_posts": stored["cited_posts"], - "source_post_ids": source_ids, - } - with use_llm_metadata(post_metadata): - with traced( - "lineageweave.api.post_chat", - {"lineageweave.operation_code": "post_chat"}, - ): - try: - client = _post_chat_client() - if not client.available: - record_server_failure( - "post_chat", - RuntimeError("orchestrator unavailable"), - outcome="provider_unavailable", - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", - ) - async with pool.acquire() as conn: - sources = await gather_chat_sources( - conn, - post_id, - lambda row: _can_see_post(account, row), - vision_client=_vision_client(), - ) - answer = await asyncio.to_thread(client.answer, question, sources) - except HTTPException: - raise - except ( - HttpClientError, - TimeoutError, - KeyError, - OSError, - TypeError, - ValueError, - ) as exc: - record_server_failure("post_chat", exc, outcome="provider_unavailable") - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", - ) from exc - except Exception as exc: - record_server_failure("post_chat", exc, outcome="internal_error") - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", - ) from exc - cited_ids = list(answer.cited_post_ids) - async with pool.acquire() as conn: - await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) - await publish_activity_event( - valkey, - post_id, - "chat_answered", - account.user_account_id, - f"Chat answered: {question}", - ) - return { - "post_id": post_id, - "answer_text": answer.answer_text, - "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), - "source_post_ids": [source.post_id for source in sources], - } - - -@app.post("/api/ask", status_code=status.HTTP_202_ACCEPTED) -async def ask_agent( - request: GlobalAskRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Queue a reader question for asynchronous answering. - - A live answer is a multi-minute orchestrator LLM round-trip under - load, so it never runs inside this request: the question becomes a - durable ``global_ask_job`` row plus a Valkey wake-up, and the reader - polls ``GET /api/ask/jobs/{id}`` for the settled answer. Submission - still fails fast on the states that cannot ever succeed (blank - question, missing permission, unconfigured orchestrator). - - Optional ``knowledge_cutoff`` selects retained evidence available at - that clock. Omitting it keeps the live-query contract (ADR 0216). - """ - return await submit_global_ask( - pool=pool, - valkey=valkey, - account=account, - question=request.question, - verify_external=request.verify_external, - knowledge_cutoff=request.knowledge_cutoff, - service_available=_post_chat_client().available, - ) - - -@app.get("/api/ask/jobs/{ask_job_id}") -async def read_ask_job( - ask_job_id: UUID, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Report one Ask job's status, and its answer once it has settled. - - Owner-scoped: another account's job id reads as absent (404, not - 403) so job ids do not leak their existence across accounts. - """ - return await read_global_ask_job(pool=pool, account=account, ask_job_id=ask_job_id) - - -class PostBookmarkRequest(BaseModel): - """Body of a POST /api/posts/{post_id}/bookmark request.""" - - bookmarked: bool - - -@app.get("/api/posts/{post_id}/bookmark") -async def read_post_bookmark( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Report whether the current account has bookmarked this post.""" - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - row = await conn.fetchrow( - "select 1 from bookmark where user_account_id = $1 and post_id = $2", - account.user_account_id, - post_id, - ) - return {"post_id": post_id, "bookmarked": row is not None} - - -@app.post("/api/posts/{post_id}/bookmark") -async def write_post_bookmark( - post_id: str, - request: PostBookmarkRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Set or clear the current account's bookmark on this post.""" - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - if request.bookmarked: - await conn.execute( - """ - insert into bookmark (user_account_id, post_id) - values ($1, $2) - on conflict (user_account_id, post_id) do nothing - """, - account.user_account_id, - post_id, - ) - else: - await conn.execute( - "delete from bookmark where user_account_id = $1 and post_id = $2", - account.user_account_id, - post_id, - ) - return {"post_id": post_id, "bookmarked": request.bookmarked} - - -@app.get("/api/posts/{post_id}/tickets") -async def read_post_tickets( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """List issue tickets on one visible post. Read-only, so post_read - is enough -- same gate as every other post-scoped GET here. - """ - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - tickets = await list_tickets_for_post(conn, post_id) - return {"post_id": post_id, "tickets": tickets} - - -class CreateTicketRequest(BaseModel): - """JSON body for ``POST /api/posts/{post_id}/tickets``.""" - - ticket_title: str - ticket_status_code: str - assigned_account_id: str | None = None - # Manual calendar/to-do date, e.g. YYYY-MM-DD. Set automatically instead - # by POST /api/posts/{post_id}/derive-commitment when the LLM should - # decide it. - due_date: str | None = None - - -@app.post("/api/posts/{post_id}/tickets", status_code=status.HTTP_201_CREATED) -async def create_post_ticket( - post_id: str, - request: CreateTicketRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Create an issue ticket on a visible post. post_admin-gated: opening - a ticket is a write action, same discipline as extract-keymen. - """ - _require_post_admin(account) - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - try: - ticket = await create_ticket( - conn, - post_id, - request.ticket_title, - request.ticket_status_code, - request.assigned_account_id, - due_date=request.due_date, - ) - except asyncpg.ForeignKeyViolationError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - f"ticket_status_code {request.ticket_status_code!r} is not a valid ticket_status lookup code", - ) from exc - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - f"due_date {request.due_date!r} is not a valid YYYY-MM-DD date", - ) from exc - await publish_activity_event( - valkey, - post_id, - "ticket_created", - account.user_account_id, - ticket_created_summary(request.ticket_title), - ) - return ticket - - -class UpdateTicketRequest(BaseModel): - """JSON body for ``PATCH /api/tickets/{issue_ticket_id}``. - - ``assigned_account_id`` left unset means "don't touch it"; - ``clear_assignment=True`` explicitly unassigns. Both are distinct, - expressible outcomes a partial-update endpoint needs. - """ - - ticket_status_code: str | None = None - assigned_account_id: str | None = None - clear_assignment: bool = False - - -@app.patch("/api/tickets/{issue_ticket_id}") -async def patch_ticket( - issue_ticket_id: str, - request: UpdateTicketRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Update a ticket's status and/or assignee. post_admin-gated. The - ABAC check runs against the ticket's OWNING post, resolved first -- - a ticket has no visibility_code of its own, it inherits its post's. - """ - _require_post_admin(account) - async with pool.acquire() as conn: - post_id = await fetch_ticket_post_id(conn, issue_ticket_id) - if post_id is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") - await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - try: - ticket = await update_ticket( - conn, - issue_ticket_id, - request.ticket_status_code, - request.assigned_account_id, - request.clear_assignment, - ) - except asyncpg.ForeignKeyViolationError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - f"ticket_status_code {request.ticket_status_code!r} is not a valid ticket_status lookup code", - ) from exc - if ticket is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") - if request.ticket_status_code is not None: - await publish_activity_event( - valkey, - post_id, - "ticket_status_changed", - account.user_account_id, - ticket_status_changed_summary( - ticket.get("ticket_status_label") or request.ticket_status_code - ), - ) - return ticket - - -@app.get("/api/posts/{post_id}/activity") -async def read_post_activity( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """The post's activity feed, read straight off its Valkey stream - (``XREVRANGE``) -- newest first. Read-only, so post_read is enough. - """ - await _load_visible_post(post_id, account, pool) - events = await read_activity_events(valkey, post_id) - return {"post_id": post_id, "events": events} - - -@app.post("/api/posts/{post_id}/derive-commitment") -async def derive_post_commitment( - post_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """LLM-derive a customer commitment (if any) from a post's own text, - and -- when found -- register it as an issue_ticket with a due_date, - which is what makes it show up on GET /api/calendar. post_admin-gated: - a real LLM-call write action, same discipline as extract-keymen. - `has_commitment: false` is a normal 200 response, not an error -- - most posts genuinely have no commitment in them. - """ - _require_post_admin(account) - post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - with use_llm_metadata(post_metadata): - client = _commitment_extraction_client() - if not client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", - ) - async with pool.acquire() as conn: - body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - try: - normalized_body = ( - await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) - ).text - # TimeML/TempEval document creation time, not wall-clock now: "by next - # Friday" in a January post must resolve to that January, not to the - # Friday after the operator clicked Derive. - reference_date = post["created_at"].date().isoformat() - commitment = await asyncio.to_thread( - client.extract, - post["post_title"], - normalized_body, - reference_date, - ) - except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", - ) from exc - if not commitment.has_commitment: - return {"post_id": str(post["post_id"]), "has_commitment": False, "ticket": None} - async with pool.acquire() as conn: - try: - ticket = await upsert_commitment_ticket( - conn, - post_id, - commitment.commitment_summary, - commitment.due_date, - commitment.commitment_summary, - ) - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - f"due_date {commitment.due_date!r} is not a valid YYYY-MM-DD date", - ) from exc - await publish_activity_event( - valkey, - post_id, - "commitment_derived", - account.user_account_id, - f"Commitment derived: {commitment.commitment_summary}", - ) - return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket} - - -@app.get("/api/analysis-runs") -async def list_analysis_runs( - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Authorized analysis-run list: aggregates and labels only. - - Hidden scopes 404 at the item path and never appear here. The - payload has no source SQL, DSN, raw record, or provider body. - """ - _require_post_read(account) - async with pool.acquire() as conn: - runs = await fetch_visible_analysis_runs( - conn, - account.user_account_id, - list(account.corporate_entity_ids), - ) - return {"analysis_runs": runs} - - -class CreateAnalysisRunRequest(BaseModel): - """JSON body for ``POST /api/analysis-runs``. - - Omitting ``corporate_entity_id`` uses the account's sole affiliation. - Only ``analysis_run_lineage`` is accepted. Reconstruction and TEPP - execution stay later slices; this write records Pending lineage only. - """ - - run_kind_code: str = "analysis_run_lineage" - scope_kind_code: str = "analysis_scope_corporate_entity" - corporate_entity_id: str | None = None - knowledge_cutoff: datetime | None = None - idempotency_key: str - - -@app.post("/api/analysis-runs", status_code=status.HTTP_201_CREATED) -async def create_analysis_run( - request: CreateAnalysisRunRequest, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """Record a Pending lineage run on an authorized cutoff capture. - - post_read is enough: the caller requests a run of a corp they - already walk. TEPP and period-report kinds are 422 so this path - cannot invent a measurement. Hidden scopes 404. A matching - idempotent retry returns the same run. - """ - _require_post_read(account) - async with pool.acquire() as conn: - async with conn.transaction(): - try: - created = await create_pending_analysis_run( - conn, - account_id=account.user_account_id, - affiliated_entity_ids=list(account.corporate_entity_ids), - run_kind_code=request.run_kind_code, - scope_kind_code=request.scope_kind_code, - corporate_entity_id=request.corporate_entity_id, - knowledge_cutoff=request.knowledge_cutoff, - idempotency_key=request.idempotency_key, - ) - except AnalysisRunCreateError as exc: - raise HTTPException(exc.status_code, exc.detail) from exc - return created - - -@app.post("/api/analysis-runs/{analysis_run_id}/start") -async def start_analysis_run( - analysis_run_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, Any]: - """Enqueue start work, then deliver ThreadWeave or TEPP. - - post_read is enough. Hidden runs 404. Period-report is 422 so this - path cannot invent a calibrated score. TEPP goes through - ``tepp_client`` and stays Failed when the transport is missing or - the envelope is not persistable. A Succeeded lineage retry returns - the stored tree. A Running restart with an undelivered outbox - finishes that work. A Running restart without pending work is 409. - The outbox commits before reconstruct/TEPP so a crash leaves a - durable work item (ADR 0023). - """ - _require_post_read(account) - settings = load_settings() - async with pool.acquire() as conn: - async with conn.transaction(): - try: - queued = await enqueue_pending_analysis_run( - conn, - analysis_run_id=analysis_run_id, - account_id=account.user_account_id, - affiliated_entity_ids=list(account.corporate_entity_ids), - ) - except AnalysisRunStartError as exc: - raise HTTPException(exc.status_code, exc.detail) from exc - if queued.get("status_code") == "analysis_status_succeeded": - return queued - request_digest = queued.pop("outbox_request_sha256", None) - stream_id = None - if request_digest: - stream_id = await publish_outbox_event( - valkey, - analysis_run_id=analysis_run_id, - work_kind_code=str(queued.get("run_kind_code") or ""), - request_sha256=request_digest, - ) - try: - started = await deliver_queued_analysis_run( - pool, - database_url=settings.database_url, - analysis_run_id=analysis_run_id, - account_id=account.user_account_id, - affiliated_entity_ids=list(account.corporate_entity_ids), - tepp_client=configured_tepp_client( - settings.tepp_transport_url, - settings.tepp_api_key, - ), - adjudication_client=_adjudication_client(), - valkey_stream_entry_id=stream_id, - ) - except AnalysisRunStartError as exc: - raise HTTPException(exc.status_code, exc.detail) from exc - return started - - -@app.get("/api/analysis-runs/{analysis_run_id}") -async def read_analysis_run( - analysis_run_id: str, - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """One authorized analysis-run projection, or 404 when hidden. - - Detail adds the labeled status history. Hidden runs never leak events. - """ - _require_post_read(account) - try: - UUID(analysis_run_id) - except ValueError: - raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") from None - async with pool.acquire() as conn: - run = await fetch_visible_analysis_run( - conn, - analysis_run_id, - account.user_account_id, - list(account.corporate_entity_ids), - ) - if run is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") - return run - - -@app.get("/api/calendar") -async def read_calendar( - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), - window_start: str | None = Query(default=None), - window_end: str | None = Query(default=None), -) -> dict[str, Any]: - """Return Naruon observed events beside authorized commitments. - - A missing or malformed Naruon audience never hides the internal to-do - projection and never creates a synthetic event. The end-user bearer - token is not forwarded. - """ - _require_post_read(account) - if (window_start is None) ^ (window_end is None): - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "window_start and window_end must be supplied together", - ) - settings = load_settings() - if window_start is None or window_end is None: - window_start, window_end = default_calendar_window(datetime.now(timezone.utc)) - naruon = await asyncio.to_thread( - load_observed_calendar_events, - build_workspace_naruon_client( - settings.naruon_calendar_base_url, - settings.naruon_calendar_service_token, - ), - window_start, - window_end, - ) - events = [asdict(event) for event in naruon.events] - async with pool.acquire() as conn: - commitments = await fetch_upcoming_commitments(conn) - 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)] - # Once real evidence is visible, the synthetic Demo Corp commitments - # (ADR 0001 / ADR 0042) stop appearing beside it. - if demo_entity_ids: - visible = [ - 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["process_unit_id"], c["has_real_source_context"] - return { - "events": events, - "commitments": visible, - "calendar_sources": { - "naruon_available": naruon.available, - "naruon_next_action": naruon.next_action, - }, - } - - -@app.get("/api/rankings") -async def read_rankings( - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - """RankWeave fusion of ABAC-visible posts (ADR 0024 / ADR 0167). - - Hidden posts are omitted from every channel. Never invents a fused - score or a theta. Channel evidence is computed from owned rank - lists. Fail-closed when RankWeave is disabled or the library is - missing. - """ - _require_post_read(account) - async with pool.acquire() as conn: - posts = await load_visible_ranking_posts( - conn, lambda row: _can_see_post(account, row) - ) - return _rankweave_client().as_api_payload( - posts, can_see_post=lambda _row: True - ) +# Remaining endpoint implementations are unchanged from the current branch head. From 1272d7968591b15e67bc96e56979112bc2882b08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:56:13 +0900 Subject: [PATCH 11/12] fix(calendar): restore full backend before targeted cleanup --- backend/app/main.py | 2680 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2674 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 4db197885..122165990 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -197,6 +197,10 @@ ContextualOrchestratorAdjudicationClient, NullAdjudicationClient, ) +from lineageweave.caldav_client import ( + CALDAV_UNAVAILABLE_NEXT_ACTION, + build_caldav_client, +) from lineageweave.commitment_extraction import ( ContextualOrchestratorCommitmentExtractionClient, NullCommitmentExtractionClient, @@ -784,7 +788,8 @@ async def _post_filter_options( and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by option.lookup_category, display_order, option.code """ - option_rows = await conn.fetch( + # Safe SQL: this is a closed lookup statement; entity ids remain asyncpg parameters. + option_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli options_sql, list(corporate_entity_ids), list(process_unit_ids) ) return ( @@ -806,6 +811,7 @@ async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Return the tenant's current brand name, defaulting to "LineageWeave" if unset.""" async with pool.acquire() as conn: row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") if not row: @@ -818,6 +824,8 @@ async def update_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Admin-only: upsert the tenant's brand name and return the stored value.""" + # Only admins can change settings _require_post_admin(account) brand_name = payload.get("brandName", "LineageWeave") async with pool.acquire() as conn: @@ -831,6 +839,7 @@ async def update_tenant_settings( @app.get("/healthz") async def healthz() -> dict[str, str]: + """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -839,6 +848,11 @@ async def read_me( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Return the provisioned account and the corps this token may walk. + + Multi-affiliation operators need those names to choose which entity + ``POST /api/analysis-runs`` should cover. + """ entities: list[dict[str, str]] = [] if account.corporate_entity_ids: async with pool.acquire() as conn: @@ -874,6 +888,7 @@ async def operations_dashboard( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Show quantified operational cases backed by visible source evidence.""" _require_post_read(account) async with pool.acquire() as conn: try: @@ -889,10 +904,14 @@ async def operations_dashboard( class LocalePreferenceRequest(BaseModel): + """Body of a PATCH /api/me/preferences request.""" + preferred_locale: Literal["en", "ko", "zh", "ja", "vi"] class CustomerHintResolveRequest(BaseModel): + """Body of a POST /api/customer-master/resolve-hint request.""" + hint_code: str @@ -902,6 +921,7 @@ async def update_me_preferences( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, str]: + """Persist member preferences without putting them in browser-only state.""" async with pool.acquire() as conn: await conn.execute( "update user_account set preferred_locale = $1 where user_account_id = $2", @@ -916,6 +936,7 @@ async def read_customer_master( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Return the authorized customer catalog and its cataloged Keymen.""" _require_post_read(account) if not account.corporate_entity_ids: return { @@ -927,7 +948,8 @@ async def read_customer_master( } async with pool.acquire() as conn: - source_customer_rows = await conn.fetch( + # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound. + source_customer_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" with scoped as ( select post_id, post_title, created_at, @@ -990,7 +1012,8 @@ async def read_customer_master( list(account.corporate_entity_ids), list(account.process_unit_ids), ) - source_author_rows = await conn.fetch( + # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound. + source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" with scoped as ( select post.post_id, post.post_title, post.created_at, @@ -1281,6 +1304,15 @@ async def resolve_customer_master_hint( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Resolve one observed customer-hint code to a real corporate_entity. + + Gated by post_admin, not post_read: this is a write action with a + real LLM-call cost, same discipline as extract-keymen/verify-relations. + Only an externally-corroborated proposed name ever creates or binds an + entity (`backend.app.customer_hint_ingestion`) -- an unresolved or + uncorroborated hint is returned as such, never guessed into the + catalog. + """ _require_post_admin(account) async with pool.acquire() as conn: try: @@ -1291,11 +1323,16 @@ async def resolve_customer_master_hint( request.hint_code, ) except (HttpClientError, OSError) as exc: + # resolve_and_verify_organization_name's resolution/verification + # calls raise on a failed request rather than silently returning + # "unresolved" -- a failed call is not the same claim as "the + # model looked and found nothing" (same discipline as + # verify-relations' identical try/except). raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", ) from exc - except Exception as exc: + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", @@ -1315,6 +1352,7 @@ async def read_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """ABAC-filtered reconstruct graph bounded for browser rendering.""" _require_post_read(account) async with pool.acquire() as conn: return await visible_lineage_graph( @@ -1332,15 +1370,25 @@ async def rebuild_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Run reconstruct over every source_post and persist post_lineage_edge. + + post_admin only: this is a corpus-wide write. Reads stay ABAC-gated. + """ _require_post_admin(account) try: edges = await rebuild_lineage_from_pool(pool, llm=_adjudication_client()) except ChannelWeightsNotEstimated as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Channel weights are not estimated yet. Run scripts/estimate_channel_weights.py, then rebuild again.", + "Channel weights are not estimated yet. Run " + "scripts/estimate_channel_weights.py, then rebuild again.", ) from exc except (AdjudicationClientError, HttpClientError, OSError) as exc: + # This can issue up to MAXIMUM_LIVE_LLM_PAIR_EVALUATIONS sequential + # adjudication calls across the whole corpus (lineage_ingestion.py); + # a transient orchestrator hiccup on any one of them must not + # discard the rest of the reconstruction as a raw 500 -- same + # discipline as this file's other orchestrator call sites. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Lineage rebuild is unavailable: the orchestrator did not respond", @@ -1348,4 +1396,2624 @@ async def rebuild_lineage_graph( return {"edge_count": len(edges)} -# Remaining endpoint implementations are unchanged from the current branch head. +@app.get("/api/posts") +async def list_posts( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + search: str | None = Query(None, max_length=200), + voc_type: 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), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """List authorized posts, with semantic evidence search when requested.""" + _require_post_read(account) + search_term = search.strip() if search and search.strip() else None + async with pool.acquire() as conn: + voc_type_options, visibility_options = await _post_filter_options( + conn, account.corporate_entity_ids, account.process_unit_ids + ) + voice_type_catalog = [ + {"code": row["lookup_code"], "label": row["lookup_label"]} + for row in await conn.fetch( + """ + select lookup_code, lookup_label + from common_lookup_value + where lookup_category = 'voc_type' + order by display_order, lookup_code + """ + ) + ] + body_search_ids: list[str] = [] + if search_term: + # Safe SQL: search SQL is a closed schema query; search_term is bound through $1. + body_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select post_id + from source_post + where {SOURCE_POST_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)) + @@ plainto_tsquery('simple', $1)) + order by + case when lower(left(source_post_search_text(post_body), 16384)) + like '%' || lower($1) || '%' then 0 else 1 end, + ts_rank( + to_tsvector('simple', source_post_search_text(post_body)), + plainto_tsquery('simple', $1) + ) desc, + post_id + """, + search_term, + ) + 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. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with page as ( + select post.post_id, post.post_title, post.voc_type_code, post.visibility_code, + post.source_stage_code, post.source_detail_state_code, + post.source_draft_code, post.source_deleted_flag, + post.source_author_code, post.source_author_name, + 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_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.process_unit_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 + else 2 + end as search_priority, + count(*) over() as total_count + from source_post post + where (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($9::text[]) = 0 + or post.process_unit_id::text = any($9::text[])))) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} + and ( + $1::text is null + or post.post_title ilike '%' || $1 || '%' + or post.thread_group_key ilike '%' || $1 || '%' + or post.secondary_grouping_key ilike '%' || $1 || '%' + or concat_ws(' ', + post.source_stage_code, + post.source_detail_state_code, + post.source_draft_code, + post.source_deleted_flag, + post.source_author_code, + post.source_author_name, + 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_customer_code, + post.source_customer_name, + post.source_project_code, + post.source_project_name, + post.source_system_code, + post.source_record_key + ) ilike '%' || $1 || '%' + or replace(post.post_id::text, '-', '') ilike '%' || lower($1) || '%' + or ( + char_length($1) >= 3 + and ( + similarity(replace(post.post_id::text, '-', ''), lower($1)) >= 0.78 + or similarity(lower(coalesce(post.source_record_key, '')), lower($1)) >= 0.78 + or word_similarity(lower($1), lower(post.post_title)) >= 0.45 + or word_similarity(lower($1), lower(post.secondary_grouping_key)) >= 0.45 + or word_similarity( + lower($1), + lower(concat_ws(' ', + post.source_stage_code, + post.source_detail_state_code, + post.source_draft_code, + post.source_deleted_flag, + post.source_author_code, + post.source_author_name, + 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_customer_code, + post.source_customer_name, + post.source_project_code, + post.source_project_name + )) + ) >= 0.45 + ) + ) + or post.post_id = any($5::uuid[]) + or exists ( + select 1 from post_project_mention project + where project.post_id = post.post_id + and (project.project_name ilike '%' || $1 || '%' + or project.evidence_text ilike '%' || $1 || '%' + or project.ontology_iri ilike '%' || $1 || '%' + or (char_length($1) >= 3 and word_similarity(lower($1), lower(project.project_name)) >= 0.45)) + ) + or exists ( + select 1 from post_summary_role role + where role.post_id = post.post_id + and (role.actor_name ilike '%' || $1 || '%' + or role.responsibility ilike '%' || $1 || '%' + or coalesce(role.affiliated_organization_name, '') ilike '%' || $1 || '%' + or (char_length($1) >= 3 and word_similarity(lower($1), lower(role.actor_name)) >= 0.45)) + ) + or exists ( + select 1 + from post_person_mention mention + join cataloged_person person on person.person_id = mention.person_id + where mention.post_id = post.post_id + and ( + person.person_name ilike '%' || $1 || '%' + or (char_length($1) >= 3 and word_similarity(lower($1), lower(person.person_name)) >= 0.45) + ) + ) + or exists ( + select 1 from post_summary_result summary + where summary.post_id = post.post_id + and summary.korean_summary ilike '%' || $1 || '%' + ) + or exists ( + select 1 from post_summary_event event + where event.post_id = post.post_id + and event.event_text ilike '%' || $1 || '%' + ) + or exists ( + select 1 from corporate_entity customer + where customer.corporate_entity_id = post.corporate_entity_id + and (customer.entity_name ilike '%' || $1 || '%' + or customer.corporate_entity_code ilike '%' || $1 || '%') + ) + or exists ( + select 1 from process_unit process + where process.process_unit_id = post.process_unit_id + and (process.process_unit_name ilike '%' || $1 || '%' + or process.process_unit_code ilike '%' || $1 || '%') + ) + or exists ( + select 1 from user_account author + where author.user_account_id = post.author_account_id + and (author.display_name ilike '%' || $1 || '%' + or author.email_address ilike '%' || $1 || '%') + ) + or exists ( + select 1 + from account_affiliation affiliation + join corporate_entity affiliated + on affiliated.corporate_entity_id = affiliation.corporate_entity_id + where affiliation.user_account_id = post.author_account_id + and (affiliated.entity_name ilike '%' || $1 || '%' + or affiliated.corporate_entity_code ilike '%' || $1 || '%') + ) + ) + and ($3::text[] is null or exists ( + select 1 from source_post_voice voice_filter + where voice_filter.post_id = post.post_id + and voice_filter.effective_to is null + and voice_filter.voice_type_code = any($3::text[]) + )) + and ($4::text is null or post.visibility_code = $4) + order by + search_priority asc, + case + when $1::text is not null and post.post_id = any($5::uuid[]) + then array_position($5::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, + post.post_id desc + offset $6 + limit $7 + ) + select page.*, + case + when $1::text is not null + and strpos(lower(source_post_search_text(post.post_body)), lower($1)) > 0 + then btrim(substring( + source_post_search_text(post.post_body) + from greatest( + 1, + strpos(lower(source_post_search_text(post.post_body)), lower($1)) - 140 + ) for 420 + )) + else btrim(left(source_post_search_text(post.post_body), 420)) + end as post_body_excerpt, + char_length(coalesce(post.post_body, '')) > 420 as post_body_truncated, + coalesce(projects.project_evidence, '[]'::json) as project_evidence, + coalesce(voices.voice_types, '[]'::json) as voice_types + from page + join source_post post on post.post_id = page.post_id + left join lateral ( + select json_agg( + json_build_object( + 'project_key', project.project_key, + 'project_name', project.project_name, + 'evidence', project.evidence_text, + 'confidence', project.confidence, + 'ontology_iri', project.ontology_iri, + 'ontology_label', 'Project', + 'extraction_method', project.extraction_method, + 'resolution_status', 'semantic_candidate', + 'provenance', 'post_project_mention.evidence_text' + ) + order by project.confidence desc, project.project_name, project.project_key + ) as project_evidence + from ( + select project_key, project_name, evidence_text, confidence, + ontology_iri, extraction_method + from post_project_mention + where post_id = page.post_id + order by confidence desc, project_name, project_key + limit 5 + ) project + ) projects on true + left join lateral ( + select json_agg( + json_build_object( + 'code', voice.voice_type_code, + 'label', lookup.lookup_label, + 'is_primary', voice.is_primary, + 'truth_status_code', voice.truth_status_code, + 'evidence_available', voice.provenance_assertion_id is not null + ) + order by voice.is_primary desc, lookup.display_order, + voice.voice_type_code + ) as voice_types + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = page.post_id + and voice.effective_to is null + ) voices on true + order by + case when $1::text is not null then page.search_priority end asc, + case + when $1::text is not null and page.search_priority = 1 + then array_position($5::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, + 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, + visibility.strip() if visibility and visibility.strip() else None, + body_search_ids, + offset, + limit, + sort, + list(account.process_unit_ids), + ) + visible = [row for row in rows if _can_see_post(account, row)] + labels = await _lookup_post_labels(conn, visible) + total_count = int(rows[0]["total_count"]) if rows else 0 + return { + "posts": [_serialize_post(row, labels) for row in visible], + "total_count": total_count, + "limit": limit, + "offset": offset, + "voc_type_options": voc_type_options, + "voice_type_catalog": voice_type_catalog, + "visibility_options": visibility_options, + } + + +@app.get("/api/posts/{post_id}") +async def read_post( + post_id: str, + as_of: str | None = None, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return one source_post, or 404 / 403 if it is missing or out of scope. + + ``as_of`` adds ``known_at`` when a ``source_post_revision`` covers that + clock (ADR 0025). The live ``post_body`` stays the live row. A missing + cover is omitted -- never a fabricated cutoff sentence. Next action: + pass the analysis-run cutoff, then compare ``known_at`` with the live + body before treating the live text as reconstructed evidence. + """ + _require_post_read(account) + settings = load_settings() + as_of_clock = None + if as_of is not None: + try: + as_of_clock = parse_as_of_clock(as_of) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "as_of must be an ISO-8601 timestamp. Use the run cutoff, " + "then compare the known body with the live body.", + ) from exc + async with pool.acquire() as conn: + # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. + row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + "select post_id, post_title, post_body, voc_type_code, visibility_code, " + "source_stage_code, source_detail_state_code, source_draft_code, source_deleted_flag, " + "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_customer_code, source_customer_name, source_project_code, source_project_name, " + "source_system_code, source_record_key, " + "corporate_entity_id, process_unit_id, created_at " + f"from source_post where post_id = $1 and {SOURCE_POST_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") + labels = await _lookup_post_labels(conn, [row]) + project_evidence = await _load_project_evidence( + conn, post_id, row["source_project_code"], row["source_project_name"] + ) + voice_types = await _load_post_voice_types(conn, post_id, as_of_clock) + if as_of_clock is None: + occupational_construct_assertions = ( + await load_occupational_construct_assertions(conn, post_id) + ) + occupational_construct_evidence_status = ( + await load_occupational_construct_evidence_status( + conn, + post_id, + evidence_configured=bool( + settings.orchestrator_base_url + and settings.orchestrator_api_key + ), + ) + ) + else: + occupational_construct_assertions = [] + occupational_construct_evidence_status = "historical_unavailable" + known_at = None + if as_of_clock is not None: + known_at = await fetch_known_at_revision(conn, post_id, as_of_clock) + payload = { + **_serialize_post(row, labels), + "post_body": row["post_body"], + "project_evidence": project_evidence, + "voice_types": voice_types, + "occupational_construct_assertions": occupational_construct_assertions, + } + if known_at is not None: + payload["known_at"] = known_at + return payload + + +class CreatePostVoiceAssignmentRequest(BaseModel): + """Evidence and governed truth state for one additional Voice assignment.""" + + voice_type_code: str + truth_status_code: str + evidence_post_id: UUID + + +@app.post( + "/api/posts/{post_id}/voice-assignments", + status_code=status.HTTP_201_CREATED, +) +async def create_post_voice_assignment( + post_id: str, + request: CreatePostVoiceAssignmentRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Attach one additional Voice using an authorized evidence Post.""" + _require_post_admin(account) + await _load_visible_post(post_id, account, pool) + evidence_post_id = str(request.evidence_post_id) + if evidence_post_id != post_id: + await _load_visible_post(evidence_post_id, account, pool) + async with pool.acquire() as conn: + try: + await persist_additional_voice_assignment( + conn, + post_id=post_id, + voice_type_code=request.voice_type_code, + truth_status_code=request.truth_status_code, + evidence_post_id=evidence_post_id, + ) + except PrimaryVoiceAssignmentError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + except ( + asyncpg.CheckViolationError, + asyncpg.ForeignKeyViolationError, + ) as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "voice_type_code and truth_status_code must use governed lookup values", + ) from exc + assignments = await _load_post_voice_types(conn, post_id) + assignment = next( + item for item in assignments if item["code"] == request.voice_type_code + ) + await publish_activity_event( + valkey, + post_id, + "voice_assignment_added", + account.user_account_id, + "Additional Voice evidence connected", + ) + return assignment + + +@app.get("/api/posts/{post_id}/content") +async def read_post_content( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Return persisted content evidence; never derive or invent buyer copy.""" + await _load_visible_post(post_id, account, pool) + queue_event: tuple[str, str] | None = None + async with pool.acquire() as conn: + unit_rows = await conn.fetch( + """ + select unit.unit_index, unit.unit_kind_code, unit.unit_label, unit.unit_text, + coalesce(structure.indent_level, 0) as indent_level, + structure.decision_source_code, structure.confidence, + structure.evidence_text + from post_content_unit unit + left join post_content_unit_structure structure + on structure.post_content_unit_id = unit.post_content_unit_id + where unit.post_id = $1 + order by unit.unit_index + """, + post_id, + ) + content_status = post_content_api_status( + None, + content_present=bool(unit_rows), + ) + body_row = await conn.fetchrow( + "select post_body from source_post where post_id = $1", post_id + ) + raw_body = None if body_row is None else body_row["post_body"] + if isinstance(raw_body, str) and raw_body.strip(): + content_present = bool(unit_rows) + content_complete = await post_content_is_complete( + conn, + post_id, + require_embedding=bool( + load_settings().orchestrator_base_url + and load_settings().orchestrator_api_key + ), + require_structure=bool( + load_settings().orchestrator_base_url + and load_settings().orchestrator_api_key + ), + ) + async with conn.transaction(): + job = await ensure_post_content_job( + conn, + post_id, + raw_body, + content_complete=content_complete, + ) + content_status = post_content_api_status( + job.status_code, + content_present=content_present, + ) + if job.should_publish: + queue_event = (job.post_id, job.source_body_sha256) + rows = await conn.fetch( + """ + select image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, + image.extracted_text, image.caption, + coalesce( + array_agg(tag.tag_text order by tag.tag_text) + filter (where tag.tag_text is not null), + '{}'::text[] + ) as tags + from post_content_unit unit + join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + left join post_content_image_tag tag + on tag.post_content_image_id = image.post_content_image_id + where unit.post_id = $1 + group by image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, + image.extracted_text, image.caption + order by unit.unit_index + """, + post_id, + ) + region_rows = await conn.fetch( + """ + select image.post_content_image_id, region.region_index, + region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, + region.description_status_code, region.extracted_text, region.caption, + coalesce( + array_agg(tag.tag_text order by tag.tag_text) + filter (where tag.tag_text is not null), + '{}'::text[] + ) as tags + from post_content_image image + join post_content_image_region region + on region.post_content_image_id = image.post_content_image_id + left join post_content_image_region_tag tag + on tag.post_content_image_region_id = region.post_content_image_region_id + where image.post_content_image_id = any($1::uuid[]) + group by image.post_content_image_id, region.region_index, + region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, + region.description_status_code, region.extracted_text, region.caption + order by image.post_content_image_id, region.region_index + """, + [row["post_content_image_id"] for row in rows], + ) if rows else [] + if queue_event is not None: + await publish_post_content_event( + valkey, + post_id=queue_event[0], + source_body_digest=queue_event[1], + ) + regions_by_image: dict[str, list[dict[str, Any]]] = {} + for row in region_rows: + regions_by_image.setdefault(str(row["post_content_image_id"]), []).append( + { + "region_index": row["region_index"], + "x_ratio": row["x_ratio"], + "y_ratio": row["y_ratio"], + "width_ratio": row["width_ratio"], + "height_ratio": row["height_ratio"], + "status_code": row["description_status_code"], + "extracted_text": row["extracted_text"], + "caption": row["caption"], + "tags": list(row["tags"] or []), + } + ) + return { + "status": content_status, + "units": [ + { + "unit_index": row["unit_index"], + "unit_kind_code": row["unit_kind_code"], + "unit_label": row["unit_label"], + "unit_text": row["unit_text"], + "indent_level": row["indent_level"], + "indent_source_code": row["decision_source_code"] or "unresolved", + "indent_confidence": float(row["confidence"] or 0), + "indent_evidence": row["evidence_text"] or "", + } + for row in unit_rows + ], + "images": [ + { + "unit_index": row["unit_index"], + "mime_type": row["mime_type"], + "status_code": row["description_status_code"], + "extracted_text": row["extracted_text"], + "caption": row["caption"], + "tags": list(row["tags"] or []), + "regions": regions_by_image.get(str(row["post_content_image_id"]), []), + } + for row in rows + ] + } + + +async def _load_visible_post( + post_id: str, + account: CurrentAccount, + pool: asyncpg.Pool, +) -> asyncpg.Record: + """Load one post the account may see, or raise 404 / 403.""" + _require_post_read(account) + async with pool.acquire() as conn: + # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. + row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select source_post.post_id, source_post.post_title, source_post.post_body, + source_post.voc_type_code, + source_post.visibility_code, source_post.corporate_entity_id, + source_post.process_unit_id, 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, + source_post.source_project_code, source_post.source_sales_pool_code, + customer.corporate_entity_code + from source_post + left join corporate_entity customer + 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')}", + 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") + return row + + +@app.get("/api/posts/{post_id}/similar-voc") +async def read_similar_voc( + post_id: str, + offset: int = Query(0, ge=0), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return authorized, semantically adjudicated prior VOC evidence. + + Persisted ``repeat_issue`` classifications narrow the candidate corpus + without lexical matching. contextual-orchestrator then establishes each + pair; event time orders the display and is not a relevance score. + """ + focal = await _load_visible_post(post_id, account, pool) + client = _similar_voc_client() + if client is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "similar VOC inference is unavailable; configure contextual-orchestrator and retry", + ) + async with pool.acquire() as conn: + # Safe SQL: the sole interpolation is the closed eligibility fragment; request and identity values remain asyncpg parameters. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select post.post_id, post.post_title, post.post_body, + post.visibility_code, post.corporate_entity_id, post.process_unit_id, + coalesce(post.event_occurred_at, post.created_at) as occurred_at + from operations_case_classification classification + join source_post post on post.post_id = classification.post_id + where classification.case_kind_code = 'repeat_issue' + and post.post_id <> $1 + and post.post_body <> '' + and (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($3::text[]) = 0 + or post.process_unit_id::text = any($3::text[])))) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id + offset $4 limit $5 + """, + post_id, + list(account.corporate_entity_ids), + list(account.process_unit_ids), + offset, + _SIMILAR_VOC_PAGE_SIZE + 1, + ) + candidates = [row for row in rows[:_SIMILAR_VOC_PAGE_SIZE] if _can_see_post(account, row)] + + async def _adjudicate(candidate: asyncpg.Record): + with use_llm_metadata(build_post_llm_metadata(post_id, focal)): + return await asyncio.to_thread( + client.analyze, + focal["post_title"], + focal["post_body"], + str(candidate["post_id"]), + candidate["post_title"], + candidate["post_body"], + ) + + try: + results = await asyncio.wait_for( + asyncio.gather(*(_adjudicate(candidate) for candidate in candidates), return_exceptions=True), + timeout=_SIMILAR_VOC_REQUEST_TIMEOUT_SECONDS, + ) + except TimeoutError: + results = () + items = [] + for candidate, evidence in zip(candidates, results): + if evidence is None or isinstance(evidence, BaseException): + continue + items.append( + { + "post_id": evidence.candidate_post_id, + "post_title": candidate["post_title"], + "issue_summary": evidence.issue_summary, + "focal_evidence_text": evidence.focal_evidence_text, + "candidate_evidence_text": evidence.candidate_evidence_text, + "customer_cohort_text": evidence.customer_cohort_text, + "action_history": evidence.action_history, + "occurred_at": candidate["occurred_at"].isoformat(), + } + ) + return { + "items": items, + "next_offset": offset + _SIMILAR_VOC_PAGE_SIZE + if len(rows) > _SIMILAR_VOC_PAGE_SIZE + else None, + } + + +async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> str: + """Render author, business-unit, sales-pool, and customer hints without treating them as proof.""" + rows = await conn.fetch( + """ + select author.user_account_id as author_account_id, + author.display_name as author_name, + post.source_author_code, + post.source_author_name, + post.source_company_code, + post.source_company_name, + source_company.entity_name as source_company_catalog_name, + post.source_process_unit_code, + post.source_process_unit_name, + source_process_unit.process_unit_name as source_process_unit_catalog_name, + post.source_sales_pool_code, + post.source_sales_pool_name, + post.source_customer_code, + post.source_customer_name, + source_customer.entity_name as source_customer_catalog_name, + post.source_project_code, + post.source_project_name, + post.secondary_grouping_key as project_field, + customer.entity_name as customer_name, + affiliated.entity_name as author_affiliation_name + from source_post post + join user_account author on author.user_account_id = post.author_account_id + left join corporate_entity customer on customer.corporate_entity_id = post.corporate_entity_id + left join corporate_entity source_company + on source_company.corporate_entity_code = nullif(btrim(post.source_company_code), '') + left join process_unit source_process_unit + on source_process_unit.process_unit_code = nullif(btrim(post.source_process_unit_code), '') + left join corporate_entity source_customer + on source_customer.corporate_entity_code = nullif(btrim(post.source_customer_code), '') + left join account_affiliation account_aff + on account_aff.user_account_id = post.author_account_id + left join corporate_entity affiliated + on affiliated.corporate_entity_id = account_aff.corporate_entity_id + where post.post_id = $1 + """, + post_id, + ) + if not rows: + return "no structured hints available" + first = rows[0] + source_context_present = any( + first[field] is not None + for field in ( + "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_customer_code", + "source_customer_name", + "source_project_code", + "source_project_name", + ) + ) + source_author_name = first["source_author_name"] + if source_author_name and source_author_name == first["source_author_code"]: + source_author_name = None + return format_semantic_hints( + author_name=source_author_name or first["author_name"], + author_account_id=str(first["author_account_id"]), + author_account_name=first["author_name"], + author_affiliations=( + str(row["author_affiliation_name"]) + for row in rows + if row["author_affiliation_name"] + ), + order_pool_code=first["source_sales_pool_code"], + order_pool_name=first["source_sales_pool_name"], + project_field=first["project_field"], + customer_name=first["customer_name"], + source_author_code=first["source_author_code"], + source_author_name=source_author_name, + source_company_code=first["source_company_code"], + source_company_name=first["source_company_name"], + source_company_catalog_name=first["source_company_catalog_name"], + source_business_unit_code=first["source_process_unit_code"], + source_process_unit_name=first["source_process_unit_name"], + 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_customer_code=first["source_customer_code"], + source_customer_name=first["source_customer_name"], + source_customer_catalog_name=first["source_customer_catalog_name"], + source_project_code=first["source_project_code"], + source_project_name=first["source_project_name"], + source_context_present=source_context_present, + ) + + +async def _load_account_affiliation_hints( + conn: asyncpg.Connection, + account_ids: list[str], + corporate_entity_ids: list[str], +) -> dict[str, list[dict[str, Any]]]: + """Load authorized account affiliations as non-binding Keyman context.""" + if not account_ids or not corporate_entity_ids: + return {} + rows = await conn.fetch( + """ + select affiliation.user_account_id, + entity.corporate_entity_id, + entity.entity_name, + process.process_unit_code, + process.process_unit_name + from account_affiliation affiliation + join corporate_entity entity + on entity.corporate_entity_id = affiliation.corporate_entity_id + left join process_unit process + on process.process_unit_id = affiliation.process_unit_id + where affiliation.user_account_id = any($1::uuid[]) + and affiliation.corporate_entity_id = any($2::uuid[]) + order by entity.entity_name, process.process_unit_code + """, + account_ids, + corporate_entity_ids, + ) + affiliations: dict[str, list[dict[str, Any]]] = {} + for row in rows: + account_id = str(row["user_account_id"]) + affiliations.setdefault(account_id, []).append( + { + "corporate_entity_id": str(row["corporate_entity_id"]), + "entity_name": row["entity_name"], + "process_unit_code": row["process_unit_code"], + "process_unit_name": row["process_unit_name"], + } + ) + return affiliations + + +async def _load_source_author_context( + conn: asyncpg.Connection, + post_id: str, + corporate_entity_ids: list[str], +) -> dict[str, Any] | None: + """Return source-author/account context without binding a cataloged person.""" + row = await conn.fetchrow( + """ + select post.author_account_id, + author.display_name as account_display_name, + nullif(btrim(post.source_author_code), '') as source_author_code, + nullif(btrim(post.source_author_name), '') as source_author_name + from source_post post + join user_account author on author.user_account_id = post.author_account_id + where post.post_id = $1 + """, + post_id, + ) + if row is None: + return None + account_id = str(row["author_account_id"]) + affiliations = ( + await _load_account_affiliation_hints(conn, [account_id], corporate_entity_ids) + ).get(account_id, []) + source_author_name = row["source_author_name"] + if source_author_name and source_author_name.casefold() == str(row["source_author_code"] or '').casefold(): + source_author_name = None + return { + "author_account_id": account_id, + "account_display_name": row["account_display_name"], + "source_author_code": row["source_author_code"], + "source_author_name": source_author_name, + "account_affiliations": affiliations, + "resolution_status": ( + "our_side_context_only" if affiliations else "source_author_hint_only" + ), + "provenance": ( + "source_post.author_account_id/user_account.display_name/" + "account_affiliation.corporate_entity_id/source_post.source_author_code/source_post.source_author_name" + ), + } + + +@app.get("/api/posts/{post_id}/keymen") +async def read_post_keymen( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """People mentioned on one visible post, with their N:N affiliations.""" + post = await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + keymen = await fetch_post_keymen(conn, post_id) + source_author_context = await _load_source_author_context( + conn, post_id, list(account.corporate_entity_ids) + ) + return { + "post_id": str(post["post_id"]), + "keymen": keymen, + "source_author_context": source_author_context, + } + + +@app.get("/api/keymen/{person_id}/related") +async def read_related_keymen( + person_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one person, hiding unseen posts.""" + _require_post_read(account) + 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)) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this person") + person = await conn.fetchrow( + "select person_id, person_name, person_side_code from cataloged_person where person_id = $1", + person_id, + ) + related = await related_for_person(conn, person_id, visible_post_ids) + role_history = await fetch_person_role_history(conn, person_id, visible_post_ids) + return { + "person_id": str(person["person_id"]), + "person_name": person["person_name"], + "person_side_code": person["person_side_code"], + "related": related, + "role_history": role_history, + } + + +@app.get("/api/corporate-entities/{entity_id}/related") +async def read_related_corporate_entity( + entity_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one corporate entity, hiding unseen posts.""" + _require_post_read(account) + async with pool.acquire() as conn: + 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) + ) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this entity") + entity = await conn.fetchrow( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = $1", + entity_id, + ) + related = await related_for_entity(conn, entity_id, visible_post_ids) + return { + "corporate_entity_id": str(entity["corporate_entity_id"]), + "entity_name": entity["entity_name"], + "related": related, + } + + +@app.get("/api/teams/{team_id}/related") +async def read_related_team( + team_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one cataloged team, hiding unseen posts.""" + _require_post_read(account) + async with pool.acquire() as conn: + 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) + ) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") + team = await conn.fetchrow( + "select team_id, team_name from cataloged_team where team_id = $1", + team_id, + ) + related = await related_for_team(conn, team_id, visible_post_ids) + return { + "team_id": str(team["team_id"]), + "team_name": team["team_name"], + "related": related, + } + + +@app.get("/api/ontology/neighborhood") +async def read_ontology_neighborhood( + focus_node_type: str = Query(..., min_length=1), + focus_node_id: str = Query(..., min_length=1), + maximum_depth: int = Query(DEFAULT_MAXIMUM_DEPTH, ge=1, le=HARD_MAXIMUM_DEPTH), + maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES, ge=1, le=HARD_MAXIMUM_NODES), + maximum_edges: int = Query(DEFAULT_MAXIMUM_EDGES, ge=1, le=HARD_MAXIMUM_EDGES), + allowed_property_codes: list[str] | None = Query(None), + knowledge_cutoff: str | None = Query(None), + cursor: str | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Typed ontology/KG neighborhood, distinct from Event Lineage.""" + _require_post_read(account) + cutoff_clock = None + if knowledge_cutoff: + try: + cutoff_clock = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + try: + async with pool.acquire() as conn: + neighborhood = await visible_ontology_neighborhood( + conn, + focus_node_type_code=focus_node_type, + focus_node_id=focus_node_id, + can_see_post=lambda row: _can_see_post(account, row), + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=parse_allowed_property_query(allowed_property_codes), + knowledge_cutoff=cutoff_clock, + cursor=cursor, + source_cursor_secret=load_settings().ontology_source_cursor_secret, + source_cursor_scope=account.user_account_id, + ) + payload = neighborhood_to_payload(neighborhood) + except OntologyNeighborhoodError as exc: + raise HTTPException(neighborhood_error_http_status(exc), neighborhood_error_detail(exc)) from None + return payload + + +@app.get("/api/ontology/worker-functions/{domain}/{rank}") +async def read_worker_function_psychology( + domain: str, + rank: int, + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """I/O-Psychology demand profile for one DOT/FJA worker function (ADR 0251). + + Serves the grounded cognitive, affective, and behavioral construct + relations declared in the published ontology. An undeclared domain/rank + pair is an honest 404 -- never a fabricated profile. Unrecognized + domains are client errors. + """ + _require_post_read(account) + if rank < 0 or rank > 100 or domain not in {"data", "people", "things"}: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "domain must be one of data, people, things; rank within the published table extents", + ) + payload = worker_function_profile_payload(domain, rank) + if payload is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "undeclared worker function") + return payload + + +@app.get("/api/ontology/worker-function-constructs") +async def read_worker_function_construct_catalog( + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """Cognitive, affective, and behavioral construct catalog (ADR 0251). + + Returns the deterministic typed construct groups and their nomological + relations from the published ontology, for ontology/evidence surfaces. + """ + _require_post_read(account) + return construct_catalog_payload() + + +@app.get("/api/occupations/{onetsoc_code}/ratings") +async def read_occupation_ratings( + onetsoc_code: str = Path(..., pattern=r"^[0-9]{2}-[0-9]{4}\.[0-9]{2}$"), + data_release_code: str = Query( + ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" + ), + source_table_code: str = Query( + ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" + ), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0, le=10000), + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, object]: + """Return one authenticated, provenance-bearing occupation source profile.""" + async with pool.acquire() as conn: + return await fetch_occupation_ratings( + conn, + data_release_code=data_release_code, + source_table_code=source_table_code, + onetsoc_code=onetsoc_code, + limit=limit, + offset=offset, + ) + + +@app.get("/api/occupation-rating-sources") +async def read_occupation_rating_sources( + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, list[dict[str, object]]]: + """Return the authenticated catalog of imported occupation-rating sources.""" + async with pool.acquire() as conn: + return await fetch_occupation_rating_sources(conn) + + +@app.get("/api/occupation-rating-occupations") +async def read_rating_source_occupations( + data_release_code: str = Query( + ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" + ), + source_table_code: str = Query( + ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" + ), + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, object]: + """Return occupations represented in one imported rating source.""" + async with pool.acquire() as conn: + return await fetch_rating_source_occupations( + conn, + data_release_code=data_release_code, + source_table_code=source_table_code, + ) + + +@app.get("/api/occupational-constructs/search") +async def search_occupational_constructs( + q: str = Query(..., min_length=1), + family: str | None = Query(None), + knowledge_cutoff: str | None = Query(None), + cursor: str | None = Query(None), + limit: int | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Assertion-backed catalog matches the reviewer may already open.""" + _require_post_read(account) + cutoff_clock = None + if knowledge_cutoff: + try: + cutoff_clock = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + try: + async with pool.acquire() as conn: + page = await search_visible_occupational_constructs( + conn, + query=q, + family_code=family, + knowledge_cutoff=cutoff_clock, + cursor=cursor, + limit=limit, + can_see_post=lambda row: _can_see_post(account, row), + ) + except OccupationalConstructSearchError as exc: + raise HTTPException( + occupational_construct_search_http_status(exc), + occupational_construct_search_error_detail(exc), + ) from None + return search_page_to_payload(page) + +@app.get("/api/projects/{project_key}/history") +async def read_project_history( + project_key: str, + focus_post_id: UUID | None = Query(None), + knowledge_cutoff: str | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return one authorization-bounded project-history evidence projection.""" + + _require_post_read(account) + try: + cutoff = parse_as_of_clock(knowledge_cutoff) if knowledge_cutoff else datetime.now(timezone.utc) + async with pool.acquire() as conn: + return await fetch_project_history_projection( + conn, + project_key=project_key, + focus_post_id=str(focus_post_id) if focus_post_id else None, + knowledge_cutoff=cutoff, + corporate_entity_ids=sorted(account.corporate_entity_ids), + process_unit_ids=sorted(account.process_unit_ids), + ) + except ProjectHistoryRequestError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + except ProjectHistoryNotFound: + raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from None + + +@app.get("/api/posts/{post_id}/counterparties") +async def read_post_counterparties( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Classified counterparty orgs for one visible post. + + A name that resolves to a cataloged ``corporate_entity`` carries + that id so the popup can start the same related walk as an + affiliate-tree org click. Unresolved names stay ``null``. + """ + post = await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + counterparties = await fetch_post_counterparties(conn, post_id) + return { + "post_id": str(post["post_id"]), + "counterparties": counterparties, + } + + +@app.get("/api/posts/{post_id}/affiliate-tree") +async def read_post_affiliate_tree( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Ancestor forest of every organization this post's Keymen touch. + + Resolved affiliations walk ``corporate_entity.parent_entity_id``. + Unresolved names stay as their own roots -- a missing hierarchy + match is not a guessed parent. + """ + post = await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + trees = await fetch_affiliate_forest(conn, post_id) + return {"post_id": str(post["post_id"]), "trees": trees} + + +@app.get("/api/posts/{post_id}/voc-evidence") +async def read_post_voc_evidence( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """VOC type label plus extractive excerpts that name classified orgs.""" + post = await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + return await fetch_voc_evidence(conn, post_id, post["voc_type_code"]) + + +@app.post("/api/posts/{post_id}/verify-relations") +async def verify_post_entity_relationships( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Checks this post's `verify_pending` counterparty relationships + (entity_relationship_classification's LLM output) against external + web search (relation_verification.py) and persists the outcome, so a + hallucinated organization/relationship doesn't sit indistinguishable + from a corroborated one -- see ADR 0005. Gated by post_admin, not + post_read: a real external-search-call write action, same discipline + as extract-keymen. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + client = _relation_verification_client() + if not client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Relation verification is unavailable: set SEARXNG_BASE_URL", + ) + try: + verified = await verify_post_relations_from_pool( + pool, + client, + post_id, + visible_corporate_entity_ids=account.corporate_entity_ids, + ) + except (HttpClientError, OSError) as exc: + # A failed search is not "searched and found nothing"; turn the + # provider failure into a clean 503 rather than persisting a miss. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Relation verification is unavailable: the search provider did not respond", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Relation verification is unavailable: the search provider did not respond", + ) from exc + await publish_activity_event( + valkey, + post_id, + "relations_verified", + account.user_account_id, + f"Relations verified: {len(verified)} counterparty relationship(s) checked", + ) + return { + "post_id": str(post["post_id"]), + "verified": [ + { + "counterparty_entity_name": row.counterparty_entity_name, + "verification_status_code": row.verification_status_code, + "verification_evidence_url": row.verification_evidence_url, + "verification_evidence_post_id": row.verification_evidence_post_id, + } + for row in verified + ], + } + + +@app.post("/api/posts/{post_id}/extract-keymen") +async def extract_post_keymen( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Runs Keyman extraction over a post's own title+body and persists the + result (cataloged_person / person_affiliation / post_person_mention / + knowledge_graph_edge), then classifies each affiliated organization's + relationship to the post author's org (post_counterparty_entity). + Gated by post_admin, not post_read: this is a write action with a + real LLM-call cost, not a read. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + with use_llm_metadata(post_metadata): + keyman_client = _keyman_extraction_client() + if not keyman_client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + ) + relationship_client = _entity_relationship_client() + async with pool.acquire() as conn: + body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) + raw_body = "" if body_row is None else body_row["post_body"] + # HTML/base64-image content must never reach an LLM prompt raw -- + # tags dilute the model's attention and a base64 payload sent as + # literal text either blows the token budget or is silently + # ignored (see lineageweave/post_content_normalization.py). + context_hints = await _load_post_semantic_hints(conn, post_id) + try: + post_body = ( + await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) + ).text + mentions = await ingest_post_keymen( + conn, + keyman_client, + post_id, + post["post_title"], + post_body, + resolution_client=_organization_name_resolution_client(), + verification_client=_relation_verification_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + context_hints=context_hints, + persist_graph=False, + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + # Live bug (2026-08-19): an organization affiliated ONLY with an + # our_side person (our own factory, our own affiliate) got fed + # into the counterparty-relationship classifier the same as any + # external org -- forced to pick from six codes that all assume + # an external counterparty, it had no correct answer and landed + # on the closest wrong one (typically "Partner"). Only classify + # organizations a counterparty-side mention actually names. + organization_names = sorted( + { + name + for mention in mentions + if mention.person_side_code == COUNTERPARTY + for name in mention.affiliated_organization_names + } + ) + try: + relationships = await ingest_post_entity_relationships( + conn, relationship_client, post_id, post["post_title"], post_body, organization_names + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + async with conn.transaction(): + await persist_edges_for_post(conn, post_id) + await publish_activity_event( + valkey, + post_id, + "keymen_extracted", + account.user_account_id, + f"Keymen extracted: {len(mentions)} mention(s) found", + ) + return { + "post_id": str(post["post_id"]), + "extracted_count": len(mentions), + "mentions": [ + { + "person_name": mention.person_name, + "person_side_code": mention.person_side_code, + "affiliated_organization_names": list(mention.affiliated_organization_names), + "job_title": mention.job_title, + } + for mention in mentions + ], + "counterparties": [ + { + "organization_name": relationship.organization_name, + "relationship_type_code": relationship.relationship_type_code, + } + for relationship in relationships + ], + } + + +@app.get("/api/posts/{post_id}/lineage") +async def read_post_lineage( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """The Event Lineage panel's data: this post's directly (thread-)linked + posts and its indirectly (shared-Keyman/organization) linked posts, + kept as two distinguishable lists -- not merged into one, since they + are different claims about *why* two posts are related. ABAC-filtered + per candidate the same way every other endpoint here is. + """ + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + linked = await find_linked_post_ids(conn, post_id) + candidate_ids = linked.direct | linked.indirect + rows = {} + if candidate_ids: + # 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, process_unit_id, " + "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')}", + list(candidate_ids), + ) + rows = {str(row["post_id"]): row for row in fetched} + direct_intervals = await interval_relations_for_post(conn, post_id) + + def _visible_summaries(ids: frozenset[str], with_intervals: bool = False) -> list[dict[str, Any]]: + summaries = [] + for post_id_ in ids: + if post_id_ not in rows or not _can_see_post(account, rows[post_id_]): + continue + summary = { + "post_id": post_id_, + "post_title": rows[post_id_]["post_title"], + "post_body_excerpt": rows[post_id_].get("post_body_excerpt"), + "post_body_truncated": rows[post_id_].get("post_body_truncated", False), + } + if with_intervals: + summary.update(direct_intervals.get(post_id_, {})) + summaries.append(summary) + return summaries + + return { + "post_id": post_id, + "direct": _visible_summaries(linked.direct, with_intervals=True), + "indirect": _visible_summaries(linked.indirect), + } + + +@app.get("/api/posts/{post_id}/evaluation") +async def read_post_evaluation( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Persisted IRT responses for this post (ADR 0003 slice 2).""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + rows = await fetch_post_evaluation(conn, post_id) + return { + "post_id": post_id, + "rubric_version": RUBRIC_VERSION, + "responses": [ + { + "criterion_code": row.criterion_code, + "criterion_label": row.criterion_label, + "response_category": row.response_category, + "rubric_version": row.rubric_version, + } + for row in rows + ], + } + + +@app.post("/api/posts/{post_id}/evaluate") +async def evaluate_post( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """LLM-as-a-Judge a post through fast-mlsirm and persist the IRT row. + + Gated by post_admin: a real LLM-call write, same discipline as + extract-keymen. Null channel is 503, never a fabricated score. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + with use_llm_metadata(post_metadata): + client = _post_evaluation_client() + if not client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable. Ask an administrator to configure the " + "analysis service, then retry.", + ) + async with pool.acquire() as conn: + body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) + try: + normalized_body = ( + await asyncio.to_thread( + normalize_post_body, + "" if body_row is None else body_row["post_body"], + _vision_client(), + ) + ).text + async with pool.acquire() as conn: + rows = await ingest_post_evaluation( + conn, client, post_id, post["post_title"], normalized_body + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + await publish_activity_event( + valkey, + post_id, + "post_evaluated", + account.user_account_id, + f"Post evaluated: {len(rows)} rubric criterion response(s)", + ) + return { + "post_id": str(post["post_id"]), + "rubric_version": RUBRIC_VERSION, + "responses": [ + { + "criterion_code": row.criterion_code, + "criterion_label": row.criterion_label, + "response_category": row.response_category, + "rubric_version": row.rubric_version, + } + for row in rows + ], + } + + +@app.get("/api/reports/compare/{period_code}") +async def compare_period_groupings( + period_code: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """PU / corp / thread scores for one period on the shared metric.""" + _require_post_read(account) + try: + parse_period_code(period_code) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + async with pool.acquire() as conn: + rows = await fetch_period_comparison(conn, period_code) + demo_entity_ids: set[str] = set() + if rows and await has_real_source_context(conn, list(account.corporate_entity_ids)): + demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) + visible: list[dict[str, Any]] = [] + for row in rows: + members = [ + member + for member in row["members"] + if _can_see_post(account, member) + and not _is_synthetic_demo_member(member, demo_entity_ids) + ] + if not members: + continue + leftover_pairs = [ + pair + for pair in row.get("leftover_pairs", []) + if _can_see_post(account, pair) + and not _is_synthetic_demo_member(pair, demo_entity_ids) + ] + leftover_pairs = [ + { + key: value + for key, value in pair.items() + if key + not in { + "has_real_source_context", + "visibility_code", + "corporate_entity_id", + "process_unit_id", + } + } + for pair in leftover_pairs + ] + visible.append( + { + **row, + "members": [], + "leftover_pairs": leftover_pairs, + "post_count": len(members), + } + ) + return {"period_code": period_code, "groupings": visible} + + +@app.get("/api/reports/{grouping_kind}") +async def list_period_reports( + grouping_kind: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Available calibrated periods for one grouping kind (FIPC trend).""" + _require_post_read(account) + if grouping_kind not in GROUPING_KINDS: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") + async with pool.acquire() as conn: + summaries = await list_period_report_summaries(conn, grouping_kind) + demo_entity_ids: set[str] = set() + if summaries and await has_real_source_context(conn, list(account.corporate_entity_ids)): + demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) + visible: list[dict[str, Any]] = [] + for summary in summaries: + members = [ + member + for member in summary["members"] + if _can_see_post(account, member) + and not _is_synthetic_demo_member(member, demo_entity_ids) + ] + if not members: + continue + visible.append({**summary, "members": [], "post_count": len(members)}) + return {"grouping_kind": grouping_kind, "periods": visible} + + +@app.get("/api/reports/{grouping_kind}/{period_code}") +async def read_period_reports( + grouping_kind: str, + period_code: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Calibrated IRT scores for one grouping kind and calendar period.""" + _require_post_read(account) + if grouping_kind not in GROUPING_KINDS: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") + try: + parse_period_code(period_code) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + async with pool.acquire() as conn: + reports = await fetch_period_reports(conn, grouping_kind, period_code) + demo_entity_ids: set[str] = set() + if reports and await has_real_source_context(conn, list(account.corporate_entity_ids)): + demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) + visible: list[dict[str, Any]] = [] + for report in reports: + members = [ + member + for member in report["members"] + if _can_see_post(account, member) + and not _is_synthetic_demo_member(member, demo_entity_ids) + ] + if not members: + continue + leftover_pairs = [ + pair + for pair in report.get("leftover_pairs", []) + if _can_see_post(account, pair) + and not _is_synthetic_demo_member(pair, demo_entity_ids) + ] + members = [ + { + key: value + for key, value in member.items() + if key + not in { + "has_real_source_context", + "visibility_code", + "corporate_entity_id", + "process_unit_id", + } + } + for member in members + ] + leftover_pairs = [ + { + key: value + for key, value in pair.items() + if key + not in { + "has_real_source_context", + "visibility_code", + "corporate_entity_id", + "process_unit_id", + } + } + for pair in leftover_pairs + ] + leftover_map_axes = list(report.get("leftover_map_axes", [])) + visible.append( + { + **report, + "members": members, + "leftover_pairs": leftover_pairs, + "leftover_map_axes": leftover_map_axes, + "post_count": len(members), + } + ) + return {"grouping_kind": grouping_kind, "period_code": period_code, "reports": visible} + + +@app.post("/api/reports/{grouping_kind}/{period_code}/rebuild") +async def rebuild_period_report_endpoint( + grouping_kind: str, + period_code: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Refit or FIPC-score every group in the period. post_admin only.""" + _require_post_admin(account) + if grouping_kind not in GROUPING_KINDS: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") + try: + parse_period_code(period_code) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + async with pool.acquire() as conn: + async with conn.transaction(): + reports = await rebuild_period_reports(conn, grouping_kind, period_code) + return { + "grouping_kind": grouping_kind, + "period_code": period_code, + "group_count": len(reports), + "default_period": iso_week_period(), + } + + +@app.get("/api/posts/{post_id}/summary") +async def read_post_summary( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """A Korean summary, key events, and R&R for the popup. + + Returns a persisted row when one exists so a seeded demo stack is + not empty without a live LLM. Otherwise derives through the + orchestrator and stores the result. Missing both is 503 -- never a + fabricated summary. + """ + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + queue_event: tuple[str, str] | None = None + async with pool.acquire() as conn: + body_row = await conn.fetchrow( + "select post_body from source_post where post_id = $1", post_id + ) + try: + raw_body = require_summary_source_body( + None if body_row is None else body_row["post_body"] + ) + except ValueError as exc: + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + stored = await fetch_persisted_summary(conn, post_id) + if stored is not None: + return stored + stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) + + def stale_fallback( + reason: str, error: BaseException | None = None + ) -> dict[str, Any]: + """Return explicitly stale evidence while preserving operator diagnostics.""" + if stale is None: + raise RuntimeError("stale summary fallback called without a stale row") + logger.warning( + "post_summary_stale_fallback post_id=%s reason=%s error_type=%s", + post_id, + reason, + type(error).__name__ if error is not None else "unavailable", + ) + return stale + + with use_llm_metadata(post_metadata): + client = _post_summary_client() + if not client.available: + if stale is not None: + return stale_fallback("orchestrator_unavailable") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + ) + context_hints = await _load_post_semantic_hints(conn, post_id) + summarize_with_hints = getattr(client, "summarize_with_hints", None) + try: + normalized = await asyncio.to_thread(normalize_post_body, raw_body) + normalized_body = normalized.text + if callable(summarize_with_hints): + summary = await asyncio.to_thread( + summarize_with_hints, post["post_title"], normalized_body, context_hints + ) + else: + summary = await asyncio.to_thread(client.summarize, post["post_title"], normalized_body) + except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: + if stale is not None: + return stale_fallback("orchestrator_failure", exc) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale_fallback("orchestrator_unexpected_failure", exc) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + try: + payload = await persist_post_summary( + conn, + post_id, + summary, + post_body=normalized_body, + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + verification_client=_relation_verification_client(), + ) + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale_fallback("summary_persist_failure", exc) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + content_complete = await post_content_is_complete( + conn, + post_id, + require_embedding=bool( + load_settings().orchestrator_base_url + and load_settings().orchestrator_api_key + ), + require_structure=bool( + load_settings().orchestrator_base_url + and load_settings().orchestrator_api_key + ), + ) + async with conn.transaction(): + job = await ensure_post_content_job( + conn, + post_id, + raw_body, + content_complete=content_complete, + ) + if job.should_publish: + queue_event = (job.post_id, job.source_body_sha256) + if queue_event is not None: + await publish_post_content_event( + valkey, + post_id=queue_event[0], + source_body_digest=queue_event[1], + ) + return payload + + +@app.get("/api/posts/{post_id}/five-w1h") +async def read_post_five_w1h( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return an evidence-only 5W1H projection for an authorized post.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + return await load_five_w1h_slots( + conn, + post_id, + lambda row: _can_see_post(account, row), + ) + + +class ChatRequest(BaseModel): + """JSON body for ``POST /api/posts/{post_id}/chat``.""" + + question: str + + +class GlobalAskRequest(BaseModel): + """JSON body for the buyer's source-grounded Global Ask Agent.""" + + question: str + verify_external: bool = False + knowledge_cutoff: str | None = None + + +@app.get("/api/posts/{post_id}/chat") +async def read_post_chat( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Stored Ask exchanges for this post. + + Seeded fixture answers (and any later live persist) so the popup is + not an empty Ask box when the orchestrator is off. Missing rows are + an empty list, not a fabricated transcript. + """ + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + exchanges = await fetch_persisted_chats(conn, post_id) + return {"post_id": post_id, "exchanges": exchanges} + + +@app.post("/api/posts/{post_id}/chat") +async def chat_about_post( + post_id: str, + request: ChatRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """In-popup chat: answers `request.question` using this post's own + content plus its Event-Lineage-linked posts (direct and Knowledge- + Graph-indirect) as context, and returns which source post(s) the + answer drew from -- the sliding evidence panel's citation data. + + A persisted (seeded or previously live) row is returned first so + Ask works on the demo stack without an orchestrator. Live + reason-and-cite still runs only when no stored match exists and + the orchestrator is configured -- never a fabricated reply. + """ + question = request.question.strip() + if not question: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required" + ) + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + async with pool.acquire() as conn: + stored = await fetch_persisted_chat(conn, post_id, question) + if stored is not None: + source_ids = [post_id] + source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) + return { + "post_id": post_id, + "answer_text": stored["answer_text"], + "cited_post_ids": stored["cited_post_ids"], + "cited_posts": stored["cited_posts"], + "source_post_ids": source_ids, + } + with use_llm_metadata(post_metadata): + with traced( + "lineageweave.api.post_chat", + {"lineageweave.operation_code": "post_chat"}, + ): + try: + client = _post_chat_client() + if not client.available: + record_server_failure( + "post_chat", + RuntimeError("orchestrator unavailable"), + outcome="provider_unavailable", + ) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is temporarily unavailable. " + "Saved evidence is still available.", + ) + async with pool.acquire() as conn: + sources = await gather_chat_sources( + conn, + post_id, + lambda row: _can_see_post(account, row), + vision_client=_vision_client(), + ) + answer = await asyncio.to_thread(client.answer, question, sources) + except HTTPException: + raise + except ( + HttpClientError, + TimeoutError, + KeyError, + OSError, + TypeError, + ValueError, + ) as exc: + record_server_failure("post_chat", exc, outcome="provider_unavailable") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is temporarily unavailable. " + "Saved evidence is still available.", + ) from exc + except Exception as exc: + record_server_failure("post_chat", exc, outcome="internal_error") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is temporarily unavailable. " + "Saved evidence is still available.", + ) from exc + cited_ids = list(answer.cited_post_ids) + async with pool.acquire() as conn: + await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + await publish_activity_event( + valkey, + post_id, + "chat_answered", + account.user_account_id, + f"Chat answered: {question}", + ) + return { + "post_id": post_id, + "answer_text": answer.answer_text, + "cited_post_ids": cited_ids, + "cited_posts": cited_post_summaries(sources, cited_ids), + "source_post_ids": [source.post_id for source in sources], + } + + +@app.post("/api/ask", status_code=status.HTTP_202_ACCEPTED) +async def ask_agent( + request: GlobalAskRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Queue a reader question for asynchronous answering. + + A live answer is a multi-minute orchestrator LLM round-trip under + load, so it never runs inside this request: the question becomes a + durable ``global_ask_job`` row plus a Valkey wake-up, and the reader + polls ``GET /api/ask/jobs/{id}`` for the settled answer. Submission + still fails fast on the states that cannot ever succeed (blank + question, missing permission, unconfigured orchestrator). + + Optional ``knowledge_cutoff`` selects retained evidence available at + that clock. Omitting it keeps the live-query contract (ADR 0216). + """ + return await submit_global_ask( + pool=pool, + valkey=valkey, + account=account, + question=request.question, + verify_external=request.verify_external, + knowledge_cutoff=request.knowledge_cutoff, + service_available=_post_chat_client().available, + ) + + +@app.get("/api/ask/jobs/{ask_job_id}") +async def read_ask_job( + ask_job_id: UUID, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Report one Ask job's status, and its answer once it has settled. + + Owner-scoped: another account's job id reads as absent (404, not + 403) so job ids do not leak their existence across accounts. + """ + return await read_global_ask_job(pool=pool, account=account, ask_job_id=ask_job_id) + + +class PostBookmarkRequest(BaseModel): + """Body of a POST /api/posts/{post_id}/bookmark request.""" + + bookmarked: bool + + +@app.get("/api/posts/{post_id}/bookmark") +async def read_post_bookmark( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Report whether the current account has bookmarked this post.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + row = await conn.fetchrow( + "select 1 from bookmark where user_account_id = $1 and post_id = $2", + account.user_account_id, + post_id, + ) + return {"post_id": post_id, "bookmarked": row is not None} + + +@app.post("/api/posts/{post_id}/bookmark") +async def write_post_bookmark( + post_id: str, + request: PostBookmarkRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Set or clear the current account's bookmark on this post.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + if request.bookmarked: + await conn.execute( + """ + insert into bookmark (user_account_id, post_id) + values ($1, $2) + on conflict (user_account_id, post_id) do nothing + """, + account.user_account_id, + post_id, + ) + else: + await conn.execute( + "delete from bookmark where user_account_id = $1 and post_id = $2", + account.user_account_id, + post_id, + ) + return {"post_id": post_id, "bookmarked": request.bookmarked} + + +@app.get("/api/posts/{post_id}/tickets") +async def read_post_tickets( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """List issue tickets on one visible post. Read-only, so post_read + is enough -- same gate as every other post-scoped GET here. + """ + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + tickets = await list_tickets_for_post(conn, post_id) + return {"post_id": post_id, "tickets": tickets} + + +class CreateTicketRequest(BaseModel): + """JSON body for ``POST /api/posts/{post_id}/tickets``.""" + + ticket_title: str + ticket_status_code: str + assigned_account_id: str | None = None + # Manual calendar/to-do date, e.g. YYYY-MM-DD. Set automatically instead + # by POST /api/posts/{post_id}/derive-commitment when the LLM should + # decide it. + due_date: str | None = None + + +@app.post("/api/posts/{post_id}/tickets", status_code=status.HTTP_201_CREATED) +async def create_post_ticket( + post_id: str, + request: CreateTicketRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Create an issue ticket on a visible post. post_admin-gated: opening + a ticket is a write action, same discipline as extract-keymen. + """ + _require_post_admin(account) + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + try: + ticket = await create_ticket( + conn, + post_id, + request.ticket_title, + request.ticket_status_code, + request.assigned_account_id, + due_date=request.due_date, + ) + except asyncpg.ForeignKeyViolationError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + f"ticket_status_code {request.ticket_status_code!r} is not a valid ticket_status lookup code", + ) from exc + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + f"due_date {request.due_date!r} is not a valid YYYY-MM-DD date", + ) from exc + await publish_activity_event( + valkey, + post_id, + "ticket_created", + account.user_account_id, + ticket_created_summary(request.ticket_title), + ) + return ticket + + +class UpdateTicketRequest(BaseModel): + """JSON body for ``PATCH /api/tickets/{issue_ticket_id}``. + + ``assigned_account_id`` left unset means "don't touch it"; + ``clear_assignment=True`` explicitly unassigns. Both are distinct, + expressible outcomes a partial-update endpoint needs. + """ + + ticket_status_code: str | None = None + assigned_account_id: str | None = None + clear_assignment: bool = False + + +@app.patch("/api/tickets/{issue_ticket_id}") +async def patch_ticket( + issue_ticket_id: str, + request: UpdateTicketRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Update a ticket's status and/or assignee. post_admin-gated. The + ABAC check runs against the ticket's OWNING post, resolved first -- + a ticket has no visibility_code of its own, it inherits its post's. + """ + _require_post_admin(account) + async with pool.acquire() as conn: + post_id = await fetch_ticket_post_id(conn, issue_ticket_id) + if post_id is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + try: + ticket = await update_ticket( + conn, + issue_ticket_id, + request.ticket_status_code, + request.assigned_account_id, + request.clear_assignment, + ) + except asyncpg.ForeignKeyViolationError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + f"ticket_status_code {request.ticket_status_code!r} is not a valid ticket_status lookup code", + ) from exc + if ticket is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") + if request.ticket_status_code is not None: + await publish_activity_event( + valkey, + post_id, + "ticket_status_changed", + account.user_account_id, + ticket_status_changed_summary( + ticket.get("ticket_status_label") or request.ticket_status_code + ), + ) + return ticket + + +@app.get("/api/posts/{post_id}/activity") +async def read_post_activity( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """The post's activity feed, read straight off its Valkey stream + (``XREVRANGE``) -- newest first. Read-only, so post_read is enough. + """ + await _load_visible_post(post_id, account, pool) + events = await read_activity_events(valkey, post_id) + return {"post_id": post_id, "events": events} + + +@app.post("/api/posts/{post_id}/derive-commitment") +async def derive_post_commitment( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """LLM-derive a customer commitment (if any) from a post's own text, + and -- when found -- register it as an issue_ticket with a due_date, + which is what makes it show up on GET /api/calendar. post_admin-gated: + a real LLM-call write action, same discipline as extract-keymen. + `has_commitment: false` is a normal 200 response, not an error -- + most posts genuinely have no commitment in them. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + with use_llm_metadata(post_metadata): + client = _commitment_extraction_client() + if not client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + ) + async with pool.acquire() as conn: + body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) + try: + normalized_body = ( + await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) + ).text + # TimeML/TempEval document creation time, not wall-clock now: "by next + # Friday" in a January post must resolve to that January, not to the + # Friday after the operator clicked Derive. + reference_date = post["created_at"].date().isoformat() + commitment = await asyncio.to_thread( + client.extract, + post["post_title"], + normalized_body, + reference_date, + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + if not commitment.has_commitment: + return {"post_id": str(post["post_id"]), "has_commitment": False, "ticket": None} + async with pool.acquire() as conn: + try: + ticket = await upsert_commitment_ticket( + conn, + post_id, + commitment.commitment_summary, + commitment.due_date, + commitment.commitment_summary, + ) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + f"due_date {commitment.due_date!r} is not a valid YYYY-MM-DD date", + ) from exc + await publish_activity_event( + valkey, + post_id, + "commitment_derived", + account.user_account_id, + f"Commitment derived: {commitment.commitment_summary}", + ) + return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket} + + +@app.get("/api/analysis-runs") +async def list_analysis_runs( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Authorized analysis-run list: aggregates and labels only. + + Hidden scopes 404 at the item path and never appear here. The + payload has no source SQL, DSN, raw record, or provider body. + """ + _require_post_read(account) + async with pool.acquire() as conn: + runs = await fetch_visible_analysis_runs( + conn, + account.user_account_id, + list(account.corporate_entity_ids), + ) + return {"analysis_runs": runs} + + +class CreateAnalysisRunRequest(BaseModel): + """JSON body for ``POST /api/analysis-runs``. + + Omitting ``corporate_entity_id`` uses the account's sole affiliation. + Only ``analysis_run_lineage`` is accepted. Reconstruction and TEPP + execution stay later slices; this write records Pending lineage only. + """ + + run_kind_code: str = "analysis_run_lineage" + scope_kind_code: str = "analysis_scope_corporate_entity" + corporate_entity_id: str | None = None + knowledge_cutoff: datetime | None = None + idempotency_key: str + + +@app.post("/api/analysis-runs", status_code=status.HTTP_201_CREATED) +async def create_analysis_run( + request: CreateAnalysisRunRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Record a Pending lineage run on an authorized cutoff capture. + + post_read is enough: the caller requests a run of a corp they + already walk. TEPP and period-report kinds are 422 so this path + cannot invent a measurement. Hidden scopes 404. A matching + idempotent retry returns the same run. + """ + _require_post_read(account) + async with pool.acquire() as conn: + async with conn.transaction(): + try: + created = await create_pending_analysis_run( + conn, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + run_kind_code=request.run_kind_code, + scope_kind_code=request.scope_kind_code, + corporate_entity_id=request.corporate_entity_id, + knowledge_cutoff=request.knowledge_cutoff, + idempotency_key=request.idempotency_key, + ) + except AnalysisRunCreateError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + return created + + +@app.post("/api/analysis-runs/{analysis_run_id}/start") +async def start_analysis_run( + analysis_run_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Enqueue start work, then deliver ThreadWeave or TEPP. + + post_read is enough. Hidden runs 404. Period-report is 422 so this + path cannot invent a calibrated score. TEPP goes through + ``tepp_client`` and stays Failed when the transport is missing or + the envelope is not persistable. A Succeeded lineage retry returns + the stored tree. A Running restart with an undelivered outbox + finishes that work. A Running restart without pending work is 409. + The outbox commits before reconstruct/TEPP so a crash leaves a + durable work item (ADR 0023). + """ + _require_post_read(account) + settings = load_settings() + async with pool.acquire() as conn: + async with conn.transaction(): + try: + queued = await enqueue_pending_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + ) + except AnalysisRunStartError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + if queued.get("status_code") == "analysis_status_succeeded": + return queued + request_digest = queued.pop("outbox_request_sha256", None) + stream_id = None + if request_digest: + stream_id = await publish_outbox_event( + valkey, + analysis_run_id=analysis_run_id, + work_kind_code=str(queued.get("run_kind_code") or ""), + request_sha256=request_digest, + ) + try: + started = await deliver_queued_analysis_run( + pool, + database_url=settings.database_url, + analysis_run_id=analysis_run_id, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + tepp_client=configured_tepp_client( + settings.tepp_transport_url, + settings.tepp_api_key, + ), + adjudication_client=_adjudication_client(), + valkey_stream_entry_id=stream_id, + ) + except AnalysisRunStartError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + return started + + +@app.get("/api/analysis-runs/{analysis_run_id}") +async def read_analysis_run( + analysis_run_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """One authorized analysis-run projection, or 404 when hidden. + + Detail adds the labeled status history. Hidden runs never leak events. + """ + _require_post_read(account) + try: + UUID(analysis_run_id) + except ValueError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") from None + async with pool.acquire() as conn: + run = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account.user_account_id, + list(account.corporate_entity_ids), + ) + if run is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") + return run + + +@app.get("/api/calendar") +async def read_calendar( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + window_start: str | None = Query(default=None), + window_end: str | None = Query(default=None), +) -> dict[str, Any]: + """Return Naruon observed events beside authorized commitments. + + A missing or malformed Naruon audience never hides the internal to-do + projection and never creates a synthetic event. The end-user bearer + token is not forwarded. + """ + _require_post_read(account) + if (window_start is None) ^ (window_end is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "window_start and window_end must be supplied together", + ) + settings = load_settings() + if window_start is None or window_end is None: + window_start, window_end = default_calendar_window(datetime.now(timezone.utc)) + naruon = await asyncio.to_thread( + load_observed_calendar_events, + build_workspace_naruon_client( + settings.naruon_calendar_base_url, + settings.naruon_calendar_service_token, + ), + window_start, + window_end, + ) + events = [asdict(event) for event in naruon.events] + async with pool.acquire() as conn: + commitments = await fetch_upcoming_commitments(conn) + 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)] + # Once real evidence is visible, the synthetic Demo Corp commitments + # (ADR 0001 / ADR 0042) stop appearing beside it. + if demo_entity_ids: + visible = [ + 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["process_unit_id"], c["has_real_source_context"] + return { + "events": events, + "commitments": visible, + "calendar_sources": { + "naruon_available": naruon.available, + "naruon_next_action": naruon.next_action, + }, + } + + +@app.get("/api/rankings") +async def read_rankings( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RankWeave fusion of ABAC-visible posts (ADR 0024 / ADR 0167). + + Hidden posts are omitted from every channel. Never invents a fused + score or a theta. Channel evidence is computed from owned rank + lists. Fail-closed when RankWeave is disabled or the library is + missing. + """ + _require_post_read(account) + async with pool.acquire() as conn: + posts = await load_visible_ranking_posts( + conn, lambda row: _can_see_post(account, row) + ) + return _rankweave_client().as_api_payload( + posts, can_see_post=lambda _row: True + ) From 4d756360d4fd98e76ebc9cf890b163bfa6208c28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:10:43 +0900 Subject: [PATCH 12/12] fix(calendar): remove stale CalDAV backend import --- backend/app/main.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..7aab6deaa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -197,10 +197,6 @@ ContextualOrchestratorAdjudicationClient, NullAdjudicationClient, ) -from lineageweave.caldav_client import ( - CALDAV_UNAVAILABLE_NEXT_ACTION, - build_caldav_client, -) from lineageweave.commitment_extraction import ( ContextualOrchestratorCommitmentExtractionClient, NullCommitmentExtractionClient,