diff --git a/.github/workflows/repair-global-ask-pnpm-v2.yml b/.github/workflows/repair-global-ask-pnpm-v2.yml deleted file mode 100644 index a2ef5489f..000000000 --- a/.github/workflows/repair-global-ask-pnpm-v2.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Repair Global Ask pnpm provisioning deterministically - -on: - workflow_dispatch: - push: - branches: - - "feat/global-ask-public-claim-verification-v2200" - paths: - - ".github/workflows/repair-global-ask-pnpm-v2.yml" - -permissions: - contents: write - -concurrency: - group: repair-global-ask-pnpm-v2200-v2 - cancel-in-progress: false - -jobs: - repair: - name: Pin repository pnpm and re-arm product integration - runs-on: ubuntu-latest - steps: - - name: Checkout exact feature branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/global-ask-public-claim-verification-v2200 - fetch-depth: 0 - persist-credentials: true - - - name: Repair only the package-manager provisioning boundary - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - workflow = Path('.github/workflows/apply-global-ask-public-verification-v2200.yml') - text = workflow.read_text(encoding='utf-8') - - actor_guard = " github.event.pull_request.head.repo.full_name == github.repository &&\n github.actor != 'github-actions[bot]'" - if actor_guard in text: - text = text.replace( - actor_guard, - " github.event.pull_request.head.repo.full_name == github.repository", - 1, - ) - - old_install = " corepack enable\n pnpm --dir frontend install --frozen-lockfile" - new_install = ( - " corepack enable\n" - " corepack prepare pnpm@9.15.9 --activate\n" - " test \"$(pnpm --version)\" = \"9.15.9\"\n" - " pnpm --dir frontend install --frozen-lockfile" - ) - if old_install in text: - text = text.replace(old_install, new_install, 1) - elif new_install not in text: - raise SystemExit('refusing to edit an unknown pnpm provisioning shape') - - if "github.actor != 'github-actions[bot]'" in text: - raise SystemExit('actor guard remains after repair') - if new_install not in text: - raise SystemExit('pinned pnpm provisioning was not installed') - - workflow.write_text(text, encoding='utf-8') - PY - - - name: Remove repair-only workflows and publish the narrow repair - shell: bash - run: | - rm -f .github/workflows/repair-global-ask-pnpm.yml - rm .github/workflows/repair-global-ask-pnpm-v2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A .github/workflows - git diff --cached --check - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "ci: pin Global Ask pnpm provisioning" - git push origin HEAD:feat/global-ask-public-claim-verification-v2200 diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..858175ccb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ reimplementing them: tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls). - [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for multi-channel score fusion (`weighted_convex_fuse` in - `reconstruct.py`) and the buyer-facing Rankings port + `reconstruct.py`) and the reader-facing Rankings port (`rankweave_client.py`) -- never invent a fused score or a theta. - [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire contract for calibrated measurement (`tepp_client.py`) -- never @@ -127,7 +127,7 @@ contextual-orchestrator owns model discovery and selection. retaining the original asset and provenance. Recognize image DOM/visual regions before OCR, descriptions, Keyman extraction, or embeddings. Store region-level evidence; never show an internal LLM instruction such as - `This post is an image` to a buyer. + `This post is an image` to a reader. ## Source parsing and semantic units @@ -146,10 +146,10 @@ contextual-orchestrator owns model discovery and selection. - Remove presentation-only visual line alignment inside a paragraph (for example continuation lines manually aligned after `-`, `*`, `1.`, or `.`) from derived semantic text, while retaining the source body and meaningful - list/heading nesting. A buyer-facing post view must render semantic + list/heading nesting. A reader-facing post view must render semantic paragraphs, not the authoring application's spacing workaround. - Image descriptions, OCR text, and region evidence are analysis artifacts, - not buyer-facing prompt instructions. Buyer UI shows the source content and + not reader-facing prompt instructions. The workspace UI shows the source content and useful captions/evidence only, with provenance where appropriate. ## Pluggable channels: never fake a missing signal diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..d4ff2e159 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the reader sees the picture, not the base64 string; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | @@ -326,7 +326,7 @@ Keymen are affiliated with (`lineageweave/affiliate_tree.py`, loaded by set of those leaves, not the whole company directory -- a sibling the post never mentions is omitted. People on the tree are buttons that reuse `GET /api/keymen/{person_id}/related` so the popup Keyman walk -starts from the affiliation the buyer clicked. A resolved organization +starts from the affiliation the reader clicked. A resolved organization is the same walk via `GET /api/corporate-entities/{id}/related`. An affiliation that did not resolve to a `corporate_entity` row stays as its own root (`resolved=false`); that is the same never-guess-a-parent rule @@ -467,7 +467,7 @@ truly have no dated open tickets. ## Phase 6-M2: authorized analysis-run evidence (read projection) -Issue #79's first buyer-visible Milestone 2 slice is a source-redacting +Issue #79's first reader-visible Milestone 2 slice is a source-redacting read of the #89 registry. `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` require `post_read` and apply the scope in SQL: the requester always sees their own run; a corporate-entity or @@ -475,7 +475,7 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the run's scope whose `created_at` is at or before `knowledge_cutoff` -(ADR 0016) so a buyer can open a post the run was allowed to know +(ADR 0016) so a reader can open a post the run was allowed to know without seeing later live rows or hidden bodies. Detail also returns revision and configuration digest prefixes. `POST /api/analysis-runs` records a Pending lineage run on a new @@ -925,7 +925,7 @@ code. Wired into both `keyman_ingestion.py`'s affiliation loop and ## Standards-complete W3C PROV-O provenance layer ADR 0011 separates standards-complete provenance from the compact -buyer-facing navigation graph. `lineageweave/prov_o.py` validates +reader-facing navigation graph. `lineageweave/prov_o.py` validates and materializes all 50 normative PROV-O properties, including literal-valued times/values and qualified Influence resources. `migrations/0017_prov_o_standard_relations.sql` stores definitions, diff --git a/CHANGELOG.d/2.12.6-frontend-build-gate.md b/CHANGELOG.d/2.12.6-frontend-build-gate.md new file mode 100644 index 000000000..75d7d6590 --- /dev/null +++ b/CHANGELOG.d/2.12.6-frontend-build-gate.md @@ -0,0 +1,3 @@ +## Fixed + +- Keep the unauthenticated login surface free of authenticated admin controls and remove unused OIDC imports so TypeScript production builds pass. diff --git a/CHANGELOG.d/2.12.6-provider-error-boundary.md b/CHANGELOG.d/2.12.6-provider-error-boundary.md new file mode 100644 index 000000000..99a2fbd4d --- /dev/null +++ b/CHANGELOG.d/2.12.6-provider-error-boundary.md @@ -0,0 +1,4 @@ +## Fixed + +- Keep contextual-orchestrator, OIDC, RankWeave, TEPP, and durable-ingestion diagnostics behind stable product error boundaries while retaining the original exception for server-side chaining. +- Keep browser 5xx and transport failures behind the same stable client error boundary. diff --git a/CHANGELOG.d/2.12.6-remove-self-modifying-repair-workflows.md b/CHANGELOG.d/2.12.6-remove-self-modifying-repair-workflows.md new file mode 100644 index 000000000..8392cf24c --- /dev/null +++ b/CHANGELOG.d/2.12.6-remove-self-modifying-repair-workflows.md @@ -0,0 +1,5 @@ +## Fixed + +- Removed completed repair-only GitHub Actions that could write to feature + branches. Product fixes now require ordinary reviewed commits and the normal + protected Checks path. diff --git a/CHANGELOG.d/2.12.7-lineage-dag-evidence.md b/CHANGELOG.d/2.12.7-lineage-dag-evidence.md new file mode 100644 index 000000000..acb9ae5d6 --- /dev/null +++ b/CHANGELOG.d/2.12.7-lineage-dag-evidence.md @@ -0,0 +1,8 @@ +### Changed + +- Made the buyer Event Lineage DAG explicitly directional with parent-to-child arrowheads whose paths stop outside node circles. +- Preserved authored graph width behind a keyboard-focusable horizontal scroll region instead of shrinking deep lineages. +- Added visible event dates, a redundant visual/text legend, and an accessible exact-value evidence table for lineage relations, dates, and fused scores. +- Added five-locale buyer copy stating that reconstructed continuation edges do not prove causality or authoritative fact. +- Replaced the internal "seed posts" empty-state language with an actionable five-locale instruction to add eligible source records and rebuild Event Lineage. +- Added synthetic Storybook states for branching, selected-root, isolated-root, and empty lineage surfaces. diff --git a/CHANGELOG.d/global-ask-history-compose-replay.md b/CHANGELOG.d/global-ask-history-compose-replay.md new file mode 100644 index 000000000..595be4884 --- /dev/null +++ b/CHANGELOG.d/global-ask-history-compose-replay.md @@ -0,0 +1,4 @@ +### Fixed + +- Replay the Global Ask conversation-history migration in the Compose database + initializer so existing volumes create the tables required by Ask history. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..114cf546a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,83 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added + +- Playwright e2e harness (`frontend/playwright.config.ts`, `npm run + test:e2e`) with the ADR 0132 fail-closed spec + (`frontend/e2e/topic-lineage-fail-closed.spec.ts`): a seeded Failed + topic-lineage run must show its caption, connect-a-transport next + action, and detail-only failure code, and must render no + evidence/inference/prediction mark without a TEPP envelope. Skips + itself, with the ADR 0001/0042 reason named, on deployments holding + real source data. + +- Registered the `analysis_run_topic_lineage` analysis-run kind (migrations + 0131/0132, ADR 0132), the LineageWeave-side consumption boundary for + TEPP's Temporal Relational Shared-Latent Topic Measurement (TRSL-TM, + TEPP ADR 0012) and CHRONOS/TDT event-intelligence status (TEPP ADR 0016). + It mirrors the existing TEPP measurement path exactly: submits through + `tepp_client`, fails closed (`tepp_not_available` / + `tepp_result_not_persisted`) until TEPP publishes a completed envelope, + and never computes a topic identity or event prediction locally. + `make seed` now also writes a Demo Corp topic-lineage run alongside the + existing lineage/TEPP/period-report rows. + +- `EvidenceStatusMark`, a reusable evidence/inference/prediction status + badge (ADR 0132 decision 5, TEPP ADR 0016) distinguishing status by label + text and glyph shape, not color alone. Ships ahead of the Event Lineage + DAG topic-thread wiring it is designed for, so review and Storybook + coverage (`Analysis/EvidenceStatusMark`) are available now; it is + presentational only and never infers a status itself. + +- Persisted Global Ask conversation history (ADR 0126, superseding the + non-persistence consequence in ADR 0090): migration + `0105_global_ask_conversation_history.sql` and + `backend/app/global_ask_history.py` store each account's questions, + answers, next actions, retrieved source posts, cited posts, and + reader-safe evidence facts, and re-apply current post visibility on + read so a revoked post drops from history while the stored answer + stays. Ask history now survives navigation and a new browser session + instead of living only in the client. + +### Changed + +- Renamed "Buyer" terminology to reader/workspace naming across the frontend + shell, backend evidence helpers, and living docs (ADR 0119). LineageWeave + has no explicit buyer role, so `BuyerNav`/`BuyerDestination` became + `WorkspaceNav`/`WorkspaceDestination`, `.buyer-gnb*` CSS became + `.workspace-gnb*`, and prose referring to the reading user now says + "reader" instead of "buyer". Historical ADRs and changelog entries keep + their original wording as a point-in-time record. + ### Fixed +- The post-detail dialog focus trap now excludes descendants of `aria-hidden` + content as well as collapsed `details`, keeping keyboard focus inside the + visible modal controls. + - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +- The workspace Event Lineage global Search action now retries focus after the + board finishes loading, so navigation from Customer master, Calendar, or + Ask Agent lands the cursor in the search box. The handled request is consumed, + so later board navigation does not steal focus, and Search closes an open + mobile drawer like every other destination change. +- Mobile Event Lineage evidence cards now read their translated column labels + from the rendered cells instead of hardcoded English CSS, and the two drawer + close controls have distinct accessible names. +- Event Lineage SVG edges now retain their instance-specific direction markers, + so parent-to-child arrows remain visible when multiple lineage groups render. +- All OpenAI-compatible chat-completion consumers now validate the shared + response envelope before parsing it, preventing malformed provider bodies + from escaping as raw `KeyError` or response-shape details. +- Customer Master integration fixtures no longer reference the Global Ask + history migration for their own setup; they apply only the shipped + scope-facet migration directly, since Global Ask's persisted history + (see Added, ADR 0126) is unrelated to Customer Master scope fixtures. +- The static SQL review contract now counts the Customer Master evidence query + that uses closed schema fragments and bound entity ids. ## [2.12.6] - 2026-08-20 diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..67ab3241c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,22 @@ cited source. After that next action, the popup lands the first cited evidence. Changing the week first still focuses the report period field. Mean θ stays on the period-report panel. + +## Analysis-run topic lineage (ADR 0132) + +`make seed` also writes a Demo Corp topic-lineage run on the same shared +snapshot, alongside the lineage, TEPP, and period-report rows. The +topic-lineage kind (`analysis_run_topic_lineage`, migration 0131) submits +through the same `tepp_client` boundary as TEPP (ADR 0022), requesting +TEPP's Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) and +CHRONOS/TDT event-intelligence status instead of calibrated psychometric +measurement. A missing transport or an unused accepted envelope is Failed +(`tepp_not_available` / `tepp_result_not_persisted`), the same as TEPP. Do +not invent a topic identity, a CHRONOS event prediction, or a local topic +model of any kind. `POST /api/analysis-runs` still 422s this kind — Create +does not invent a Pending topic-lineage row; connect a TEPP transport from +a Failed topic-lineage row and re-run through +`POST /api/analysis-runs/{id}/start`, exactly like TEPP. A Succeeded +envelope persists into `analysis_run_topic_lineage_result` (migration 0132) +as an opaque, versioned JSON envelope until TEPP's topic-identity/CHRONOS +schema stabilizes into dedicated columns. diff --git a/add_translations.py b/add_translations.py index 8448d75ee..e9f233ac7 100644 --- a/add_translations.py +++ b/add_translations.py @@ -39,7 +39,7 @@ for eng, trans in translations.items(): content = content.replace(f' Refresh: "새로 고침",', f' Refresh: "새로 고침",\n "{eng}": "{trans["ko"]}",') content = content.replace(f' Refresh: "조회",', f' Refresh: "조회",\n "{eng}": "{trans["ko"]}",') - + content = content.replace(f' Refresh: "刷新",', f' Refresh: "刷新",\n "{eng}": "{trans["zh"]}",') content = content.replace(f' Refresh: "更新",', f' Refresh: "更新",\n "{eng}": "{trans["ja"]}",') content = content.replace(f' Refresh: "Làm mới",', f' Refresh: "Làm mới",\n "{eng}": "{trans["vi"]}",') diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f7da2969b..09c749b9b 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -34,11 +34,13 @@ _LINEAGE_RUN_KIND = "analysis_run_lineage" _TEPP_RUN_KIND = "analysis_run_tepp" _REPORT_RUN_KIND = "analysis_run_report" +_TOPIC_LINEAGE_RUN_KIND = "analysis_run_topic_lineage" _CORPORATE_SCOPE = "analysis_scope_corporate_entity" _CAPTURE_CONTRACT_VERSION = "analysis-run-capture-v1" _KIND_SCHEMA_VERSION = { "analysis_run_lineage": "lineage-run-v1", "analysis_run_tepp": "tepp-run-v1", + "analysis_run_topic_lineage": "topic-lineage-run-v1", } _RUN_LIST_SQL = f""" @@ -249,7 +251,7 @@ async def fetch_outbox_deliveries( """Labeled claim/delivery events for one already-visible run. Missing outbox tables mean migration 0023 is not applied. Stream - entry ids stay off the payload -- they are not buyer evidence. + entry ids stay off the payload -- they are not reader-facing evidence. """ try: rows = await conn.fetch( @@ -285,7 +287,7 @@ async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], ) -> list[dict[str, Any]]: - """Project registry rows into the authorized buyer-facing payload.""" + """Project registry rows into the authorized reader-facing payload.""" if not rows: return [] count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows]) @@ -346,7 +348,7 @@ async def fetch_visible_analysis_runs( """Runs the account requested or whose scope they may already walk. Once real source-import evidence is visible, the synthetic `make seed` - Demo Corp runs stop appearing here -- a buyer must not mistake that + Demo Corp runs stop appearing here -- a reader must not mistake that fabricated narrative for real evidence (ADR 0001 / ADR 0042). """ # Safe SQL: this immutable module query contains only closed schema SQL; request values remain bound below. @@ -611,11 +613,12 @@ def __init__(self, status_code: int, detail: str) -> None: def _require_lineage_create_kind(run_kind_code: str) -> None: - """Reject TEPP and report writes so this path cannot fake those products. + """Reject TEPP, topic-lineage, and report writes so this path cannot fake those products. - TEPP stays a ``tepp_client`` wire path. Period reports stay on the - Reports panel rebuild. A Pending TEPP row that never called the - transport is a fabricated measurement request. + TEPP and topic-lineage stay ``tepp_client`` wire paths (ADR 0022 / + ADR 0132). Period reports stay on the Reports panel rebuild. A Pending + TEPP or topic-lineage row that never called the transport is a + fabricated measurement request. """ if run_kind_code == _TEPP_RUN_KIND: raise AnalysisRunCreateError( @@ -623,6 +626,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None: "Connect a TEPP transport from a Failed TEPP row; this endpoint " "does not invent a measurement.", ) + if run_kind_code == _TOPIC_LINEAGE_RUN_KIND: + raise AnalysisRunCreateError( + 422, + "Connect a TEPP transport from a Failed topic-lineage row; this " + "endpoint does not invent a topic model.", + ) if run_kind_code == _REPORT_RUN_KIND: raise AnalysisRunCreateError( 422, diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..2b2de3a36 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -37,12 +37,15 @@ _LINEAGE_KIND = "analysis_run_lineage" _TEPP_KIND = "analysis_run_tepp" _REPORT_KIND = "analysis_run_report" +_TOPIC_LINEAGE_KIND = "analysis_run_topic_lineage" _PENDING = "analysis_status_pending" _RUNNING = "analysis_status_running" _SUCCEEDED = "analysis_status_succeeded" _FAILED = "analysis_status_failed" _TEPP_MODEL_CONTRACT = "tepp-analysis-run-v1" _TEPP_OUTPUT_PROFILE = "calibrated_event_measurement" +_TOPIC_LINEAGE_MODEL_CONTRACT = "tepp-topic-lineage-v1" +_TOPIC_LINEAGE_OUTPUT_PROFILE = "topic_identity_lineage" class AnalysisRunStartError(AnalysisRunCreateError): @@ -69,11 +72,11 @@ def reconstruction_result_digest(edges: list[Edge]) -> str: def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None: """Return a 422 when start cannot run this kind. - Lineage reconstructs the frozen bag. TEPP submits through - ``tepp_client`` and never invents a theta. Period-report stays on - its own rebuild path. + Lineage reconstructs the frozen bag. TEPP and topic-lineage submit + through ``tepp_client`` and never invent a theta or a topic (ADR 0022 / + ADR 0132). Period-report stays on its own rebuild path. """ - if run_kind_code in {_LINEAGE_KIND, _TEPP_KIND}: + if run_kind_code in {_LINEAGE_KIND, _TEPP_KIND, _TOPIC_LINEAGE_KIND}: return None if run_kind_code == _REPORT_KIND: return AnalysisRunStartError( @@ -104,7 +107,7 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]: headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json(url, payload, headers=headers, timeout=30.0) except (HttpClientError, OSError, ValueError, TypeError) as exc: - raise TeppNotAvailable(str(exc)) from exc + raise TeppNotAvailable("TEPP transport unavailable") from exc return TeppClient(transport=transport) @@ -130,6 +133,34 @@ def tepp_run_request( ) +def topic_lineage_run_request( + *, + idempotency_key: str, + snapshot_sha256: str, + knowledge_cutoff: datetime, + corporate_entity_id: str, +) -> AnalysisRunRequest: + """Build TEPP's published request for a topic-lineage run (ADR 0132). + + Same wire shape as :func:`tepp_run_request` -- TEPP's + ``AnalysisRunRequest`` already carries no post body or fabricated + label -- only the model contract and output profile differ, selecting + TRSL-TM topic identity plus CHRONOS/TDT event-intelligence status + instead of calibrated psychometric measurement. + """ + cutoff = knowledge_cutoff + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) + return AnalysisRunRequest( + idempotency_key=idempotency_key, + tenant_workspace_id=str(corporate_entity_id), + snapshot_id=snapshot_sha256, + knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + model_contract_version=_TOPIC_LINEAGE_MODEL_CONTRACT, + output_profile=_TOPIC_LINEAGE_OUTPUT_PROFILE, + ) + + def _tepp_submission( client: TeppClient, request: AnalysisRunRequest, @@ -165,6 +196,49 @@ def tepp_submit_outcome( return status_code, failure_code +def _topic_lineage_envelope_is_valid(envelope: dict[str, Any]) -> bool: + """Require TEPP's versioned topic-identity/CHRONOS-status contract (ADR 0132). + + ``_tepp_submission`` only checks that ``result`` is *a* dict -- a + ``completed`` envelope carrying the calibrated-measurement shape (or any + other unrelated payload) would pass it too, since both requests share the + same wire contract and differ only in ``model_contract_version`` / + ``output_profile``. This additionally requires TRSL-TM topic identity and + CHRONOS/TDT status, keyed by envelope version. + """ + result = envelope.get("result") + if not isinstance(result, dict): + return False + if not isinstance(result.get("envelope_version"), int): + return False + topic_identity = result.get("topic_identity") + if not isinstance(topic_identity, (list, dict)) or not topic_identity: + return False + chronos_status = result.get("chronos_status") + if not isinstance(chronos_status, (list, dict, str)) or not chronos_status: + return False + return True + + +def topic_lineage_submit_outcome( + client: TeppClient, + request: AnalysisRunRequest, +) -> tuple[str, str, dict[str, Any] | None]: + """Submit through ``tepp_client`` and require the topic-lineage contract. + + Mirrors :func:`tepp_submit_outcome`, but a syntactically ``completed`` + envelope that omits the versioned topic-identity/CHRONOS-status contract + is also Failed (``tepp_topic_contract_unavailable``, ADR 0132 Decision + item 3), not silently persisted as a topic-lineage result. + """ + status_code, failure_code, envelope = _tepp_submission(client, request) + if status_code == _SUCCEEDED and not ( + envelope is not None and _topic_lineage_envelope_is_valid(envelope) + ): + return _FAILED, "tepp_topic_contract_unavailable", None + return status_code, failure_code, envelope + + async def _persist_tepp_result( conn: asyncpg.Connection, *, @@ -196,6 +270,42 @@ async def _persist_tepp_result( return True +async def _persist_topic_lineage_result( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + envelope: dict[str, Any], +) -> bool: + """Persist only a validated, remote-completed topic-lineage envelope. + + Stores TEPP's TRSL-TM topic identity / CHRONOS status envelope + verbatim (ADR 0132); LineageWeave does not decompose or reinterpret + its evidence/inference/prediction fields here. + """ + remote_run_id = envelope.get("analysis_run_id") or envelope.get("run_id") + if not isinstance(remote_run_id, str) or not remote_run_id.strip(): + return False + result_json = json.dumps(envelope, separators=(",", ":"), sort_keys=True) + result_sha256 = hashlib.sha256(result_json.encode("utf-8")).hexdigest() + try: + async with conn.transaction(): + await conn.execute( + """ + insert into analysis_run_topic_lineage_result + (analysis_run_id, remote_run_id, result_json, result_sha256) + values ($1, $2, $3::jsonb, $4) + on conflict (analysis_run_id) do nothing + """, + analysis_run_id, + remote_run_id, + result_json, + result_sha256, + ) + except (asyncpg.PostgresError, TypeError, ValueError): + return False + return True + + def start_write_conflict_error() -> AnalysisRunStartError: """Next action when a concurrent start already wrote this run.""" return AnalysisRunStartError( @@ -628,6 +738,13 @@ async def deliver_queued_analysis_run( locked=outbox, tepp_client=tepp_client or TeppClient(), ) + elif outbox["work_kind_code"] == _TOPIC_LINEAGE_KIND: + await _deliver_topic_lineage_measurement( + conn, + analysis_run_id=analysis_run_id, + locked=outbox, + tepp_client=tepp_client or TeppClient(), + ) else: await _deliver_lineage_reconstruction( conn, @@ -786,3 +903,45 @@ async def _deliver_tepp_measurement( finished, failure_code, ) + + +async def _deliver_topic_lineage_measurement( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + locked: asyncpg.Record, + tepp_client: TeppClient, +) -> None: + """Submit the frozen snapshot through ``tepp_client`` for topic-lineage. + + Mirrors :func:`_deliver_tepp_measurement` (ADR 0022) with the + topic-lineage model contract (ADR 0132). Never persists a locally + computed topic identity or CHRONOS/TDT event prediction. + """ + now = datetime.now(timezone.utc) + request = topic_lineage_run_request( + idempotency_key=str(locked["idempotency_key"]), + snapshot_sha256=str(locked["snapshot_sha256"]), + knowledge_cutoff=locked["knowledge_cutoff"], + corporate_entity_id=str(locked["corporate_entity_id"]), + ) + status_code, failure_code, envelope = topic_lineage_submit_outcome(tepp_client, request) + if status_code == _SUCCEEDED and envelope is not None: + if not await _persist_topic_lineage_result( + conn, + analysis_run_id=analysis_run_id, + envelope=envelope, + ): + status_code = _FAILED + failure_code = "tepp_result_not_persisted" + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await _append_status( + conn, + analysis_run_id, + await _next_status_ordinal(conn, analysis_run_id), + status_code, + finished, + failure_code, + ) diff --git a/backend/app/auth.py b/backend/app/auth.py index 155974d52..cc19cc807 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -56,7 +56,7 @@ def _jwks(settings: Settings, *, force_refresh: bool = False) -> dict: except (HttpClientError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"could not fetch OIDC JWKS for {settings.oidc_issuer}: {exc}", + "could not fetch OIDC JWKS from the configured identity provider", ) from exc _jwks_cache[cache_key] = cached return cached @@ -91,7 +91,7 @@ def _signing_key_from_jwks(jwks: dict, token: str): return RSAAlgorithm.from_jwk(json.dumps(key)) except (KeyError, TypeError, ValueError) as exc: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "matching JWKS key is invalid") from exc - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"no JWKS key matched kid={kid!r}") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token signing key is not recognized") def _signing_key(settings: Settings, token: str): @@ -99,7 +99,7 @@ def _signing_key(settings: Settings, token: str): try: return _signing_key_from_jwks(_jwks(settings), token) except HTTPException as exc: - if not str(exc.detail).startswith("no JWKS key matched kid="): + if str(exc.detail) != "access token signing key is not recognized": raise return _signing_key_from_jwks(_jwks(settings, force_refresh=True), token) @@ -134,7 +134,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict: except HTTPException: raise except jwt.PyJWTError as exc: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {exc}") from exc + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid access token") from exc subject = claims.get("sub") if not isinstance(subject, str) or not subject.strip(): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token has no subject") diff --git a/backend/app/demo_scope.py b/backend/app/demo_scope.py index 6d25bee24..964d89cd6 100644 --- a/backend/app/demo_scope.py +++ b/backend/app/demo_scope.py @@ -4,7 +4,7 @@ something to show. Once an account can see at least one post carrying real source-import evidence, the synthetic Demo Corp tree is no longer needed to fill an empty screen and must stop appearing next to real evidence -- a -buyer must never mistake a fabricated contact (e.g. Ada West, Priya Nair) +reader must never mistake a fabricated contact (e.g. Ada West, Priya Nair) for a real one. """ @@ -40,6 +40,10 @@ async def has_real_source_context( or nullif(btrim(source_post.source_process_unit_name), '') is not null or nullif(btrim(source_post.source_sales_pool_code), '') is not null or nullif(btrim(source_post.source_sales_pool_name), '') is not null + or nullif(btrim(source_post.source_order_pool_code), '') is not null + or nullif(btrim(source_post.source_sales_order_code), '') is not null + or source_post.source_sales_order_item_number is not null + or nullif(btrim(source_post.source_inspection_point_code), '') is not null or nullif(btrim(source_post.source_customer_code), '') is not null or nullif(btrim(source_post.source_customer_name), '') is not null or nullif(btrim(source_post.source_project_code), '') is not null @@ -72,6 +76,10 @@ async def fetch_demo_corporate_entity_ids(conn: asyncpg.Connection) -> set[str]: or nullif(btrim(real_post.source_process_unit_name), '') is not null or nullif(btrim(real_post.source_sales_pool_code), '') is not null or nullif(btrim(real_post.source_sales_pool_name), '') is not null + or nullif(btrim(real_post.source_order_pool_code), '') is not null + or nullif(btrim(real_post.source_sales_order_code), '') is not null + or real_post.source_sales_order_item_number is not null + or nullif(btrim(real_post.source_inspection_point_code), '') is not null or nullif(btrim(real_post.source_customer_code), '') is not null or nullif(btrim(real_post.source_customer_name), '') is not null or nullif(btrim(real_post.source_project_code), '') is not null diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index da22a9136..815628ef0 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -128,11 +128,11 @@ async def fetch_relationship_network( can be a customer in one post, a competitor in another (their own product line competes with ours elsewhere), the customer of our customer in a third, or a supplier -- Customer Master's per-post - reads never rolled these up, so buyers could only see one role at + reads never rolled these up, so readers could only see one role at a time and never the entity's whole network. This groups every visible, eligible post's classifications by counterparty name, keeping every distinct relationship type observed (not just the - most frequent), so a buyer can see a name marked both Customer and + most frequent), so a reader can see a name marked both Customer and Competitor and know that reflects the real, mixed relationship rather than a classification error. diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py index 736a8ecd6..99ab0766f 100644 --- a/backend/app/five_w1h_ingestion.py +++ b/backend/app/five_w1h_ingestion.py @@ -35,7 +35,8 @@ async def load_five_w1h_slots( linked_titles: list[str] = [] if candidate_ids: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + "select post_id, post_title, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code " "from source_post where post_id = any($1::uuid[])", candidate_ids, ) diff --git a/backend/app/global_ask_history.py b/backend/app/global_ask_history.py new file mode 100644 index 000000000..61ec881ab --- /dev/null +++ b/backend/app/global_ask_history.py @@ -0,0 +1,350 @@ +"""Account-owned persistence for the Global Ask transcript.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg + + +class GlobalAskConversationNotFound(LookupError): + """The requested conversation is absent or owned by another account.""" + + +def conversation_title(question: str) -> str: + """Use the first question as a bounded, readable transcript label.""" + compact = " ".join(question.strip().split()) + return compact[:80] or "New conversation" + + +async def conversation_exists( + conn: asyncpg.Connection, user_account_id: str, conversation_id: UUID +) -> bool: + return bool( + await conn.fetchval( + "select exists(select 1 from global_ask_session where global_ask_session_id = $1 and user_account_id = $2)", + conversation_id, + user_account_id, + ) + ) + + +async def list_conversations( + conn: asyncpg.Connection, + user_account_id: str, + *, + limit: int = 50, + before_updated_at: datetime | None = None, + before_conversation_id: UUID | None = None, +) -> dict[str, Any]: + cursor_clause = "" + arguments: list[Any] = [user_account_id] + if before_updated_at is not None and before_conversation_id is not None: + cursor_clause = """ + and ( + session.updated_at < $2 + or (session.updated_at = $2 and session.global_ask_session_id < $3) + ) + """ + arguments.extend([before_updated_at, before_conversation_id]) + arguments.append(limit + 1) + limit_placeholder = f"${len(arguments)}" + # Safe SQL: cursor_clause is one of two hardcoded literals and + # limit_placeholder is a computed positional index ($N); no request + # value is ever interpolated, all bound below. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select session.global_ask_session_id, + coalesce( + (select left(turn.question_text, 80) + from global_ask_turn turn + where turn.global_ask_session_id = session.global_ask_session_id + order by turn.turn_ordinal + limit 1), + 'New conversation' + ) as conversation_title, + session.updated_at, + count(turn.turn_ordinal)::int as turn_count + from global_ask_session session + left join global_ask_turn turn + on turn.global_ask_session_id = session.global_ask_session_id + where session.user_account_id = $1 + {cursor_clause} + group by session.global_ask_session_id, session.updated_at + order by session.updated_at desc, session.global_ask_session_id desc + limit {limit_placeholder} + """, + *arguments, + ) + page_rows = rows[:limit] + next_cursor = None + if len(rows) > limit and page_rows: + last = page_rows[-1] + next_cursor = { + "updated_at": last["updated_at"], + "conversation_id": str(last["global_ask_session_id"]), + } + return { + "conversations": [ + { + "conversation_id": str(row["global_ask_session_id"]), + "title": row["conversation_title"], + "updated_at": row["updated_at"], + "turn_count": row["turn_count"], + } + for row in page_rows + ], + "next_cursor": next_cursor, + } + + +async def _visible_post_ids( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinal: int, + can_see_post: Callable[[asyncpg.Record], bool], + *, + source: bool, +) -> tuple[list[str], dict[str, asyncpg.Record]]: + table = "global_ask_turn_source" if source else "global_ask_turn_citation" + id_column = "source_post_id" if source else "cited_post_id" + ordinal_column = "source_ordinal" if source else "citation_ordinal" + # Safe SQL: table/id_column/ordinal_column are each one of two hardcoded + # literals selected by the boolean `source` argument, not request input. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.{id_column}::text as post_id, relation.{ordinal_column} as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from {table} relation + join source_post post on post.post_id = relation.{id_column} + where relation.global_ask_session_id = $1 + and relation.turn_ordinal = $2 + order by relation.{ordinal_column} + """, + conversation_id, + turn_ordinal, + ) + visible = [row for row in rows if can_see_post(row)] + return [str(row["post_id"]) for row in visible], {str(row["post_id"]): row for row in visible} + + +async def fetch_conversation( + conn: asyncpg.Connection, + user_account_id: str, + conversation_id: UUID, + can_see_post: Callable[[asyncpg.Record], bool], + *, + turn_limit: int = 50, + before_turn_ordinal: int | None = None, +) -> dict[str, Any] | None: + header = await conn.fetchrow( + """ + select global_ask_session_id + from global_ask_session + where global_ask_session_id = $1 and user_account_id = $2 + """, + conversation_id, + user_account_id, + ) + if header is None: + return None + + title_question = await conn.fetchval( + """ + select question_text + from global_ask_turn + where global_ask_session_id = $1 + order by turn_ordinal + limit 1 + """, + conversation_id, + ) + if before_turn_ordinal is None: + # Safe SQL: fully parameterized, no interpolation of any kind. + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text, next_action + from global_ask_turn + where global_ask_session_id = $1 + order by turn_ordinal desc + limit $2 + """, + conversation_id, + turn_limit + 1, + ) + else: + # Safe SQL: fully parameterized, no interpolation of any kind. + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text, next_action + from global_ask_turn + where global_ask_session_id = $1 + and turn_ordinal < $2 + order by turn_ordinal desc + limit $3 + """, + conversation_id, + before_turn_ordinal, + turn_limit + 1, + ) + has_older = len(turns) > turn_limit + turns = list(turns[:turn_limit]) + turns.reverse() + exchanges: list[dict[str, Any]] = [] + for turn in turns: + ordinal = int(turn["turn_ordinal"]) + source_ids, _ = await _visible_post_ids( + conn, conversation_id, ordinal, can_see_post, source=True + ) + cited_ids, cited_rows = await _visible_post_ids( + conn, conversation_id, ordinal, can_see_post, source=False + ) + evidence_rows = await conn.fetch( + """ + select cited_post_id::text as post_id, fact_kind, fact_text + from global_ask_turn_evidence + where global_ask_session_id = $1 and turn_ordinal = $2 + order by cited_post_id, fact_ordinal + """, + conversation_id, + ordinal, + ) + evidence: dict[str, list[dict[str, str]]] = {} + for row in evidence_rows: + post_id = str(row["post_id"]) + if post_id in cited_rows: + evidence.setdefault(post_id, []).append( + {"kind": row["fact_kind"], "text": row["fact_text"]} + ) + exchanges.append( + { + "turn_id": f"{conversation_id}:{ordinal}", + "question_text": turn["question_text"], + "answer_text": turn["answer_text"], + "cited_post_ids": cited_ids, + "cited_posts": [ + {"post_id": post_id, "post_title": cited_rows[post_id]["post_title"]} + for post_id in cited_ids + ], + "cited_post_evidence": [ + {"post_id": post_id, "facts": evidence[post_id]} + for post_id in cited_ids + if evidence.get(post_id) + ], + "source_post_ids": source_ids, + "next_action": turn["next_action"], + } + ) + title = conversation_title(title_question) if title_question else "New conversation" + return { + "conversation_id": str(header["global_ask_session_id"]), + "title": title, + "exchanges": exchanges, + "older_cursor": str(turns[0]["turn_ordinal"]) if has_older and turns else None, + } + + +async def persist_turn( + conn: asyncpg.Connection, + user_account_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + next_action: str | None, + source_post_ids: Iterable[str], + cited_post_ids: Iterable[str], + cited_post_evidence: Iterable[dict[str, Any]], +) -> UUID: + source_ids = list(dict.fromkeys(str(post_id) for post_id in source_post_ids)) + source_set = set(source_ids) + cited_ids = list(dict.fromkeys(str(post_id) for post_id in cited_post_ids if str(post_id) in source_set)) + evidence_by_post = { + str(item["post_id"]): item.get("facts") or [] + for item in cited_post_evidence + if str(item["post_id"]) in cited_ids + } + async with conn.transaction(): + if conversation_id is None: + conversation_id = uuid4() + await conn.execute( + "insert into global_ask_session (global_ask_session_id, user_account_id) values ($1, $2)", + conversation_id, + user_account_id, + ) + else: + conversation = await conn.fetchrow( + """ + select global_ask_session_id + from global_ask_session + where global_ask_session_id = $1 and user_account_id = $2 + for update + """, + conversation_id, + user_account_id, + ) + if conversation is None: + raise GlobalAskConversationNotFound + + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from global_ask_turn where global_ask_session_id = $1", + conversation_id, + ) + ) + await conn.execute( + """ + insert into global_ask_turn + (global_ask_session_id, turn_ordinal, question_text, answer_text, next_action) + values ($1, $2, $3, $4, $5) + """, + conversation_id, + ordinal, + question, + answer_text, + next_action, + ) + for source_ordinal, post_id in enumerate(source_ids): + await conn.execute( + "insert into global_ask_turn_source (global_ask_session_id, turn_ordinal, source_ordinal, source_post_id) values ($1, $2, $3, $4)", + conversation_id, + ordinal, + source_ordinal, + post_id, + ) + for citation_ordinal, post_id in enumerate(cited_ids): + await conn.execute( + "insert into global_ask_turn_citation (global_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) values ($1, $2, $3, $4)", + conversation_id, + ordinal, + citation_ordinal, + post_id, + ) + for fact_ordinal, fact in enumerate(evidence_by_post.get(post_id, ())): + fact_kind = str(fact.get("kind", "source_field")) + fact_text = str(fact.get("text", "")).strip() + if not fact_text: + continue + await conn.execute( + """ + insert into global_ask_turn_evidence + (global_ask_session_id, turn_ordinal, cited_post_id, + fact_ordinal, fact_kind, fact_text) + values ($1, $2, $3, $4, $5, $6) + """, + conversation_id, + ordinal, + post_id, + fact_ordinal, + fact_kind, + fact_text, + ) + await conn.execute( + "update global_ask_session set updated_at = now() where global_ask_session_id = $1", + conversation_id, + ) + assert conversation_id is not None + return conversation_id diff --git a/backend/app/issue_ticket_ingestion.py b/backend/app/issue_ticket_ingestion.py index 2cb3fe01d..4831322be 100644 --- a/backend/app/issue_ticket_ingestion.py +++ b/backend/app/issue_ticket_ingestion.py @@ -139,6 +139,7 @@ async def fetch_upcoming_commitments(conn: asyncpg.Connection) -> list[dict[str, "issue_ticket.commitment_summary, issue_ticket.created_at, " "issue_ticket.updated_at, " "p.post_title, p.visibility_code, p.corporate_entity_id, " + "p.author_account_id, p.source_detail_state_code, " f"({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context " "from issue_ticket " "join source_post p on p.post_id = issue_ticket.post_id " @@ -154,6 +155,8 @@ async def fetch_upcoming_commitments(conn: asyncpg.Connection) -> list[dict[str, "post_title": row["post_title"], "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), } for row in rows @@ -173,7 +176,7 @@ async def upsert_commitment_ticket( Re-deriving the same post must not stack duplicate calendar rows -- an existing open ticket with a commitment_summary is updated in place. - A closed ticket is left alone so the buyer can keep the historical + A closed ticket is left alone so the reader can keep the historical record and still derive a fresh open one. """ existing = await conn.fetchrow( diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 71304ce92..f8fc38915 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -8,13 +8,13 @@ from __future__ import annotations +import hashlib from typing import Any from uuid import UUID import asyncpg from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from lineageweave.ontology import ontology_annotations from lineageweave.knowledge_graph import ( EDGE_AFFILIATION, EDGE_CO_MENTION, @@ -34,10 +34,36 @@ random_walk_with_restart, select_related_nodes, ) - +from lineageweave.ontology import ontology_annotations, semantic_predicate_annotations +from lineageweave.source_lineage_hints import source_lineage_hints _GRAPH_PROJECTION_LOCK_KEY = "lineageweave:knowledge_graph_projection" +_SEMANTIC_NODE_CLASS_IRIS = { + "person": "http://www.w3.org/ns/prov#Person", + "organization": "http://www.w3.org/ns/prov#Organization", + "team": "http://www.w3.org/ns/org#OrganizationalUnit", + "software_agent": "http://www.w3.org/ns/prov#SoftwareAgent", + "project": "https://contextualwisdomlab.github.io/lineageweave/ontology#Project", + "corporate_entity": "https://contextualwisdomlab.github.io/lineageweave/ontology#CorporateEntity", + "post": "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + "event": "https://contextualwisdomlab.github.io/lineageweave/ontology#Event", + "event_observation": "https://contextualwisdomlab.github.io/lineageweave/ontology#EventObservation", + "evidence_clue": "https://contextualwisdomlab.github.io/lineageweave/ontology#EvidenceClue", + "place": "https://contextualwisdomlab.github.io/lineageweave/ontology#Place", + "industrial_asset": "https://contextualwisdomlab.github.io/lineageweave/ontology#IndustrialAsset", + "industrial_process": "https://contextualwisdomlab.github.io/lineageweave/ontology#IndustrialProcess", + "document": "https://contextualwisdomlab.github.io/lineageweave/ontology#Document", + "observation": "https://contextualwisdomlab.github.io/lineageweave/ontology#Observation", + "activity": "https://contextualwisdomlab.github.io/lineageweave/ontology#Activity", + "temporal_entity": "https://contextualwisdomlab.github.io/lineageweave/ontology#TemporalEntity", + "normative_statement": "https://contextualwisdomlab.github.io/lineageweave/ontology#NormativeStatement", + "quality_assessment": "https://contextualwisdomlab.github.io/lineageweave/ontology#QualityAssessment", + "risk_statement": "https://contextualwisdomlab.github.io/lineageweave/ontology#RiskStatement", + "organization_context": "https://contextualwisdomlab.github.io/lineageweave/ontology#OrganizationContext", + "source_observation": "https://contextualwisdomlab.github.io/lineageweave/ontology#Observation", +} + def edge_spec_from_row(row: asyncpg.Record) -> KnowledgeGraphEdgeSpec: """Map one ``knowledge_graph_edge`` row onto the library spec.""" @@ -259,7 +285,8 @@ async def visible_mention_post_ids( # Safe SQL: the eligibility predicate is an immutable schema fragment; person id is bound. rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select post.post_id, post.visibility_code, post.corporate_entity_id + select post.post_id, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code from combined_post_person_mention mention join source_post post on post.post_id = mention.post_id where mention.person_id = $1 @@ -280,7 +307,8 @@ async def visible_affiliation_post_ids( rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" select distinct post.post_id, post.visibility_code, - post.corporate_entity_id, post.created_at + post.corporate_entity_id, post.author_account_id, + post.source_detail_state_code, post.created_at from source_post post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} and post.post_id in ( @@ -310,7 +338,8 @@ async def visible_team_mention_post_ids( # Safe SQL: the eligibility predicate is an immutable schema fragment; team id is bound. rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select post.post_id, post.visibility_code, post.corporate_entity_id + select post.post_id, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code from post_team_mention mention join source_post post on post.post_id = mention.post_id where mention.team_id = $1 @@ -539,6 +568,192 @@ async def hydrate_related_nodes( return payload +async def post_knowledge_graph( + conn: asyncpg.Connection, + post_id: str, + *, + relation_limit: int = 64, +) -> dict[str, Any]: + """Return an evidence-scoped KG view for one authorized post. + + Compact catalog edges come from ``knowledge_graph_edge``. Semantic + relations stay normalized and are projected into post-scoped text nodes; + this preserves unresolved-name uncertainty while making the extracted + relation drawable. + """ + catalog_edges = await load_visible_subgraph(conn, [post_id]) + endpoint_keys = {node_key(NODE_POST, post_id)} + endpoint_keys.update( + node_key(edge.source_node_type_code, edge.source_node_id) for edge in catalog_edges + ) + endpoint_keys.update( + node_key(edge.target_node_type_code, edge.target_node_id) for edge in catalog_edges + ) + catalog_nodes = await hydrate_related_nodes( + conn, [(key, 1.0 if key == node_key(NODE_POST, post_id) else 0.0) for key in endpoint_keys] + ) + nodes: dict[str, dict[str, Any]] = { + node_key(item["node_type_code"], item["node_id"]): { + "id": node_key(item["node_type_code"], item["node_id"]), + "node_type_code": item["node_type_code"], + "node_id": item["node_id"], + "label": item["label"], + "ontology_iri": item.get("ontology_iri"), + "ontology_label": item.get("ontology_label"), + "is_focus": item["node_type_code"] == NODE_POST and item["node_id"] == post_id, + } + for item in catalog_nodes + } + edges: list[dict[str, Any]] = [] + for edge in catalog_edges[:relation_limit]: + source = node_key(edge.source_node_type_code, edge.source_node_id) + target = node_key(edge.target_node_type_code, edge.target_node_id) + annotation = ontology_annotations(edge.edge_type_code) + edges.append( + { + "source": source, + "target": target, + "edge_type_code": edge.edge_type_code, + "ontology_iri": annotation.get("ontology_iri"), + "ontology_label": annotation.get("ontology_label", edge.edge_type_code), + "confidence": edge.edge_weight, + "evidence_post_ids": [post_id], + } + ) + + relation_rows = await conn.fetch( + """ + select relation_ordinal, subject_name, subject_type, predicate_code, + object_name, object_type, evidence_text, relation_confidence + from post_summary_semantic_relationship + where post_id = $1 + order by relation_ordinal + limit ($2 + 1) + """, + post_id, + relation_limit, + ) + + def semantic_key(node_type: str, name: str) -> str: + digest = hashlib.sha256(f"{node_type}\0{name}".encode()).hexdigest()[:16] + return f"semantic:{post_id}:{digest}" + + for row in relation_rows[:relation_limit]: + source = semantic_key(row["subject_type"], row["subject_name"]) + target = semantic_key(row["object_type"], row["object_name"]) + for key, node_type, name in ( + (source, row["subject_type"], row["subject_name"]), + (target, row["object_type"], row["object_name"]), + ): + nodes.setdefault( + key, + { + "id": key, + "node_type_code": f"semantic_{node_type}", + "node_id": key, + "label": name, + "ontology_iri": _SEMANTIC_NODE_CLASS_IRIS[node_type], + "ontology_label": node_type, + "is_focus": False, + "is_evidence_text_node": True, + }, + ) + annotation = semantic_predicate_annotations(row["predicate_code"]) + edges.append( + { + "source": source, + "target": target, + "edge_type_code": row["predicate_code"], + "ontology_iri": annotation.get("ontology_iri"), + "ontology_label": annotation.get("ontology_label", row["predicate_code"]), + "confidence": float(row["relation_confidence"]), + "evidence_text": row["evidence_text"], + "evidence_post_ids": [post_id], + } + ) + + source_row = await conn.fetchrow( + """ + select source_customer_code, source_order_pool_code, + source_sales_order_code, source_sales_order_item_number, + source_stage_code, source_detail_state_code, + source_inspection_point_code, source_deleted_flag + from source_post + where post_id = $1 + """, + post_id, + ) + if source_row is not None: + source_values = dict(source_row) + observed_source_fields = any( + value is not None and (not isinstance(value, str) or bool(value.strip())) + for value in source_values.values() + ) + if observed_source_fields: + source_hints = source_lineage_hints( + customer_code=source_row["source_customer_code"], + order_pool_code=source_row["source_order_pool_code"], + sales_order_code=source_row["source_sales_order_code"], + sales_order_item_number=source_row["source_sales_order_item_number"], + stage_code=source_row["source_stage_code"], + detail_state_code=source_row["source_detail_state_code"], + inspection_point_code=source_row["source_inspection_point_code"], + deleted_flag=source_row["source_deleted_flag"], + ) + source_observations = [ + ( + "commercial_context", + f"Commercial context: {source_hints['commercial_context_code']}", + "combination=" + f"{source_hints['combination_code']}; " + f"present_fields={','.join(source_hints['present_fields']) or 'none'}; " + "inference=inferred; provenance=source_post.field_presence", + ), + ( + "lifecycle_vector", + f"Lifecycle vector: {source_hints['lifecycle_vector']}", + "raw_codes_only; provenance=source_post.lifecycle_fields", + ), + ] + predicate = semantic_predicate_annotations("prov_was_derived_from") + for kind, label, evidence_text in source_observations: + digest = hashlib.sha256( + f"{post_id}\0{kind}\0{label}".encode() + ).hexdigest()[:16] + observation_key = f"source-observation:{post_id}:{digest}" + nodes.setdefault( + observation_key, + { + "id": observation_key, + "node_type_code": "semantic_source_observation", + "node_id": observation_key, + "label": label, + "ontology_iri": _SEMANTIC_NODE_CLASS_IRIS["source_observation"], + "ontology_label": "Observation", + "is_focus": False, + "is_evidence_text_node": True, + }, + ) + edges.append( + { + "source": observation_key, + "target": node_key(NODE_POST, post_id), + "edge_type_code": "prov_was_derived_from", + "ontology_iri": predicate.get("ontology_iri"), + "ontology_label": predicate.get("ontology_label", "Was derived from"), + "confidence": 1.0, + "evidence_text": evidence_text, + "evidence_post_ids": [post_id], + } + ) + return { + "post_id": post_id, + "nodes": list(nodes.values()), + "edges": edges, + "truncated": len(catalog_edges) > relation_limit or len(relation_rows) > relation_limit, + } + + async def related_for_start( conn: asyncpg.Connection, node_type_code: str, @@ -576,7 +791,7 @@ async def fetch_person_role_history( responsibility, in posts at different times (a job change, a title change, a move between projects). ``post_summary_role`` already carries this per post; this simply orders it chronologically for - one person instead of leaving a buyer to open every post that + one person instead of leaving a reader to open every post that mentions them and compare manually. ``visible_post_ids`` must already be ABAC-filtered by the caller @@ -590,7 +805,7 @@ async def fetch_person_role_history( rows = await conn.fetch( """ select role.post_id, post.post_title, post.created_at, - role.responsibility, role.affiliated_organization_name + role.responsibility_text, role.affiliated_organization_name from post_summary_role role join source_post post on post.post_id = role.post_id where role.cataloged_person_id = $1 @@ -605,7 +820,7 @@ async def fetch_person_role_history( "post_id": str(row["post_id"]), "post_title": row["post_title"], "created_at": row["created_at"].isoformat(), - "responsibility": row["responsibility"], + "responsibility": row["responsibility_text"], "affiliated_organization_name": row["affiliated_organization_name"], } for row in rows diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index f1e76d495..21b8e88d7 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -98,7 +98,8 @@ async def visible_lineage_graph( """ posts = await conn.fetch( "select post_id, post_title, voc_type_code, visibility_code, " - "corporate_entity_id, process_unit_id, thread_group_key, created_at " + "corporate_entity_id, author_account_id, source_detail_state_code, " + "process_unit_id, thread_group_key, created_at " f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}" ) visible_all = [row for row in posts if can_see_post(row)] diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..e03aa6ef8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,9 @@ ABAC gate, evaluated per row on top of the RBAC gate: a source_post is visible if it is public, or if it is private and the requesting account is -affiliated with the post's owning corporate_entity_id. abac_policy's +affiliated with the post's owning corporate_entity_id. A W source-detail row +is raw-source-visible only to its author or post_admin; derived readers use a +separate eligibility boundary that excludes W. abac_policy's condition_expression column is reserved for a future, richer per-policy DSL (documented in migrations/0001_initial_schema.sql); Phase 1 implements exactly this one fixed rule directly, since it is the only rule the @@ -85,6 +87,7 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints +from lineageweave.source_lineage_hints import source_lineage_hints from lineageweave.ontology import LW from lineageweave.rankweave_client import build_rankweave_client @@ -104,8 +107,11 @@ from backend.app.analysis_run_worker import run_analysis_run_worker from backend.app.post_content_queue import ( ensure_post_content_job, + fetch_post_summary_source, post_content_api_status, post_content_is_complete, + post_content_summary_is_ready, + post_body_has_images, publish_post_content_event, ) from backend.app.post_content_worker import run_post_content_worker @@ -129,6 +135,13 @@ ingest_post_entity_relationships, ) from backend.app.five_w1h_ingestion import load_five_w1h_slots +from backend.app.global_ask_history import ( + GlobalAskConversationNotFound, + conversation_exists, + fetch_conversation, + list_conversations, + persist_turn, +) from backend.app.post_evaluation_ingestion import fetch_post_evaluation, ingest_post_evaluation from backend.app.ranking_ingestion import load_visible_ranking_posts from backend.app.report_ingestion import ( @@ -157,6 +170,7 @@ labels_for_codes, person_exists, persist_edges_for_post, + post_knowledge_graph, related_for_entity, related_for_person, related_for_team, @@ -179,11 +193,17 @@ persist_post_summary, require_summary_source_body, ) -from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from backend.app.post_eligibility import ( + SOURCE_POST_ELIGIBILITY_SQL, + SOURCE_POST_READER_ELIGIBILITY_SQL, + SOURCE_POST_VISIBILITY_SQL, + WRITING_SOURCE_DETAIL_STATE_CODE, + normalize_source_detail_state_code, + source_post_state_visibility_sql, +) from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, has_real_source_context, - is_demo_scope, ) from lineageweave.http_client import HttpClientError @@ -414,12 +434,24 @@ def _rankweave_client(): def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: - """ABAC: public rows are visible; private rows require same-corp affiliation.""" + """ABAC: W is author/admin-only; other rows use public/corp visibility.""" + state_code = normalize_source_detail_state_code(post.get("source_detail_state_code")) + if state_code == WRITING_SOURCE_DETAIL_STATE_CODE: + return account.has_permission(_POST_ADMIN) or str(post["author_account_id"]) == account.user_account_id if post["visibility_code"] == "public": return True return str(post["corporate_entity_id"]) in account.corporate_entity_ids +def _can_use_post_for_analysis(account: CurrentAccount, post: asyncpg.Record) -> bool: + """Derived features consume only non-W authorized source posts.""" + return ( + normalize_source_detail_state_code(post.get("source_detail_state_code")) + != WRITING_SOURCE_DETAIL_STATE_CODE + and _can_see_post(account, post) + ) + + def _is_synthetic_demo_member(member: dict[str, Any], demo_entity_ids: set[str]) -> bool: """Identify one pure seed row without hiding real rows sharing its entity.""" return bool(demo_entity_ids) and member["corporate_entity_id"] in demo_entity_ids and not bool( @@ -443,7 +475,9 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) "visibility_code": visibility, "visibility_label": resolved.get(visibility, visibility), "source_stage_code": post.get("source_stage_code"), - "source_detail_state_code": post.get("source_detail_state_code"), + "source_detail_state_code": normalize_source_detail_state_code( + post.get("source_detail_state_code") + ), "source_draft_code": post.get("source_draft_code"), "source_deleted_flag": post.get("source_deleted_flag"), "publication_state_code": _publication_state_code(post), @@ -453,14 +487,29 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) "source_company_name": post.get("source_company_name"), "source_process_unit_code": post.get("source_process_unit_code"), "source_process_unit_name": post.get("source_process_unit_name"), + "source_process_unit_catalog_name": post.get("source_process_unit_catalog_name"), "source_sales_pool_code": post.get("source_sales_pool_code"), "source_sales_pool_name": post.get("source_sales_pool_name"), + "source_order_pool_code": post.get("source_order_pool_code"), + "source_sales_order_code": post.get("source_sales_order_code"), + "source_sales_order_item_number": post.get("source_sales_order_item_number"), + "source_inspection_point_code": post.get("source_inspection_point_code"), "source_customer_code": post.get("source_customer_code"), "source_customer_name": post.get("source_customer_name"), "source_project_code": post.get("source_project_code"), "source_project_name": post.get("source_project_name"), "source_system_code": post.get("source_system_code"), "source_record_key": post.get("source_record_key"), + "source_lineage_hints": source_lineage_hints( + customer_code=post.get("source_customer_code"), + order_pool_code=post.get("source_order_pool_code"), + sales_order_code=post.get("source_sales_order_code"), + sales_order_item_number=post.get("source_sales_order_item_number"), + stage_code=post.get("source_stage_code"), + detail_state_code=post.get("source_detail_state_code"), + inspection_point_code=post.get("source_inspection_point_code"), + deleted_flag=post.get("source_deleted_flag"), + ), "post_body_excerpt": post.get("post_body_excerpt"), "post_body_truncated": post.get("post_body_truncated", False), "project_evidence": project_evidence, @@ -508,11 +557,11 @@ async def _load_project_evidence( ) rows = await conn.fetch( """ - select project_key, project_name, evidence_text, confidence, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = $1 - order by confidence desc, project_name, project_key + order by mention_confidence desc, project_name, project_key """, post_id, ) @@ -521,7 +570,7 @@ async def _load_project_evidence( "project_key": row["project_key"], "project_name": row["project_name"], "evidence": row["evidence_text"], - "confidence": float(row["confidence"]), + "confidence": float(row["mention_confidence"]), "ontology_iri": row["ontology_iri"], "ontology_label": "Project", "extraction_method": row["extraction_method"], @@ -540,9 +589,12 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor async def _post_filter_options( - conn: asyncpg.Connection, corporate_entity_ids: frozenset[str] -) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + conn: asyncpg.Connection, account: CurrentAccount +) -> tuple[list[dict[str, str]], list[dict[str, str]], list[dict[str, str]]]: """Return every authorized filter value, not only values on the current page.""" + state_visibility_sql = source_post_state_visibility_sql( + "post", corporate_param=1, account_param=2, admin_param=3 + ) visibility_sql = f""" select distinct post.visibility_code as code, coalesce(lookup.lookup_label, post.visibility_code) as label, @@ -551,9 +603,8 @@ async def _post_filter_options( left join common_lookup_value lookup on lookup.lookup_category = 'post_visibility' and lookup.lookup_code = post.visibility_code - where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($1::text[])) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ type_sql = f""" @@ -564,34 +615,58 @@ async def _post_filter_options( left join common_lookup_value lookup on lookup.lookup_category = 'voc_type' and lookup.lookup_code = post.voc_type_code - where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($1::text[])) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ + detail_state_sql = f""" + select distinct upper(btrim(post.source_detail_state_code)) as code, + upper(btrim(post.source_detail_state_code)) as label + from source_post post + where {state_visibility_sql} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')} + and nullif(btrim(post.source_detail_state_code), '') is not null + order by code + """ # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - visibility_sql, list(corporate_entity_ids) + visibility_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), ) # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. type_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - type_sql, list(corporate_entity_ids) + type_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), + ) + # Detail-state codes intentionally remain raw here; the UI owns the + # product-language explanation and preserves unknown source codes. + # Safe SQL: a closed lookup statement identical in shape to visibility_sql/type_sql above; entity ids remain asyncpg parameters. + detail_state_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + detail_state_sql, + list(account.corporate_entity_ids), + account.user_account_id, + account.has_permission(_POST_ADMIN), ) return ( [{"code": row["code"], "label": row["label"]} for row in type_rows], + [{"code": row["code"], "label": row["label"]} for row in detail_state_rows], [{"code": row["code"], "label": row["label"]} for row in visibility_rows], ) -@app.get("/healthz") - @app.get("/api/settings", response_model=dict) async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): async with pool.acquire() as conn: - row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") + row = await conn.fetchrow( + "SELECT brand_name FROM tenant_settings WHERE tenant_settings_id = 1" + ) if not row: return {"brandName": "LineageWeave"} return {"brandName": row["brand_name"]} @@ -607,13 +682,14 @@ async def update_tenant_settings( brand_name = payload.get("brandName", "LineageWeave") async with pool.acquire() as conn: await conn.execute( - "INSERT INTO tenant_settings (id, brand_name) VALUES (1, $1) " - "ON CONFLICT (id) DO UPDATE SET brand_name = $1", + "INSERT INTO tenant_settings (tenant_settings_id, brand_name) VALUES (1, $1) " + "ON CONFLICT (tenant_settings_id) DO UPDATE SET brand_name = $1", brand_name ) return {"brandName": brand_name} +@app.get("/healthz") async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -630,30 +706,59 @@ async def read_me( ``POST /api/analysis-runs`` should cover. """ entities: list[dict[str, str]] = [] + account_affiliations: list[dict[str, Any]] = [] if account.corporate_entity_ids: async with pool.acquire() as conn: rows = await conn.fetch( """ - select corporate_entity_id, entity_name - from corporate_entity - where corporate_entity_id = any($1::uuid[]) - order by entity_name + select affiliation.corporate_entity_id, + entity.corporate_entity_code, + entity.entity_name, + affiliation.process_unit_id, + 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 + and process.corporate_entity_id = affiliation.corporate_entity_id + where affiliation.user_account_id = $1 + and affiliation.corporate_entity_id = any($2::uuid[]) + order by entity.entity_name, process.process_unit_code nulls first """, + account.user_account_id, list(account.corporate_entity_ids), ) - entities = [ - { - "corporate_entity_id": str(row["corporate_entity_id"]), - "entity_name": row["entity_name"], - } - for row in rows - ] + seen_entities: set[str] = set() + for row in rows: + entity_id = str(row["corporate_entity_id"]) + if entity_id not in seen_entities: + entities.append( + { + "corporate_entity_id": entity_id, + "corporate_entity_code": row["corporate_entity_code"], + "entity_name": row["entity_name"], + } + ) + seen_entities.add(entity_id) + account_affiliations.append( + { + "corporate_entity_id": entity_id, + "corporate_entity_code": row["corporate_entity_code"], + "entity_name": row["entity_name"], + "process_unit_id": str(row["process_unit_id"]) if row["process_unit_id"] else None, + "process_unit_code": row["process_unit_code"], + "process_unit_name": row["process_unit_name"], + } + ) return { "user_account_id": account.user_account_id, "display_name": account.display_name, "preferred_locale": account.preferred_locale, "permission_codes": sorted(account.permission_codes), "corporate_entities": entities, + "account_affiliations": account_affiliations, } @@ -681,14 +786,59 @@ async def update_me_preferences( return {"preferred_locale": preference.preferred_locale} +_AFFILIATION_SCOPE_CODE_TO_FACET = { + "scope_own_entity": "authorized_own", + "scope_granted_entity": "authorized_granted", + # scope_unclassified contributes no own/granted facet -- the entity is + # still authorized, it is just not labeled either way (ADR 0125). +} + + +def _customer_master_scope_facets( + row: asyncpg.Record, observed_hierarchy_ids: set[str] +) -> list[str]: + """Repeatable, provenance-bearing facets for one Customer Master row.""" + facets = [ + _AFFILIATION_SCOPE_CODE_TO_FACET[code] + for code in row["scope_codes"] + if code in _AFFILIATION_SCOPE_CODE_TO_FACET + ] + if str(row["corporate_entity_id"]) in observed_hierarchy_ids: + facets.append("observed_hierarchy") + if row["is_observed_organization"]: + facets.append("observed_organization") + return facets + + +def _observed_hierarchy_ids(rows: list[asyncpg.Record]) -> set[str]: + """Return authorized ancestors of entities observed in visible posts.""" + rows_by_id = {str(row["corporate_entity_id"]): row for row in rows} + hierarchy_ids: set[str] = set() + for row in rows: + if not row["is_observed_organization"] or row["parent_entity_id"] is None: + continue + parent_id = row["parent_entity_id"] + while parent_id is not None: + parent_key = str(parent_id) + if parent_key in hierarchy_ids: + break + hierarchy_ids.add(parent_key) + parent = rows_by_id.get(parent_key) + parent_id = parent["parent_entity_id"] if parent is not None else None + return hierarchy_ids + + @app.get("/api/customer-master") async def read_customer_master( + hint_code: str | None = Query(default=None, max_length=255), 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: + requested_hint_code = (hint_code or "").strip() or None + authorized_entity_ids = list(account.corporate_entity_ids) + if not authorized_entity_ids: return { "corporate_entities": [], "keymen": [], @@ -711,8 +861,10 @@ async def read_customer_master( from source_post where (nullif(btrim(source_customer_code), '') is not null or nullif(btrim(source_customer_name), '') is not null) - and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[])) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} + and ($2::text is null + or nullif(btrim(source_customer_code), '') = $2::text) + and {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')} + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} ), ranked as ( select scoped.*, row_number() over ( @@ -756,7 +908,8 @@ async def read_customer_master( and related.customer_name_group is not distinct from top_groups.customer_name_group order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name """, - list(account.corporate_entity_ids), + authorized_entity_ids, + requested_hint_code, ) # 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 @@ -895,28 +1048,63 @@ async def read_customer_master( """, list(account.corporate_entity_ids), ) - entity_rows = await conn.fetch( - """ - select corporate_entity_id, corporate_entity_code, entity_name, - entity_level_code, parent_entity_id - from corporate_entity - where corporate_entity_id = any($1::uuid[]) - order by entity_name - """, - list(account.corporate_entity_ids), - ) has_source_context = bool(source_customer_rows or source_author_rows) if not has_source_context: has_source_context = await has_real_source_context( conn, list(account.corporate_entity_ids) ) + synthetic_only_entity_ids: set[str] = set() if has_source_context: synthetic_only_entity_ids = await fetch_demo_corporate_entity_ids(conn) - entity_rows = [ - row - for row in entity_rows - if str(row["corporate_entity_id"]) not in synthetic_only_entity_ids - ] + # ADR 0125: an entity reaches Customer Master either through this + # account's own account_affiliation grants (authorized_own / + # authorized_granted, per its explicit affiliation_scope_code -- an + # unclassified affiliation is still authorized, it just carries no + # own/granted facet) or because it is actually mentioned in a post + # this account may already see (observed_organization). Neither + # path widens access: the observed branch reuses the exact same + # public-or-own-corp/eligibility predicate every other query on + # this endpoint already applies to source_post. + # Safe SQL: the eligibility predicate is an immutable schema fragment; ids are bound. + entity_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with own_affiliation as ( + select corporate_entity_id, + array_agg(distinct affiliation_scope_code) + filter (where affiliation_scope_code is not null) as scope_codes + from account_affiliation + where user_account_id = $2 + and corporate_entity_id = any($1::uuid[]) + group by corporate_entity_id + ), observed as ( + select org_mention.corporate_entity_id + from post_organization_mention org_mention + join source_post post on post.post_id = org_mention.post_id + where (post.visibility_code = 'public' or post.corporate_entity_id = any($1::uuid[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + and not (org_mention.corporate_entity_id = any($3::uuid[])) + group by org_mention.corporate_entity_id + order by count(distinct org_mention.post_id) desc, + org_mention.corporate_entity_id + limit 100 + ) + select entity.corporate_entity_id, entity.corporate_entity_code, entity.entity_name, + entity.entity_level_code, entity.parent_entity_id, + coalesce(own_affiliation.scope_codes, array[]::text[]) as scope_codes, + (observed.corporate_entity_id is not null) as is_observed_organization + from corporate_entity entity + left join own_affiliation on own_affiliation.corporate_entity_id = entity.corporate_entity_id + left join observed on observed.corporate_entity_id = entity.corporate_entity_id + where (own_affiliation.corporate_entity_id is not null + or observed.corporate_entity_id is not null) + and not (entity.corporate_entity_id = any($3::uuid[])) + order by entity.entity_name + """, + list(account.corporate_entity_ids), + account.user_account_id, + list(synthetic_only_entity_ids), + ) + observed_hierarchy_ids = _observed_hierarchy_ids(entity_rows) entity_ids = [row["corporate_entity_id"] for row in entity_rows] source_author_affiliations = await _load_account_affiliation_hints( conn, @@ -931,11 +1119,12 @@ async def read_customer_master( affiliation.affiliated_corporate_entity_id, affiliation.role_title, entity.entity_name - from cataloged_person person - join person_affiliation affiliation on affiliation.person_id = person.person_id - left join corporate_entity entity + from cataloged_person person + join person_affiliation affiliation on affiliation.person_id = person.person_id + left join corporate_entity entity on entity.corporate_entity_id = affiliation.affiliated_corporate_entity_id where affiliation.affiliated_corporate_entity_id = any($1::uuid[]) + and person.person_side_code = 'our_side' order by person.person_name, affiliation.affiliated_organization_name """, entity_ids, @@ -984,6 +1173,7 @@ async def read_customer_master( "parent_entity_id": ( str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None ), + "scope_facets": _customer_master_scope_facets(row, observed_hierarchy_ids), } for row in entity_rows ], @@ -1074,6 +1264,11 @@ async def resolve_customer_master_hint( 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. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Hint resolution is unavailable: the orchestrator or search provider did not respond", + ) from exc if resolution is None: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -1094,7 +1289,7 @@ async def read_lineage_graph( async with pool.acquire() as conn: return await visible_lineage_graph( conn, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), limit=limit, focus_post_id=post_id, ) @@ -1122,6 +1317,7 @@ async def list_posts( offset: int = Query(0, ge=0), search: str | None = Query(None, max_length=200), voc_type: list[str] | None = Query(None, max_length=80), + source_detail_state: list[str] | None = Query(None, max_length=80), visibility: str | None = Query(None, max_length=80), sort: Literal["newest", "oldest", "title"] = Query("newest"), account: CurrentAccount = Depends(get_current_account), @@ -1130,9 +1326,17 @@ async def list_posts( """List authorized posts, with semantic evidence search when requested.""" _require_post_read(account) search_term = search.strip() if search and search.strip() else None + voc_type_codes = ( + [code.strip() for code in voc_type if code.strip()] if voc_type else None + ) or None + source_detail_state_codes = ( + [code.strip().upper() for code in source_detail_state if code.strip()] + if source_detail_state + else None + ) or None async with pool.acquire() as conn: - voc_type_options, visibility_options = await _post_filter_options( - conn, account.corporate_entity_ids + voc_type_options, source_detail_state_options, visibility_options = await _post_filter_options( + conn, account ) body_search_ids: list[str] = [] if search_term: @@ -1141,7 +1345,8 @@ async def list_posts( f""" select post_id from source_post - where {SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")} + where {source_post_state_visibility_sql("source_post", corporate_param=4, account_param=2, admin_param=3)} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias="source_post")} and (lower(left(source_post_search_text(post_body), 16384)) like '%' || lower($1) || '%' or to_tsvector('simple', source_post_search_text(post_body)) @@ -1156,6 +1361,9 @@ async def list_posts( post_id """, search_term, + account.user_account_id, + account.has_permission(_POST_ADMIN), + list(account.corporate_entity_ids), ) body_search_ids = [str(row["post_id"]) for row in body_rows] # Safe SQL: page SQL is a closed schema query; every request value is an asyncpg parameter. @@ -1169,22 +1377,23 @@ async def list_posts( post.source_company_code, post.source_company_name, post.source_process_unit_code, post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, post.source_sales_order_code, + post.source_sales_order_item_number, post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, post.source_project_name, post.source_system_code, post.source_record_key, - post.corporate_entity_id, post.created_at, + post.corporate_entity_id, post.author_account_id, post.created_at, case when $1::text is null then 0 when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0 - when post.post_id = any($5::uuid[]) then 1 + when post.post_id = any($6::uuid[]) then 1 else 2 end as search_priority, count(*) over() as total_count from source_post post - where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($2::text[])) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} + where {source_post_state_visibility_sql("post", corporate_param=2, account_param=10, admin_param=11)} + and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias="post")} and ( $1::text is null or post.post_title ilike '%' || $1 || '%' @@ -1203,6 +1412,10 @@ async def list_posts( post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, @@ -1233,6 +1446,10 @@ async def list_posts( post.source_process_unit_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, post.source_customer_code, post.source_customer_name, post.source_project_code, @@ -1241,7 +1458,7 @@ async def list_posts( ) >= 0.45 ) ) - or post.post_id = any($5::uuid[]) + or post.post_id = any($6::uuid[]) or exists ( select 1 from post_project_mention project where project.post_id = post.post_id @@ -1254,7 +1471,7 @@ async def list_posts( 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 role.responsibility_text 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)) ) @@ -1276,7 +1493,51 @@ async def list_posts( or exists ( select 1 from post_summary_event event where event.post_id = post.post_id - and event.event_text ilike '%' || $1 || '%' + and (event.event_text ilike '%' || $1 || '%' + or coalesce(event.evidence_text, '') ilike '%' || $1 || '%') + ) + or exists ( + select 1 from post_summary_event_clue clue + where clue.post_id = post.post_id + and (clue.clue_text ilike '%' || $1 || '%' + or coalesce(clue.target_text, '') ilike '%' || $1 || '%' + or coalesce(clue.normalized_value_text, '') ilike '%' || $1 || '%' + or clue.evidence_text ilike '%' || $1 || '%') + ) + or exists ( + select 1 from post_summary_five_w1h evidence + where evidence.post_id = post.post_id + and (evidence.value_text ilike '%' || $1 || '%' + or evidence.evidence_text ilike '%' || $1 || '%') + ) + or exists ( + select 1 from post_summary_quantitative_observation observation + where observation.post_id = post.post_id + and (observation.label_text ilike '%' || $1 || '%' + or observation.raw_value_text ilike '%' || $1 || '%' + or observation.evidence_text ilike '%' || $1 || '%' + or observation.qualifier_text ilike '%' || $1 || '%' + or observation.measurement_type_code ilike '%' || $1 || '%' + or observation.unit_code ilike '%' || $1 || '%' + or observation.value_numeric::text ilike '%' || $1 || '%' + or observation.quantity_numeric::text ilike '%' || $1 || '%') + ) + or exists ( + select 1 from post_summary_source_fact fact + where fact.post_id = post.post_id + and (fact.label_text ilike '%' || $1 || '%' + or fact.value_text ilike '%' || $1 || '%' + or coalesce(fact.normalized_value_text, '') ilike '%' || $1 || '%' + or fact.evidence_text ilike '%' || $1 || '%' + or fact.normalized_date::text ilike '%' || $1 || '%') + ) + or exists ( + select 1 from post_summary_semantic_relationship relation + where relation.post_id = post.post_id + and (relation.subject_name ilike '%' || $1 || '%' + or relation.predicate_code ilike '%' || $1 || '%' + or relation.object_name ilike '%' || $1 || '%' + or relation.evidence_text ilike '%' || $1 || '%') ) or exists ( select 1 from corporate_entity customer @@ -1307,19 +1568,20 @@ async def list_posts( ) ) and ($3::text[] is null or post.voc_type_code = any($3::text[])) - and ($4::text is null or post.visibility_code = $4) + and ($4::text[] is null or coalesce(upper(btrim(post.source_detail_state_code)), '') = any($4::text[])) + and ($5::text is null or post.visibility_code = $5) order by search_priority asc, case - when $1::text is not null and post.post_id = any($5::uuid[]) - then array_position($5::uuid[], post.post_id) + when $1::text is not null and post.post_id = any($6::uuid[]) + then array_position($6::uuid[], post.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(post.post_title, '')) end asc, - case when $8::text = 'oldest' then post.created_at end asc, - case when $8::text in ('newest', 'title') then post.created_at end desc, + case when $9::text = 'title' then lower(coalesce(post.post_title, '')) end asc, + case when $9::text = 'oldest' then post.created_at end asc, + case when $9::text in ('newest', 'title') then post.created_at end desc, post.post_id desc - offset $6 - limit $7 + offset $7 + limit $8 ) select page.*, case @@ -1344,21 +1606,21 @@ async def list_posts( 'project_key', project.project_key, 'project_name', project.project_name, 'evidence', project.evidence_text, - 'confidence', project.confidence, + 'confidence', project.mention_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 + order by project.mention_confidence desc, project.project_name, project.project_key ) as project_evidence from ( - select project_key, project_name, evidence_text, confidence, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = page.post_id - order by confidence desc, project_name, project_key + order by mention_confidence desc, project_name, project_key limit 5 ) project ) projects on true @@ -1366,21 +1628,24 @@ async def list_posts( case when $1::text is not null then page.search_priority end asc, case when $1::text is not null and page.search_priority = 1 - then array_position($5::uuid[], page.post_id) + then array_position($6::uuid[], page.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(page.post_title, '')) end asc, - case when $8::text = 'oldest' then page.created_at end asc, - case when $8::text in ('newest', 'title') then page.created_at end desc, + case when $9::text = 'title' then lower(coalesce(page.post_title, '')) end asc, + case when $9::text = 'oldest' then page.created_at end asc, + case when $9::text in ('newest', 'title') then page.created_at end desc, page.post_id desc """, search_term, list(account.corporate_entity_ids), - [code.strip() for code in voc_type if code.strip()] if voc_type else None, + voc_type_codes, + source_detail_state_codes, visibility.strip() if visibility and visibility.strip() else None, body_search_ids, offset, limit, sort, + account.user_account_id, + account.has_permission(_POST_ADMIN), ) visible = [row for row in rows if _can_see_post(account, row)] labels = await _lookup_post_labels(conn, visible) @@ -1391,6 +1656,7 @@ async def list_posts( "limit": limit, "offset": offset, "voc_type_options": voc_type_options, + "source_detail_state_options": source_detail_state_options, "visibility_options": visibility_options, } @@ -1428,11 +1694,18 @@ async def read_post( "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_process_unit.process_unit_name as source_process_unit_catalog_name, " "source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, " + "source_sales_order_item_number, source_inspection_point_code, " "source_customer_code, source_customer_name, source_project_code, source_project_name, " - "source_system_code, source_record_key, " - "corporate_entity_id, created_at " - f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", + "source_system_code, source_record_key, author_account_id, " + "source_post.corporate_entity_id, source_post.created_at " + "from source_post " + "left join process_unit source_process_unit " + "on source_process_unit.process_unit_id = source_post.process_unit_id " + "and source_process_unit.corporate_entity_id = source_post.corporate_entity_id " + f"where source_post.post_id = $1 and {SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) if row is None: @@ -1463,7 +1736,7 @@ async def read_post_content( 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.""" + """Return persisted content evidence; never derive or invent reader-facing copy.""" await _load_visible_post(post_id, account, pool) queue_event: tuple[str, str] | None = None async with pool.acquire() as conn: @@ -1471,7 +1744,7 @@ async def read_post_content( """ 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.decision_source_code, structure.structure_confidence, structure.evidence_text from post_content_unit unit left join post_content_unit_structure structure @@ -1516,7 +1789,7 @@ async def read_post_content( 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, + image.extracted_text, image.image_caption, coalesce( array_agg(tag.tag_text order by tag.tag_text) filter (where tag.tag_text is not null), @@ -1529,7 +1802,7 @@ async def read_post_content( 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 + image.extracted_text, image.image_caption order by unit.unit_index """, post_id, @@ -1538,7 +1811,7 @@ async def read_post_content( """ 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, + region.description_status_code, region.extracted_text, region.image_caption, coalesce( array_agg(tag.tag_text order by tag.tag_text) filter (where tag.tag_text is not null), @@ -1552,7 +1825,7 @@ async def read_post_content( 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 + region.description_status_code, region.extracted_text, region.image_caption order by image.post_content_image_id, region.region_index """, [row["post_content_image_id"] for row in rows], @@ -1574,7 +1847,7 @@ async def read_post_content( "height_ratio": row["height_ratio"], "status_code": row["description_status_code"], "extracted_text": row["extracted_text"], - "caption": row["caption"], + "caption": row["image_caption"], "tags": list(row["tags"] or []), } ) @@ -1588,7 +1861,7 @@ async def read_post_content( "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_confidence": float(row["structure_confidence"] or 0), "indent_evidence": row["evidence_text"] or "", } for row in unit_rows @@ -1599,7 +1872,7 @@ async def read_post_content( "mime_type": row["mime_type"], "status_code": row["description_status_code"], "extracted_text": row["extracted_text"], - "caption": row["caption"], + "caption": row["image_caption"], "tags": list(row["tags"] or []), "regions": regions_by_image.get(str(row["post_content_image_id"]), []), } @@ -1621,6 +1894,7 @@ async def _load_visible_post( """ select source_post.post_id, source_post.post_title, source_post.voc_type_code, source_post.visibility_code, source_post.corporate_entity_id, + source_post.source_detail_state_code, source_post.created_at, source_post.author_account_id, source_post.source_process_unit_code, source_post.source_author_code, source_post.source_company_code, source_post.source_customer_code, @@ -1631,13 +1905,18 @@ async def _load_visible_post( on customer.corporate_entity_id = source_post.corporate_entity_id where source_post.post_id = $1 and """ - f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", + f"{SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found") if not _can_see_post(account, row): raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post") + if normalize_source_detail_state_code(row.get("source_detail_state_code")) == WRITING_SOURCE_DETAIL_STATE_CODE: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "Writing-in-progress posts are not analysis targets.", + ) return row @@ -1657,6 +1936,13 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s source_process_unit.process_unit_name as source_process_unit_catalog_name, post.source_sales_pool_code, post.source_sales_pool_name, + post.source_order_pool_code, + post.source_sales_order_code, + post.source_sales_order_item_number, + post.source_inspection_point_code, + post.source_stage_code, + post.source_detail_state_code, + post.source_deleted_flag, post.source_customer_code, post.source_customer_name, source_customer.entity_name as source_customer_catalog_name, @@ -1696,6 +1982,10 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s "source_process_unit_name", "source_sales_pool_code", "source_sales_pool_name", + "source_order_pool_code", + "source_sales_order_code", + "source_sales_order_item_number", + "source_inspection_point_code", "source_customer_code", "source_customer_name", "source_project_code", @@ -1728,6 +2018,13 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s source_process_unit_catalog_name=first["source_process_unit_catalog_name"], source_sales_pool_code=first["source_sales_pool_code"], source_sales_pool_name=first["source_sales_pool_name"], + source_order_pool_code=first["source_order_pool_code"], + source_sales_order_code=first["source_sales_order_code"], + source_sales_order_item_number=first["source_sales_order_item_number"], + source_inspection_point_code=first["source_inspection_point_code"], + source_stage_code=first["source_stage_code"], + source_detail_state_code=first["source_detail_state_code"], + source_deleted_flag=first["source_deleted_flag"], source_customer_code=first["source_customer_code"], source_customer_name=first["source_customer_name"], source_customer_catalog_name=first["source_customer_catalog_name"], @@ -1852,7 +2149,7 @@ async def read_related_keymen( async with pool.acquire() as conn: if not await person_exists(conn, person_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "person not found") - visible_post_ids = await visible_mention_post_ids(conn, person_id, lambda row: _can_see_post(account, row)) + visible_post_ids = await visible_mention_post_ids(conn, person_id, lambda row: _can_use_post_for_analysis(account, row)) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this person") person = await conn.fetchrow( @@ -1882,7 +2179,7 @@ async def read_related_corporate_entity( if not await corporate_entity_exists(conn, entity_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "corporate entity not found") visible_post_ids = await visible_affiliation_post_ids( - conn, entity_id, lambda row: _can_see_post(account, row) + conn, entity_id, lambda row: _can_use_post_for_analysis(account, row) ) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this entity") @@ -1911,7 +2208,7 @@ async def read_related_team( if not await team_exists(conn, team_id): raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found") visible_post_ids = await visible_team_mention_post_ids( - conn, team_id, lambda row: _can_see_post(account, row) + conn, team_id, lambda row: _can_use_post_for_analysis(account, row) ) if not visible_post_ids: raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") @@ -2018,6 +2315,11 @@ async def verify_post_entity_relationships( 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, @@ -2071,22 +2373,33 @@ async def extract_post_keymen( # 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). - post_body = ( - await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) - ).text context_hints = await _load_post_semantic_hints(conn, post_id) - 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, - ) + 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 @@ -2102,9 +2415,20 @@ async def extract_post_keymen( for name in mention.affiliated_organization_names } ) - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, 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( @@ -2157,6 +2481,7 @@ async def read_post_lineage( # Safe SQL: the eligibility predicate is an immutable schema fragment; candidate ids are bound. fetched = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli "select post_id, post_title, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code, " "btrim(left(source_post_search_text(post_body), 420)) as post_body_excerpt, " "char_length(coalesce(post_body, '')) > 420 as post_body_truncated " f"from source_post where post_id = any($1::uuid[]) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", @@ -2173,7 +2498,7 @@ def _visible_summaries(ids: frozenset[str]) -> list[dict[str, Any]]: "post_body_truncated": rows[post_id_].get("post_body_truncated", False), } for post_id_ in ids - if post_id_ in rows and _can_see_post(account, rows[post_id_]) + if post_id_ in rows and _can_use_post_for_analysis(account, rows[post_id_]) ] return { @@ -2183,6 +2508,18 @@ def _visible_summaries(ids: frozenset[str]) -> list[dict[str, Any]]: } +@app.get("/api/posts/{post_id}/knowledge-graph") +async def read_post_knowledge_graph( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return the authorized post-scoped KG projection for visualization.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + return await post_knowledge_graph(conn, post_id) + + @app.get("/api/posts/{post_id}/evaluation") async def read_post_evaluation( post_id: str, @@ -2232,17 +2569,28 @@ async def evaluate_post( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - 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 - ) + 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, @@ -2287,7 +2635,7 @@ async def compare_period_groupings( members = [ member for member in row["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2316,7 +2664,7 @@ async def list_period_reports( members = [ member for member in summary["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2350,7 +2698,7 @@ async def read_period_reports( members = [ member for member in report["members"] - if _can_see_post(account, member) + if _can_use_post_for_analysis(account, member) and not _is_synthetic_demo_member(member, demo_entity_ids) ] if not members: @@ -2358,15 +2706,37 @@ async def read_period_reports( leftover_pairs = [ pair for pair in report.get("leftover_pairs", []) - if _can_see_post(account, pair) + if _can_use_post_for_analysis(account, pair) and not _is_synthetic_demo_member(pair, demo_entity_ids) ] members = [ - {key: value for key, value in member.items() if key != "has_real_source_context"} + { + key: value + for key, value in member.items() + if key + not in { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } + } for member in members ] leftover_pairs = [ - {key: value for key, value in pair.items() if key != "has_real_source_context"} + { + key: value + for key, value in pair.items() + if key + not in { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } + } for pair in leftover_pairs ] visible.append( @@ -2418,6 +2788,7 @@ async def read_post_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 + summary_waiting_for_images = False async with pool.acquire() as conn: body_row = await conn.fetchrow( "select post_body from source_post where post_id = $1", post_id @@ -2432,6 +2803,40 @@ async def read_post_summary( if stored is not None: return stored stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) + image_body = post_body_has_images(raw_body) + if image_body: + content_complete = await post_content_is_complete( + conn, + post_id, + embedding_model_code=load_settings().embedding_model, + 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) + summary_waiting_for_images = not await post_content_summary_is_ready(conn, post_id) + if summary_waiting_for_images and queue_event is not None: + await publish_post_content_event( + valkey, + post_id=queue_event[0], + source_body_digest=queue_event[1], + ) + if summary_waiting_for_images: + if stale is not None: + return stale + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: image evidence is still being processed", + ) with use_llm_metadata(post_metadata): client = _post_summary_client() if not client.available: @@ -2441,11 +2846,17 @@ async def read_post_summary( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) - normalized = await asyncio.to_thread(normalize_post_body, raw_body) - normalized_body = normalized.text context_hints = await _load_post_semantic_hints(conn, post_id) summarize_with_hints = getattr(client, "summarize_with_hints", None) try: + if image_body: + normalized_body = await fetch_post_summary_source(conn, post_id) + if not normalized_body: + raise ValueError("persisted post content is not available") + else: + normalized_body = ( + await asyncio.to_thread(normalize_post_body, raw_body) + ).text if callable(summarize_with_hints): summary = await asyncio.to_thread( summarize_with_hints, post["post_title"], normalized_body, context_hints @@ -2459,32 +2870,49 @@ async def read_post_summary( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc - 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(), - ) - content_complete = await post_content_is_complete( - conn, - post_id, - embedding_model_code=load_settings().embedding_model, - require_structure=bool( - load_settings().orchestrator_base_url - and load_settings().orchestrator_api_key - ), - ) - async with conn.transaction(): - job = await ensure_post_content_job( + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale + 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, + resolution_client=_organization_name_resolution_client(), + 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 + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + if not image_body: + content_complete = await post_content_is_complete( conn, post_id, - raw_body, - content_complete=content_complete, + embedding_model_code=load_settings().embedding_model, + require_structure=bool( + load_settings().orchestrator_base_url + and load_settings().orchestrator_api_key + ), ) - if job.should_publish: - queue_event = (job.post_id, job.source_body_sha256) + 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, @@ -2506,7 +2934,7 @@ async def read_post_five_w1h( return await load_five_w1h_slots( conn, post_id, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), ) @@ -2517,9 +2945,10 @@ class ChatRequest(BaseModel): class GlobalAskRequest(BaseModel): - """JSON body for the buyer's source-grounded Global Ask Agent.""" + """JSON body for the reader's source-grounded Global Ask Agent.""" question: str + conversation_id: UUID | None = None @app.get("/api/posts/{post_id}/chat") @@ -2583,12 +3012,17 @@ async def chat_about_post( "Post chat is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) sources = await gather_chat_sources( - conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() + conn, post_id, lambda row: _can_use_post_for_analysis(account, row), vision_client=_vision_client() ) try: with use_llm_metadata(post_metadata): answer = await asyncio.to_thread(client.answer, question, sources) - except (HttpClientError, KeyError, OSError, ValueError) as exc: + except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat 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 chat is unavailable: contextual-orchestrator returned no complete evidence object", @@ -2612,17 +3046,71 @@ async def chat_about_post( } +@app.get("/api/ask/conversations") +async def read_ask_conversations( + limit: int = Query(50, ge=1, le=50), + before_updated_at: datetime | None = Query(None), + before_conversation_id: UUID | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return only the authenticated account's Global Ask conversations.""" + _require_post_read(account) + async with pool.acquire() as conn: + if (before_updated_at is None) != (before_conversation_id is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "before_updated_at and before_conversation_id must be provided together", + ) + return await list_conversations( + conn, + account.user_account_id, + limit=limit, + before_updated_at=before_updated_at, + before_conversation_id=before_conversation_id, + ) + + +@app.get("/api/ask/conversations/{conversation_id}") +async def read_ask_conversation( + conversation_id: UUID, + limit: int = Query(50, ge=1, le=50), + before_turn: int | None = Query(None, ge=1), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return one owned transcript with currently authorized evidence.""" + _require_post_read(account) + async with pool.acquire() as conn: + conversation = await fetch_conversation( + conn, + account.user_account_id, + conversation_id, + lambda row: _can_use_post_for_analysis(account, row), + turn_limit=limit, + before_turn_ordinal=before_turn, + ) + if conversation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") + return conversation + + @app.post("/api/ask") async def ask_agent( request: GlobalAskRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Answer a buyer question from authorized post and graph evidence.""" + """Answer a reader question from authorized post and graph evidence.""" question = request.question.strip() if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") _require_post_read(account) + async with pool.acquire() as conn: + if request.conversation_id is not None and not await conversation_exists( + conn, account.user_account_id, request.conversation_id + ): + raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") client = _post_chat_client() if not client.available: raise HTTPException( @@ -2632,12 +3120,12 @@ async def ask_agent( async with pool.acquire() as conn: sources = await gather_global_chat_sources( conn, - lambda row: _can_see_post(account, row), + lambda row: _can_use_post_for_analysis(account, row), account.corporate_entity_ids, question=question, ) if not sources: - return { + response: dict[str, Any] = { "answer_text": "", "cited_post_ids": [], "cited_posts": [], @@ -2645,21 +3133,47 @@ async def ask_agent( "cited_post_evidence": [], "next_action": "No authorized source posts are available for this question.", } - try: - answer = await asyncio.to_thread(client.answer, question, sources) - except (HttpClientError, KeyError, OSError, ValueError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - f"Ask Agent is unavailable: {exc}", - ) from exc - cited_ids = list(answer.cited_post_ids) - return { - "answer_text": answer.answer_text, - "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), - "cited_post_evidence": cited_post_evidence(sources, cited_ids), - "source_post_ids": [source.post_id for source in sources], - } + else: + try: + answer = await asyncio.to_thread(client.answer, question, sources) + except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent 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, + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + cited_ids = list(answer.cited_post_ids) + response = { + "answer_text": answer.answer_text, + "cited_post_ids": cited_ids, + "cited_posts": cited_post_summaries(sources, cited_ids), + "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "source_post_ids": [source.post_id for source in sources], + "next_action": "Open the cited source posts to verify this answer." + if cited_ids + else "Ask a narrower question that can be verified against an authorized source post.", + } + async with pool.acquire() as conn: + try: + persisted_conversation_id = await persist_turn( + conn, + account.user_account_id, + request.conversation_id, + question, + response["answer_text"], + response.get("next_action"), + response["source_post_ids"], + response["cited_post_ids"], + response["cited_post_evidence"], + ) + except GlobalAskConversationNotFound as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") from exc + response["conversation_id"] = str(persisted_conversation_id) + return response class PostBookmarkRequest(BaseModel): @@ -2675,7 +3189,7 @@ async def read_post_bookmark( 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", + "select 1 from post_bookmark where user_account_id = $1 and post_id = $2", account.user_account_id, post_id, ) @@ -2694,7 +3208,7 @@ async def write_post_bookmark( if request.bookmarked: await conn.execute( """ - insert into bookmark (user_account_id, post_id) + insert into post_bookmark (user_account_id, post_id) values ($1, $2) on conflict (user_account_id, post_id) do nothing """, @@ -2703,7 +3217,7 @@ async def write_post_bookmark( ) else: await conn.execute( - "delete from bookmark where user_account_id = $1 and post_id = $2", + "delete from post_bookmark where user_account_id = $1 and post_id = $2", account.user_account_id, post_id, ) @@ -2881,14 +3395,25 @@ async def derive_post_commitment( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - 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 = client.extract(post["post_title"], normalized_body, reference_date) + 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 = 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: @@ -3099,7 +3624,7 @@ async def read_calendar( demo_entity_ids: set[str] = set() if commitments and await has_real_source_context(conn, list(account.corporate_entity_ids)): demo_entity_ids = await fetch_demo_corporate_entity_ids(conn) - visible = [c for c in commitments if _can_see_post(account, c)] + visible = [c for c in commitments if _can_use_post_for_analysis(account, c)] # Once real evidence is visible, the synthetic Demo Corp commitments # (ADR 0001 / ADR 0042) stop appearing beside it. if demo_entity_ids: @@ -3107,7 +3632,14 @@ async def read_calendar( c for c in visible if not _is_synthetic_demo_member(c, demo_entity_ids) ] for c in visible: - del c["visibility_code"], c["corporate_entity_id"], c["has_real_source_context"] + for key in ( + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + ): + c.pop(key, None) return { "events": events, "commitments": visible, @@ -3132,7 +3664,7 @@ async def read_rankings( _require_post_read(account) async with pool.acquire() as conn: posts = await load_visible_ranking_posts( - conn, lambda row: _can_see_post(account, row) + conn, lambda row: _can_use_post_for_analysis(account, row) ) return _rankweave_client().as_api_payload( posts, can_see_post=lambda _row: True diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..2c1f434d1 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -19,8 +19,9 @@ import asyncio import re +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from typing import Any import asyncpg @@ -33,6 +34,7 @@ random_walk_with_restart, select_related_nodes, ) +from lineageweave.ontology import ontology_annotations from lineageweave.post_chat import ( CANONICAL_CHAT_QUESTION, CANONICAL_COMMITMENT_QUESTION, @@ -41,9 +43,9 @@ normalize_chat_question, ) from lineageweave.post_content_normalization import normalize_post_body +from lineageweave.source_lineage_hints import source_lineage_hint_facts from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph -from lineageweave.ontology import ontology_annotations @dataclass(frozen=True) @@ -155,6 +157,10 @@ async def _graph_facts_for_posts( ("source_process_unit_name", "source business unit name (PU)"), ("source_sales_pool_code", "source sales pool"), ("source_sales_pool_name", "source sales pool name"), + ("source_order_pool_code", "source order pool"), + ("source_sales_order_code", "source sales order"), + ("source_sales_order_item_number", "source sales order item"), + ("source_inspection_point_code", "source inspection point"), ("source_customer_code", "source customer code"), ("source_customer_name", "source customer name"), ("source_project_code", "source project code"), @@ -175,7 +181,16 @@ def _source_hint_facts(row: Any) -> tuple[str, ...]: facts.append( f"{label}={str(value).strip()} [provenance=source_post.{field_name}; hint_only]" ) - return tuple(facts) + return tuple(facts) + source_lineage_hint_facts( + customer_code=row.get("source_customer_code"), + order_pool_code=row.get("source_order_pool_code"), + sales_order_code=row.get("source_sales_order_code"), + sales_order_item_number=row.get("source_sales_order_item_number"), + stage_code=row.get("source_stage_code"), + detail_state_code=row.get("source_detail_state_code"), + inspection_point_code=row.get("source_inspection_point_code"), + deleted_flag=row.get("source_deleted_flag"), + ) async def _semantic_facts_for_posts( @@ -191,14 +206,14 @@ async def _semantic_facts_for_posts( || ' | evidence: ' || left(evidence_text, 500) || ' | ontology_iri: ' || ontology_iri || ' | extraction_method: ' || extraction_method - || ' | confidence: ' || confidence::text + || ' | confidence: ' || mention_confidence::text || ' [provenance=post_project_mention]' as fact from post_project_mention where post_id = any($1::uuid[]) union all select post_id::text as post_id, 'actor: ' || left(actor_name, 200) - || ' | responsibility: ' || left(responsibility, 500) + || ' | responsibility: ' || left(responsibility_text, 500) || coalesce(' | affiliation: ' || left(affiliated_organization_name, 200), '') || ' [provenance=post_summary_role]' as fact from post_summary_role @@ -211,6 +226,62 @@ async def _semantic_facts_for_posts( from post_person_mention mention join cataloged_person person on person.person_id = mention.person_id where mention.post_id = any($1::uuid[]) + union all + select event.post_id::text as post_id, + 'event: ' || left(event.event_text, 300) + || coalesce(' | evidence: ' || left(event.evidence_text, 500), '') + || ' | ontology_iri: ' || event.ontology_iri + || ' | extraction_method: ' || event.extraction_method + || ' [provenance=post_summary_event]' as fact + from post_summary_event event + where event.post_id = any($1::uuid[]) + union all + select clue.post_id::text as post_id, + 'event clue: ' || clue.clue_type_code + || ' | clue: ' || left(clue.clue_text, 300) + || coalesce(' | target: ' || left(clue.target_text, 200), '') + || coalesce(' | normalized: ' || left(clue.normalized_value_text, 200), '') + || coalesce(' | assertion: ' || clue.assertion_code, '') + || ' | evidence: ' || left(clue.evidence_text, 500) + || ' | ontology_iri: ' || clue.ontology_iri + || ' | extraction_method: ' || clue.extraction_method + || ' [provenance=post_summary_event_clue]' as fact + from post_summary_event_clue clue + where clue.post_id = any($1::uuid[]) + union all + select observation.post_id::text as post_id, + 'quantitative: ' || left(observation.label_text, 200) + || ' | value: ' || left(observation.raw_value_text, 200) + || coalesce(' | quantity: ' || observation.quantity_numeric::text || ' ' || observation.quantity_unit_code, '') + || ' | evidence: ' || left(observation.evidence_text, 500) + || ' | ontology_iri: ' || observation.ontology_iri + || ' | extraction_method: ' || observation.extraction_method + || ' [provenance=post_summary_quantitative_observation]' as fact + from post_summary_quantitative_observation observation + where observation.post_id = any($1::uuid[]) + union all + select fact.post_id::text as post_id, + 'source fact: ' || left(fact.label_text, 200) + || ' | value: ' || left(fact.value_text, 500) + || coalesce(' | normalized_value: ' || left(fact.normalized_value_text, 200), '') + || coalesce(' | normalized_date: ' || fact.normalized_date::text, '') + || coalesce(' | assertion: ' || fact.assertion_code, '') + || ' | evidence: ' || left(fact.evidence_text, 500) + || ' | ontology_iri: ' || fact.ontology_iri + || ' | extraction_method: ' || fact.extraction_method + || ' [provenance=post_summary_source_fact]' as fact + from post_summary_source_fact fact + where fact.post_id = any($1::uuid[]) + union all + select relation.post_id::text as post_id, + 'semantic relation: ' || left(relation.subject_name, 200) + || ' --' || relation.predicate_code || '--> ' + || left(relation.object_name, 200) + || ' | evidence: ' || left(relation.evidence_text, 500) + || ' | confidence: ' || relation.relation_confidence::text + || ' [provenance=post_summary_semantic_relationship]' as fact + from post_summary_semantic_relationship relation + where relation.post_id = any($1::uuid[]) order by post_id, fact """, post_ids, @@ -292,6 +363,8 @@ async def gather_chat_sources( "source_author_code, source_author_name, source_company_code, source_company_name, " "source_process_unit_code, source_process_unit_name, " "source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, source_sales_order_item_number, " + "source_inspection_point_code, source_stage_code, source_detail_state_code, source_deleted_flag, " "source_customer_code, source_customer_name, source_project_code, " "source_project_name from source_post where post_id = $1", post_id, @@ -323,9 +396,12 @@ async def gather_chat_sources( rows = await conn.fetch( "select post_id, post_title, post_body, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code, " "source_system_code, source_record_key, source_author_code, source_author_name, " "source_company_code, source_company_name, source_process_unit_code, " "source_process_unit_name, source_sales_pool_code, source_sales_pool_name, " + "source_order_pool_code, source_sales_order_code, source_sales_order_item_number, " + "source_inspection_point_code, source_stage_code, source_deleted_flag, " "source_customer_code, source_customer_name, " "source_project_code, source_project_name " "from source_post where post_id = any($1::uuid[]) " @@ -425,7 +501,7 @@ async def gather_global_chat_sources( # tiebreak let recency crowd out relevance -- a year-old post whose # title is an exact company-name match lost to four newer, only # loosely related posts in a live reproduction of this bug. - _MATCH_WEIGHT = {"title": 3.0, "body": 1.0, "source_field": 1.0} + _MATCH_WEIGHT = {"title": 3.0, "body": 1.0, "source_field": 1.0, "semantic": 2.5} candidate_scores: dict[str, float] = {} for term in search_terms: candidate_rows = await conn.fetch( @@ -456,10 +532,83 @@ async def gather_global_chat_sources( source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_order_pool_code, source_sales_order_code, + source_sales_order_item_number, + source_inspection_point_code, source_stage_code, + source_deleted_flag, source_customer_code, source_customer_name, source_project_code, source_project_name) ilike '%' || $1 || '%' limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_event semantic + on semantic.post_id = post.post_id + where semantic.event_text ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_event_clue semantic + on semantic.post_id = post.post_id + where semantic.clue_text ilike '%' || $1 || '%' + or semantic.target_text ilike '%' || $1 || '%' + or semantic.normalized_value_text ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + or semantic.ontology_iri ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_project_mention semantic + on semantic.post_id = post.post_id + where semantic.project_name ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + or semantic.ontology_iri ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_role semantic + on semantic.post_id = post.post_id + where semantic.actor_name ilike '%' || $1 || '%' + or semantic.responsibility_text ilike '%' || $1 || '%' + or semantic.affiliated_organization_name ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_quantitative_observation semantic + on semantic.post_id = post.post_id + where semantic.label_text ilike '%' || $1 || '%' + or semantic.raw_value_text ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + or semantic.value_numeric::text ilike '%' || $1 || '%' + or semantic.quantity_numeric::text ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_source_fact semantic + on semantic.post_id = post.post_id + where semantic.label_text ilike '%' || $1 || '%' + or semantic.value_text ilike '%' || $1 || '%' + or semantic.normalized_value_text ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + or semantic.normalized_date::text ilike '%' || $1 || '%' + limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from source_post post + join post_summary_semantic_relationship semantic + on semantic.post_id = post.post_id + where semantic.subject_name ilike '%' || $1 || '%' + or semantic.predicate_code ilike '%' || $1 || '%' + or semantic.object_name ilike '%' || $1 || '%' + or semantic.evidence_text ilike '%' || $1 || '%' + limit 32) ) matches order by created_at desc, post_id desc limit 32 @@ -479,6 +628,27 @@ async def gather_global_chat_sources( # `find_linked_post_ids`'s `.direct` set used by the post-scoped chat # flow. Only the top match is expanded -- expanding every keyword hit # would let a loosely related term drag in an unrelated lineage chain. + kg_neighbor_ids: list[str] = [] + kg_anchor_id = candidate_ids[0] if candidate_ids else None + if kg_anchor_id: + kg_edges = await load_visible_subgraph(conn, [kg_anchor_id]) + kg_scores = random_walk_with_restart( + adjacency_from_edges(kg_edges), node_key(NODE_POST, kg_anchor_id) + ) + for node, score in select_related_nodes( + kg_scores, node_key(NODE_POST, kg_anchor_id), max_nodes=limit * 2 + ): + node_type, node_id = parse_node_key(node) + if node_type != NODE_POST: + continue + kg_neighbor_ids.append(node_id) + candidate_scores[node_id] = candidate_scores.get(node_id, 0.0) + score + candidate_ids = sorted( + candidate_scores, + key=lambda post_id: candidate_scores[post_id], + reverse=True, + ) + lineage_neighbor_ids: list[str] = [] lineage_anchor_id = candidate_ids[0] if candidate_ids else None if lineage_anchor_id: @@ -504,9 +674,12 @@ async def gather_global_chat_sources( rows = await conn.fetch( """ select post_id, post_title, post_body, visibility_code, corporate_entity_id, + author_account_id, source_detail_state_code, source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, source_sales_pool_code, source_sales_pool_name, + source_order_pool_code, source_sales_order_code, source_sales_order_item_number, + source_inspection_point_code, source_stage_code, source_deleted_flag, source_customer_code, source_customer_name, source_project_code, source_project_name from source_post diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index dae640240..55896a4c7 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -10,6 +10,8 @@ import asyncpg import redis.asyncio as redis +from lineageweave.chunking import chunk_by_source_body + POST_CONTENT_STREAM_KEY = "post-content-ingestion" QUEUED = "post_content_ingestion_queued" RUNNING = "post_content_ingestion_running" @@ -44,6 +46,18 @@ def post_content_api_status(status_code: str | None, *, content_present: bool) - return "unavailable" +def post_content_summary_status_message(status_code: str | None) -> str: + """Return an honest reader-facing image-evidence status message. + + A terminal ingestion failure is not still processing. Keeping those two + states distinct lets the popup tell the operator to retry the durable + job instead of implying that waiting will resolve a terminal failure. + """ + if status_code == FAILED: + return "Post summary is unavailable: image evidence ingestion failed; contact an administrator to retry the content job" + return "Post summary is unavailable: image evidence is still being processed" + + async def post_content_is_complete( conn: asyncpg.Connection, post_id: str, @@ -111,6 +125,70 @@ async def post_content_is_complete( ) +async def post_content_summary_is_ready( + conn: asyncpg.Connection, + post_id: str, +) -> bool: + """Require every embedded image and visual region to have VISION evidence.""" + return bool( + await conn.fetchval( + """ + select exists( + select 1 + from post_content_unit unit + where unit.post_id = $1 + ) + and not exists( + select 1 + from post_content_unit unit + left join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + where unit.post_id = $1 + and unit.unit_kind_code = 'image' + and ( + image.post_content_image_id is null + or image.description_status_code <> 'described' + or exists( + select 1 + from post_content_image_region region + where region.post_content_image_id = image.post_content_image_id + and region.description_status_code <> 'described' + ) + ) + ) + """, + post_id, + ) + ) + + +async def fetch_post_summary_source( + conn: asyncpg.Connection, + post_id: str, +) -> str | None: + """Return persisted semantic units, including completed image evidence.""" + rows = await conn.fetch( + """ + select unit_text + from post_content_unit + where post_id = $1 + order by unit_index + """, + post_id, + ) + source = "\n\n".join( + str(row["unit_text"]).strip() + for row in rows + if isinstance(row["unit_text"], str) and row["unit_text"].strip() + ) + return source or None + + +def post_body_has_images(body: str) -> bool: + """Detect image units without exposing or copying the raw body.""" + return any(chunk.unit_type == "image" for chunk in chunk_by_source_body(body)) + + def post_content_stream_fields(*, post_id: str, source_body_digest: str) -> dict[str, str]: """Valkey carries only the identity and digest needed to wake a worker.""" return {"post_id": str(post_id), "source_body_sha256": source_body_digest} @@ -410,21 +488,26 @@ async def republish_queued_post_content_jobs( async with pool.acquire() as conn: rows = await conn.fetch( """ - select post_id, source_body_sha256 + select post_content_ingestion_job.post_id, + post_content_ingestion_job.source_body_sha256 from post_content_ingestion_job - where ( - status_code = $1 - and ( - attempt_count = 0 - or queued_at <= now() - $2::interval + join source_post post on post.post_id = post_content_ingestion_job.post_id + where coalesce(upper(btrim(post.source_detail_state_code)), '') <> 'W' + and ( + ( + post_content_ingestion_job.status_code = $1 + and ( + post_content_ingestion_job.attempt_count = 0 + or post_content_ingestion_job.queued_at <= now() - $2::interval + ) + ) + or ( + post_content_ingestion_job.status_code = $3 + and post_content_ingestion_job.started_at is not null + and post_content_ingestion_job.started_at < now() - $4::interval + ) ) - ) - or ( - status_code = $3 - and started_at is not null - and started_at < now() - $4::interval - ) - order by queued_at + order by post_content_ingestion_job.queued_at limit $5 """, QUEUED, diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 458b9021f..f776ff292 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -37,6 +37,7 @@ _RECOVERY_INTERVAL_SECONDS = 30.0 _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" +_UNEXPECTED_FAILURE_DETAIL = "post-content ingestion failed; retry is scheduled" async def _stream_tail(client: redis.Redis) -> str: @@ -56,7 +57,7 @@ async def _claim_job( async with pool.acquire() as conn: async with conn.transaction(): row = await conn.fetchrow( - f""" + """ select p.*, j.source_body_sha256 as job_source_body_sha256, j.status_code as job_status_code, j.attempt_count as job_attempt_count, @@ -66,6 +67,7 @@ async def _claim_job( join source_post p on p.post_id = j.post_id where j.post_id = $1::uuid and j.source_body_sha256 = $2 + and coalesce(upper(btrim(p.source_detail_state_code)), '') <> 'W' for update of j, p """, post_id, @@ -73,6 +75,8 @@ async def _claim_job( ) if row is None: return None + if str(row.get("source_detail_state_code") or "").strip().upper() == "W": + return None status_code = str(row["job_status_code"]) attempt_count = int(row["job_attempt_count"]) if status_code == FAILED: @@ -261,13 +265,13 @@ async def process_post_content_job( expected_attempt_count=attempt_count, ) return - except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry. + except Exception: # noqa: BLE001 - durable failure is recorded for retry. _logger.exception("post content ingestion failed for post_id=%s", post_id) await _finish_failed_job( pool, post_id, failure_code="post_content_ingestion_failed", - detail_text=str(exc)[:1000], + detail_text=_UNEXPECTED_FAILURE_DETAIL, expected_attempt_count=attempt_count, ) return diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 41473d9da..42a284729 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,4 +1,29 @@ -"""Shared source-post eligibility SQL for buyer evidence reads.""" +"""Shared source-post eligibility SQL for reader-facing evidence reads.""" + +WRITING_SOURCE_DETAIL_STATE_CODE = "W" + + +def normalize_source_detail_state_code(value: object) -> str | None: + """Return a canonical, case-insensitive source detail state code.""" + if not isinstance(value, str): + return None + normalized = value.strip().upper() + return normalized or None + + +def source_post_state_visibility_sql( + alias: str, *, corporate_param: int, account_param: int, admin_param: int +) -> str: + """Apply public/corp visibility, with an author/admin exception for W.""" + return ( + f"((coalesce(upper(btrim({alias}.source_detail_state_code)), '') = " + f"'{WRITING_SOURCE_DETAIL_STATE_CODE}' " + f"and ({alias}.author_account_id = ${account_param}::uuid " + f"or ${admin_param}::boolean)) " + f"or (coalesce(upper(btrim({alias}.source_detail_state_code)), '') <> " + f"'{WRITING_SOURCE_DETAIL_STATE_CODE}' and ({alias}.visibility_code = 'public' " + f"or {alias}.corporate_entity_id::text = any(${corporate_param}::text[]))))" + ) SOURCE_CONTEXT_COLUMNS = ( "source_author_code", @@ -9,26 +34,43 @@ "source_process_unit_name", "source_sales_pool_code", "source_sales_pool_name", + "source_order_pool_code", + "source_sales_order_code", + "source_inspection_point_code", "source_customer_code", "source_customer_name", "source_project_code", "source_project_name", ) +# The SQL projection of the fixed ABAC rule in ``main._can_see_post``. Keep +# the authorized-id placeholder explicit so every reader query shares the +# same public-or-affiliated visibility boundary. +SOURCE_POST_VISIBILITY_SQL = ( + "({alias}.visibility_code = 'public' " + "or {alias}.corporate_entity_id = any({authorized_entity_ids}::uuid[]))" +) + def source_context_present_sql(alias: str) -> str: return " or ".join( - f"nullif(btrim({alias}.{column}), '') is not null" for column in SOURCE_CONTEXT_COLUMNS + [ + *(f"nullif(btrim({alias}.{column}), '') is not null" for column in SOURCE_CONTEXT_COLUMNS), + f"{alias}.source_sales_order_item_number is not null", + ] ) def source_context_missing_sql(alias: str) -> str: return " and ".join( - f"nullif(btrim({alias}.{column}), '') is null" for column in SOURCE_CONTEXT_COLUMNS + [ + *(f"nullif(btrim({alias}.{column}), '') is null" for column in SOURCE_CONTEXT_COLUMNS), + f"{alias}.source_sales_order_item_number is null", + ] ) -SOURCE_POST_ELIGIBILITY_SQL = ( +SOURCE_POST_READER_ELIGIBILITY_SQL = ( "nullif(btrim({alias}.source_draft_code), '') is null " "and nullif(btrim({alias}.source_deleted_flag), '') is null " "and not (" @@ -43,3 +85,11 @@ def source_context_missing_sql(alias: str) -> str: missing_context=source_context_missing_sql("{alias}"), present_context=source_context_present_sql("real_post"), ) + +# Derived readers (ontology, lineage, ranking, reports, Ask, and content +# projections) must never consume a writing-in-progress source. Raw board +# list/detail routes opt into SOURCE_POST_READER_ELIGIBILITY_SQL explicitly. +SOURCE_POST_ELIGIBILITY_SQL = ( + f"({SOURCE_POST_READER_ELIGIBILITY_SQL}) " + "and coalesce(upper(btrim({alias}.source_detail_state_code)), '') <> 'W'" +) diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 7403185ed..f7287ade3 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -37,21 +37,31 @@ NullCorporateHierarchyInferenceClient, ) from lineageweave.fixtures import fixture_thread_cast +from lineageweave.http_client import HttpClientError from lineageweave.knowledge_graph import ( NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_TEAM, ) -from lineageweave.ontology import LW, ontology_annotations +from lineageweave.ontology import ( + LW, + ontology_annotations, + semantic_predicate_annotations, +) +from lineageweave.organization_name_resolution import ( + NullOrganizationNameResolutionClient, + OrganizationNameResolutionClient, +) from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM, + POST_SUMMARY_CONTRACT_VERSION, KeyEvent, PostSummary, - POST_SUMMARY_CONTRACT_VERSION, - normalize_project_key, RoleResponsibility, + is_generic_team_actor, + normalize_project_key, ) from lineageweave.relation_verification import ( NullRelationVerificationClient, @@ -59,15 +69,19 @@ ) from .corporate_entity_ingestion import get_or_create_corporate_entity -from .keyman_ingestion import _load_corporate_entity_candidates +from .keyman_ingestion import ( + _load_corporate_entity_candidates, + _resolve_affiliated_organization, +) from .knowledge_graph import persist_edges_for_post +from .post_eligibility import normalize_source_detail_state_code from .team_ingestion import upsert_team - SUMMARY_SOURCE_BODY_MISSING = ( "Post summary is unavailable: the source post body is empty. " "Re-import the source record with its body before requesting a summary." ) +SUMMARY_TARGET_UNAVAILABLE = "Writing-in-progress posts are not summary targets." def require_summary_source_body(body: str | None) -> str: @@ -77,6 +91,16 @@ def require_summary_source_body(body: str | None) -> str: return body +async def require_summary_target(conn: asyncpg.Connection, post_id: str) -> None: + """Keep W out of both persisted and on-demand summary generation.""" + state_code = await conn.fetchval( + "select source_detail_state_code from source_post where post_id = $1", + post_id, + ) + if normalize_source_detail_state_code(state_code) == "W": + raise ValueError(SUMMARY_TARGET_UNAVAILABLE) + + async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str, @@ -89,7 +113,7 @@ async def fetch_persisted_summary( (ADR 0019 / 0027). This function does not join ``corporate_entity`` by ``entity_name``. Person chips read ``cataloged_person_id``. A stale row is returned only when ``allow_stale`` is explicit so a caller can - preserve buyer continuity without presenting old semantics as current. + preserve reader continuity without presenting old semantics as current. """ header = await conn.fetchrow( "select korean_summary, summary_contract_version " @@ -103,7 +127,8 @@ async def fetch_persisted_summary( return None events = await conn.fetch( """ - select event.event_text, event.project_key, mention.project_name + select event.event_ordinal, event.event_text, event.evidence_text, + event.project_key, mention.project_name from post_summary_event event left join post_project_mention mention on mention.post_id = event.post_id @@ -115,11 +140,12 @@ async def fetch_persisted_summary( ) roles = await conn.fetch( """ - select role.actor_name, role.responsibility, role.actor_type_code, + select role.actor_name, role.responsibility_text, role.actor_type_code, role.affiliated_organization_name, role.cataloged_team_id, role.cataloged_corporate_entity_id, - role.cataloged_person_id + role.cataloged_person_id, + role.cataloged_affiliated_corporate_entity_id from post_summary_role role where role.post_id = $1 order by role.actor_name @@ -128,7 +154,7 @@ async def fetch_persisted_summary( ) projects = await conn.fetch( """ - select project_key, project_name, evidence_text, confidence, ontology_iri, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = $1 @@ -150,6 +176,67 @@ async def fetch_persisted_summary( """, post_id, ) + quantitative_observations = await conn.fetch( + """ + select observation.measurement_type_code, + observation.label_text, + observation.value_numeric, + observation.unit_code, + observation.quantity_numeric, + observation.quantity_unit_code, + observation.qualifier_text, + observation.raw_value_text, + observation.evidence_text, + observation.ontology_iri, + observation.extraction_method + from post_summary_quantitative_observation observation + where observation.post_id = $1 + order by observation.observation_ordinal + """, + post_id, + ) + source_grounded_facts = await conn.fetch( + """ + select fact.fact_type_code, + fact.label_text, + fact.value_text, + fact.normalized_value_text, + fact.assertion_code, + fact.normalized_date, + fact.date_precision_code, + fact.normalization_evidence_text, + fact.qualifier_text, + fact.evidence_text, + fact.ontology_iri, + fact.extraction_method + from post_summary_source_fact fact + where fact.post_id = $1 + order by fact.fact_ordinal + """, + post_id, + ) + semantic_relationships = await conn.fetch( + """ + select relation_ordinal, subject_name, subject_type, predicate_code, + object_name, object_type, evidence_text, relation_confidence, + extraction_method + from post_summary_semantic_relationship + where post_id = $1 + order by relation_ordinal + """, + post_id, + ) + event_clues = await conn.fetch( + """ + select event_ordinal, clue_ordinal, clue_type_code, clue_text, + target_text, normalized_value_text, assertion_code, + evidence_text, ontology_iri, extraction_method + from post_summary_event_clue + where post_id = $1 + order by event_ordinal, clue_ordinal + """, + post_id, + ) payload_roles: list[dict[str, Any]] = [] for row in roles: catalog_node_id = None @@ -166,11 +253,16 @@ async def fetch_persisted_summary( payload_roles.append( { "actor_name": row["actor_name"], - "responsibility": row["responsibility"], + "responsibility": row["responsibility_text"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], "catalog_node_id": catalog_node_id, "catalog_node_type_code": catalog_node_type_code, + "affiliated_organization_catalog_id": ( + str(row["cataloged_affiliated_corporate_entity_id"]) + if row["cataloged_affiliated_corporate_entity_id"] is not None + else None + ), **ontology_annotations(row["actor_type_code"]), } ) @@ -188,9 +280,24 @@ async def fetch_persisted_summary( { "event_text": row["event_text"], "project_name": row.get("project_name"), + "evidence_text": row.get("evidence_text"), } for row in events ], + "event_clues": [ + { + "event_index": row["event_ordinal"], + "clue_type_code": row["clue_type_code"], + "clue_text": row["clue_text"], + "target_text": row["target_text"], + "normalized_value_text": row["normalized_value_text"], + "assertion_code": row["assertion_code"], + "evidence_text": row["evidence_text"], + "ontology_iri": row["ontology_iri"], + "extraction_method": row["extraction_method"], + } + for row in event_clues + ], "roles_and_responsibilities": payload_roles, "major_event_actions": [ { @@ -202,12 +309,74 @@ async def fetch_persisted_summary( } for row in actions ], + "quantitative_observations": [ + { + "measurement_type_code": row["measurement_type_code"], + "label_text": row["label_text"], + "value_numeric": str(row["value_numeric"]), + "unit_code": row["unit_code"], + "quantity_numeric": ( + str(row["quantity_numeric"]) + if row["quantity_numeric"] is not None + else None + ), + "quantity_unit_code": row["quantity_unit_code"], + "qualifier_text": row["qualifier_text"], + "raw_value_text": row["raw_value_text"], + "evidence_text": row["evidence_text"], + "ontology_iri": row["ontology_iri"], + "ontology_label": ontology_annotations( + row["measurement_type_code"] + ).get("ontology_label"), + "extraction_method": row["extraction_method"], + } + for row in quantitative_observations + ], + "source_grounded_facts": [ + { + "fact_type_code": row["fact_type_code"], + "label_text": row["label_text"], + "value_text": row["value_text"], + "normalized_value_text": row["normalized_value_text"], + "assertion_code": row["assertion_code"], + "normalized_date": ( + row["normalized_date"].isoformat() + if row["normalized_date"] is not None + else None + ), + "date_precision_code": row["date_precision_code"], + "normalization_evidence_text": row["normalization_evidence_text"], + "qualifier_text": row["qualifier_text"], + "evidence_text": row["evidence_text"], + "ontology_iri": row["ontology_iri"], + "ontology_label": ontology_annotations(row["fact_type_code"]).get( + "ontology_label" + ), + "extraction_method": row["extraction_method"], + } + for row in source_grounded_facts + ], + "semantic_relationships": [ + { + "relation_ordinal": row["relation_ordinal"], + "subject_name": row["subject_name"], + "subject_type": row["subject_type"], + "predicate_code": row["predicate_code"], + "object_name": row["object_name"], + "object_type": row["object_type"], + "evidence_text": row["evidence_text"], + "confidence": float(row["relation_confidence"]), + "extraction_method": row["extraction_method"], + **semantic_predicate_annotations(row["predicate_code"]), + } + for row in semantic_relationships + ], "project_mentions": [ { "project_key": row["project_key"], "project_name": row["project_name"], "evidence": row["evidence_text"], - "confidence": float(row["confidence"]), + "confidence": float(row["mention_confidence"]), "ontology_iri": row["ontology_iri"], "extraction_method": row["extraction_method"], } @@ -222,6 +391,7 @@ async def persist_post_summary( summary: PostSummary, *, post_body: str | None = None, + resolution_client: OrganizationNameResolutionClient | None = None, hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None, verification_client: RelationVerificationClient | None = None, ) -> dict[str, Any]: @@ -240,12 +410,14 @@ async def persist_post_summary( summary replacement transaction while all post-owned rows still commit or roll back together. """ + await require_summary_target(conn, post_id) if post_body is not None: require_summary_source_body(post_body) hierarchy_inference_client = ( hierarchy_inference_client or NullCorporateHierarchyInferenceClient() ) + resolution_client = resolution_client or NullOrganizationNameResolutionClient() verification_client = verification_client or NullRelationVerificationClient() context_text = post_body if post_body is not None else summary.korean_summary @@ -255,8 +427,30 @@ async def persist_post_summary( else [] ) resolved_organization_ids: dict[int, str] = {} + resolved_affiliation_names: dict[int, str] = {} + resolved_affiliation_ids: dict[int, str] = {} for role_index, role in enumerate(summary.roles_and_responsibilities): + if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(role.actor_name): + continue if role.actor_type_code != ACTOR_TYPE_ORGANIZATION: + if role.actor_type_code in {ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM} and role.affiliated_organization_name: + try: + _, resolved_name, corporate_entity_id = await _resolve_affiliated_organization( + conn, + role.affiliated_organization_name, + context_text, + resolution_client, + verification_client, + hierarchy_inference_client, + candidates, + ) + except (HttpClientError, OSError, TimeoutError, ValueError): + # R&R affiliation is enrichment. A provider outage must + # preserve the raw source name and the summary itself. + resolved_name, corporate_entity_id = role.affiliated_organization_name, None + resolved_affiliation_names[role_index] = resolved_name + if corporate_entity_id is not None: + resolved_affiliation_ids[role_index] = corporate_entity_id continue corporate_entity_id = await get_or_create_corporate_entity( conn, @@ -276,6 +470,8 @@ async def persist_post_summary( summary, candidates, resolved_organization_ids, + resolved_affiliation_names, + resolved_affiliation_ids, ) payload = await fetch_persisted_summary(conn, post_id) @@ -310,6 +506,8 @@ async def _replace_summary_projection( summary: PostSummary, candidates: list[Any], resolved_organization_ids: dict[int, str], + resolved_affiliation_names: dict[int, str], + resolved_affiliation_ids: dict[int, str], ) -> None: """Write one atomic replacement using pre-resolved shared identities.""" # Summary replacement owns only R&R projections. Keyman mentions remain @@ -321,6 +519,14 @@ async def _replace_summary_projection( await conn.execute("delete from post_team_mention where post_id = $1", post_id) await conn.execute("delete from post_organization_mention where post_id = $1", post_id) await conn.execute("delete from post_summary_five_w1h where post_id = $1", post_id) + await conn.execute( + "delete from post_summary_quantitative_observation where post_id = $1", post_id + ) + await conn.execute("delete from post_summary_source_fact where post_id = $1", post_id) + await conn.execute( + "delete from post_summary_semantic_relationship where post_id = $1", post_id + ) + await conn.execute("delete from post_summary_event_clue where post_id = $1", post_id) await conn.execute("delete from post_summary_action where post_id = $1", post_id) await conn.execute("delete from post_summary_result where post_id = $1", post_id) await conn.execute("delete from post_project_mention where post_id = $1", post_id) @@ -338,13 +544,13 @@ async def _replace_summary_projection( await conn.execute( """ insert into post_project_mention - (post_id, project_key, project_name, evidence_text, confidence, + (post_id, project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method) values ($1, $2, $3, $4, $5, $6, 'contextual_orchestrator_semantic') on conflict (post_id, project_key) do update set project_name = excluded.project_name, evidence_text = excluded.evidence_text, - confidence = excluded.confidence, + mention_confidence = excluded.mention_confidence, ontology_iri = excluded.ontology_iri, extraction_method = excluded.extraction_method """, @@ -355,6 +561,25 @@ async def _replace_summary_projection( project.confidence, str(LW.Project), ) + for ordinal, relation in enumerate(summary.semantic_relationships): + await conn.execute( + """ + insert into post_summary_semantic_relationship + (post_id, relation_ordinal, subject_name, subject_type, + predicate_code, object_name, object_type, evidence_text, + relation_confidence) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + """, + post_id, + ordinal, + relation.subject_name, + relation.subject_type, + relation.predicate_code, + relation.object_name, + relation.object_type, + relation.evidence_text, + relation.confidence, + ) event_details = summary.key_event_details or tuple( KeyEvent(event_text=event_text) for event_text in summary.key_events ) @@ -373,12 +598,39 @@ async def _replace_summary_projection( else None ) await conn.execute( - "insert into post_summary_event (post_id, event_ordinal, event_text, project_key) " - "values ($1, $2, $3, $4)", + "insert into post_summary_event " + "(post_id, event_ordinal, event_text, evidence_text, project_key, ontology_iri, extraction_method) " + "values ($1, $2, $3, $4, $5, $6, $7)", post_id, ordinal, event.event_text, + event.evidence_text, project_key, + str(LW.KeyEvent), + "contextual_orchestrator_event", + ) + for clue_ordinal, clue in enumerate(summary.event_clues): + if clue.event_index >= len(event_details): + continue + await conn.execute( + """ + insert into post_summary_event_clue + (post_id, event_ordinal, clue_ordinal, clue_type_code, clue_text, + target_text, normalized_value_text, assertion_code, evidence_text, + ontology_iri, extraction_method) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """, + post_id, + clue.event_index, + clue_ordinal, + clue.clue_type_code, + clue.clue_text, + clue.target_text, + clue.normalized_value_text, + clue.assertion_code, + clue.evidence_text, + str(LW.EvidenceClue), + "contextual_orchestrator_event_clue", ) for ordinal, claim in enumerate(summary.five_w1h_evidence): await conn.execute( @@ -391,17 +643,72 @@ async def _replace_summary_projection( claim.value_text, claim.evidence_text, ) + for ordinal, observation in enumerate(summary.quantitative_observations): + await conn.execute( + """ + insert into post_summary_quantitative_observation + (post_id, observation_ordinal, measurement_type_code, label_text, + value_numeric, unit_code, quantity_numeric, quantity_unit_code, + qualifier_text, raw_value_text, evidence_text, ontology_iri, + extraction_method) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + """, + post_id, + ordinal, + observation.measurement_type_code, + observation.label_text, + observation.value_numeric, + observation.unit_code, + observation.quantity_numeric, + observation.quantity_unit_code, + observation.qualifier_text, + observation.raw_value_text, + observation.evidence_text, + str(LW.QuantitativeObservation), + "contextual_orchestrator_quantitative", + ) + for ordinal, fact in enumerate(summary.source_grounded_facts): + await conn.execute( + """ + insert into post_summary_source_fact + (post_id, fact_ordinal, fact_type_code, label_text, value_text, + normalized_value_text, assertion_code, normalized_date, + date_precision_code, normalization_evidence_text, qualifier_text, + evidence_text, ontology_iri, extraction_method) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + """, + post_id, + ordinal, + fact.fact_type_code, + fact.label_text, + fact.value_text, + fact.normalized_value_text, + fact.assertion_code, + fact.normalized_date, + fact.date_precision_code, + fact.normalization_evidence_text, + fact.qualifier_text, + fact.evidence_text, + str(LW.SourceGroundedFact), + "contextual_orchestrator_source_fact", + ) # ADR 0009 / 0019 / 0027: resolve catalog identity before writing # the role row so fetch never reconstructs it by a non-unique name. for role_index, role in enumerate(summary.roles_and_responsibilities): + if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(role.actor_name): + continue cataloged_team_id = None cataloged_corporate_entity_id = None cataloged_person_id = None + cataloged_affiliated_corporate_entity_id = resolved_affiliation_ids.get(role_index) + affiliation_name = resolved_affiliation_names.get( + role_index, role.affiliated_organization_name + ) if role.actor_type_code == ACTOR_TYPE_TEAM: cataloged_team_id = await upsert_team( conn, role.actor_name, - role.affiliated_organization_name, + affiliation_name, candidates, ) elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: @@ -415,10 +722,11 @@ async def _replace_summary_projection( ) await conn.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, " + "(post_id, actor_name, responsibility_text, actor_type_code, " "affiliated_organization_name, cataloged_team_id, " - "cataloged_corporate_entity_id, cataloged_person_id) values " - "($1, $2, $3, $4, $5, $6, $7, $8)", + "cataloged_corporate_entity_id, cataloged_person_id, " + "cataloged_affiliated_corporate_entity_id) values " + "($1, $2, $3, $4, $5, $6, $7, $8, $9)", post_id, role.actor_name, role.responsibility, @@ -427,6 +735,7 @@ async def _replace_summary_projection( cataloged_team_id, cataloged_corporate_entity_id, cataloged_person_id, + cataloged_affiliated_corporate_entity_id, ) if cataloged_team_id is not None: await conn.execute( @@ -435,22 +744,31 @@ async def _replace_summary_projection( post_id, cataloged_team_id, ) - elif cataloged_corporate_entity_id is not None: + organization_ids = [] + if cataloged_corporate_entity_id is not None: + organization_ids.append(cataloged_corporate_entity_id) + if cataloged_affiliated_corporate_entity_id is not None: + organization_ids.append(cataloged_affiliated_corporate_entity_id) + for organization_id in dict.fromkeys(organization_ids): await conn.execute( "insert into post_organization_mention " "(post_id, corporate_entity_id) values ($1, $2) " "on conflict do nothing", post_id, - cataloged_corporate_entity_id, + organization_id, ) - elif cataloged_person_id is not None: + if cataloged_person_id is not None: await conn.execute( "insert into post_summary_person_mention (post_id, person_id) " "values ($1, $2) on conflict do nothing", post_id, cataloged_person_id, ) - role_names = {role.actor_name for role in summary.roles_and_responsibilities} + role_names = { + role.actor_name + for role in summary.roles_and_responsibilities + if not (role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(role.actor_name)) + } project_keys = { normalize_project_key(project.canonical_name) for project in summary.project_mentions diff --git a/backend/app/ranking_ingestion.py b/backend/app/ranking_ingestion.py index 512b71273..d76cf0f16 100644 --- a/backend/app/ranking_ingestion.py +++ b/backend/app/ranking_ingestion.py @@ -18,9 +18,10 @@ async def load_visible_ranking_posts( conn: "asyncpg.Connection", can_see_post: Callable[[Mapping[str, Any]], bool], ) -> list[dict[str, Any]]: - """Read ``source_post`` rows the buyer may rank.""" + """Read ``source_post`` rows the reader may rank.""" posts = await conn.fetch( "select post_id, post_title, created_at, visibility_code, " - "corporate_entity_id from source_post" + "corporate_entity_id, author_account_id, source_detail_state_code " + "from source_post" ) return [dict(row) for row in posts if can_see_post(row)] diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 50614b0ad..dc31fb13e 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -129,7 +129,7 @@ def grouping_value(kind: str, row: asyncpg.Record) -> str | None: from post_evaluation_response e join source_post p on p.post_id = e.post_id join post_project_mention mention on mention.post_id = p.post_id - and mention.confidence >= 0.7 + and mention.mention_confidence >= 0.7 where e.rubric_version = $1 and to_char(p.created_at at time zone 'UTC', 'IYYY-"W"IW') = $2 """ @@ -154,7 +154,7 @@ def grouping_value(kind: str, row: asyncpg.Record) -> str | None: from post_evaluation_response e join source_post p on p.post_id = e.post_id join post_project_mention mention on mention.post_id = p.post_id - and mention.confidence >= 0.7 + and mention.mention_confidence >= 0.7 where e.rubric_version = $1 and to_char(p.created_at at time zone 'UTC', 'YYYY-MM') = $2 """ @@ -256,7 +256,7 @@ async def load_shared_item_bank( return None items = await conn.fetch( """ - select item_code, item_index, slope, cat_params + select item_code, item_index, item_slope, cat_params from report_item_parameter where grouping_kind = $1 and grouping_key = $2 and period_code = $3 and rubric_version = $4 @@ -272,7 +272,7 @@ async def load_shared_item_bank( return ItemBank( model=str(header["selected_model"]), item_codes=tuple(str(row["item_code"]) for row in items), - slope=tuple(float(row["slope"]) for row in items), + slope=tuple(float(row["item_slope"]) for row in items), cat_params=tuple(tuple(float(value) for value in row["cat_params"]) for row in items), source_period_code=str(header["period_code"]), ) @@ -319,7 +319,7 @@ async def load_anchor_item_bank( return None items = await conn.fetch( """ - select item_code, item_index, slope, cat_params + select item_code, item_index, item_slope, cat_params from report_item_parameter where grouping_kind = $1 and grouping_key = $2 and period_code = $3 and rubric_version = $4 @@ -336,7 +336,7 @@ async def load_anchor_item_bank( ItemBank( model=str(header["selected_model"]), item_codes=tuple(str(row["item_code"]) for row in items), - slope=tuple(float(row["slope"]) for row in items), + slope=tuple(float(row["item_slope"]) for row in items), cat_params=tuple(tuple(float(value) for value in row["cat_params"]) for row in items), source_period_code=str(header["period_code"]), ), @@ -410,7 +410,7 @@ async def persist_period_report( """ insert into report_item_parameter ( grouping_kind, grouping_key, period_code, rubric_version, - item_code, item_index, slope, cat_params + item_code, item_index, item_slope, cat_params ) values ($1,$2,$3,$4,$5,$6,$7,$8) """, grouping_kind, @@ -427,7 +427,7 @@ async def persist_period_report( """ insert into report_item_information ( grouping_kind, grouping_key, period_code, rubric_version, - item_code, item_rank, information + item_code, item_rank, information_value ) values ($1,$2,$3,$4,$5,$6,$7) """, grouping_kind, @@ -552,6 +552,7 @@ async def fetch_period_reports( f""" select m.grouping_key, m.post_id, m.theta_eap, m.theta_sd, p.post_title, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context, t.due_date as ticket_due_date, t.ticket_title, t.ticket_status_code from report_member_score m @@ -588,7 +589,7 @@ async def fetch_period_reports( ) selected = await conn.fetch( """ - select grouping_key, item_code, item_rank, information + select grouping_key, item_code, item_rank, information_value from report_item_information where grouping_kind = $1 and period_code = $2 and rubric_version = $3 order by grouping_key, item_rank @@ -603,6 +604,7 @@ async def fetch_period_reports( select lp.grouping_key, lp.pair_kind, lp.post_id, lp.criterion_code, lp.leftover_distance, lp.leftover_residual, p.post_title, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code, ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_leftover_pair lp join source_post p on p.post_id = lp.post_id @@ -664,6 +666,8 @@ async def fetch_period_reports( "theta_sd": float(row["theta_sd"]), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), "ticket_due_date": ( None @@ -686,7 +690,7 @@ async def fetch_period_reports( { "item_code": str(row["item_code"]), "rank": int(row["item_rank"]), - "information": float(row["information"]), + "information": float(row["information_value"]), } for row in selected_by_group.get(header["grouping_key"], []) ], @@ -700,6 +704,8 @@ async def fetch_period_reports( "leftover_residual": float(row["leftover_residual"]), "visibility_code": row["visibility_code"], "corporate_entity_id": str(row["corporate_entity_id"]), + "author_account_id": str(row["author_account_id"]), + "source_detail_state_code": row["source_detail_state_code"], "has_real_source_context": bool(row["has_real_source_context"]), } for row in leftover_by_group.get(header["grouping_key"], []) @@ -731,7 +737,8 @@ async def list_period_report_summaries( # Safe SQL: the source-context expression is an immutable schema fragment; report keys are bound. members = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select m.grouping_key, m.period_code, p.visibility_code, p.corporate_entity_id + select m.grouping_key, m.period_code, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code , ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_member_score m join source_post p on p.post_id = m.post_id @@ -742,7 +749,7 @@ async def list_period_report_summaries( ) top_items = await conn.fetch( """ - select grouping_key, period_code, item_code, information + select grouping_key, period_code, item_code, information_value from report_item_information where grouping_kind = $1 and rubric_version = $2 and item_rank = 1 """, @@ -777,12 +784,14 @@ async def list_period_report_summaries( "selected_item_information": ( None if top_by_key.get((row["grouping_key"], row["period_code"])) is None - else float(top_by_key[(row["grouping_key"], row["period_code"])]["information"]) + else float(top_by_key[(row["grouping_key"], row["period_code"])]["information_value"]) ), "members": [ { "visibility_code": member["visibility_code"], "corporate_entity_id": str(member["corporate_entity_id"]), + "author_account_id": str(member["author_account_id"]), + "source_detail_state_code": member["source_detail_state_code"], "has_real_source_context": bool(member["has_real_source_context"]), } for member in members_by_key.get((row["grouping_key"], row["period_code"]), []) @@ -818,7 +827,7 @@ async def resolve_grouping_label(conn: asyncpg.Connection, grouping_kind: str, g elif grouping_kind == "project": row = await conn.fetchrow( "select project_name from post_project_mention " - "where project_key = $1 order by confidence desc, project_name limit 1", + "where project_key = $1 order by mention_confidence desc, project_name limit 1", grouping_key, ) if row is not None: @@ -847,7 +856,8 @@ async def fetch_period_comparison( # Safe SQL: the source-context expression is an immutable schema fragment; grouping filters are bound. members = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" - select m.grouping_kind, m.grouping_key, p.visibility_code, p.corporate_entity_id + select m.grouping_kind, m.grouping_key, p.visibility_code, p.corporate_entity_id, + p.author_account_id, p.source_detail_state_code , ({_SOURCE_CONTEXT_PRESENT_SQL}) as has_real_source_context from report_member_score m join source_post p on p.post_id = m.post_id @@ -876,6 +886,8 @@ async def fetch_period_comparison( { "visibility_code": member["visibility_code"], "corporate_entity_id": str(member["corporate_entity_id"]), + "author_account_id": str(member["author_account_id"]), + "source_detail_state_code": member["source_detail_state_code"], "has_real_source_context": bool(member["has_real_source_context"]), } for member in members_by_key.get((row["grouping_kind"], row["grouping_key"]), []) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..851ac4a2f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -82,6 +82,9 @@ _SOURCE_ORG_NAMED_HINTS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0039_source_org_named_hints.sql" ) +_SOURCE_COMMERCIAL_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0130_source_commercial_context.sql" +) _MEMBER_LOCALE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0044_member_locale_preference.sql" ) @@ -113,6 +116,56 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_TENANT_SETTINGS_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0103_tenant_settings.sql" +) +_IDENTIFIER_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0104_two_word_database_identifiers.sql" +) +_AFFILIATION_SCOPE_FACET_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0106_account_affiliation_scope_facet.sql" +) +_QUANTITATIVE_OBSERVATION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0108_post_summary_quantitative_observation.sql" +) +_SOURCE_FACT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0109_post_summary_source_fact.sql" +) +_SOFTWARE_AGENT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0110_role_responsibility_software_agent.sql" +) +_SEMANTIC_RELATIONSHIP_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0111_post_summary_semantic_relationship.sql" +) +_EVENT_CLUE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0112_event_clue_semantic_projection.sql" +) +_BROAD_FACT_TYPES_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0113_broad_source_fact_types.sql" +) +_SEMANTIC_RELATIONSHIP_PREDICATES_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0114_semantic_relationship_standard_predicates.sql" +) def _postgres_available() -> bool: @@ -213,6 +266,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SOURCE_RECORD_IDENTITY_MIGRATION.read_text()) cur.execute(_SOURCE_NAMED_HINTS_MIGRATION.read_text()) cur.execute(_SOURCE_ORG_NAMED_HINTS_MIGRATION.read_text()) + cur.execute(_SOURCE_COMMERCIAL_CONTEXT_MIGRATION.read_text()) cur.execute( (Path(__file__).resolve().parents[2] / "migrations" / "0040_post_summary_contract.sql") .read_text() @@ -226,6 +280,16 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) + cur.execute(_IDENTIFIER_MIGRATION.read_text()) + cur.execute(_AFFILIATION_SCOPE_FACET_MIGRATION.read_text()) + cur.execute(_EVENT_CLUE_MIGRATION.read_text()) + cur.execute(_BROAD_FACT_TYPES_MIGRATION.read_text()) + cur.execute(_QUANTITATIVE_OBSERVATION_MIGRATION.read_text()) + cur.execute(_SOURCE_FACT_MIGRATION.read_text()) + cur.execute(_SOFTWARE_AGENT_MIGRATION.read_text()) + cur.execute(_SEMANTIC_RELATIONSHIP_MIGRATION.read_text()) + cur.execute(_SEMANTIC_RELATIONSHIP_PREDICATES_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -282,6 +346,16 @@ def seeded_db(demo_analyst_token): "values ('OTHER-CORP', 'Other Corp', 'group') returning corporate_entity_id" ) other_corp_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('GRANTED-CORP', 'Granted Corp', 'company') returning corporate_entity_id" + ) + granted_corp_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('HIDDEN-CORP', 'Hidden Corp', 'company') returning corporate_entity_id" + ) + hidden_corp_id = cur.fetchone()[0] cur.execute( "insert into user_account (external_subject_id, display_name, email_address) " @@ -290,8 +364,10 @@ def seeded_db(demo_analyst_token): ) account_id = cur.fetchone()[0] cur.execute( - "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", - (account_id, own_corp_id), + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "values (%s, %s, 'scope_own_entity'), (%s, %s, 'scope_granted_entity')", + (account_id, own_corp_id, account_id, granted_corp_id), ) cur.execute( "insert into access_role (role_code, role_name) values ('viewer', 'Viewer') returning access_role_id" @@ -491,6 +567,12 @@ def _insert_post( "values (%s, 'Northridge Grid'), (%s, 'Northridge Holdings')", (counterpart_person_id, counterpart_person_id), ) + cur.execute( + "insert into person_affiliation " + "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " + "values (%s, 'Other Corp Only', %s)", + (hidden_person_id, other_corp_id), + ) cur.execute( "insert into post_person_mention (post_id, person_id) values " @@ -548,6 +630,8 @@ def _insert_post( "own_group_id": str(own_group_id), "own_corp_id": str(own_corp_id), "other_corp_id": str(other_corp_id), + "granted_corp_id": str(granted_corp_id), + "hidden_corp_id": str(hidden_corp_id), "own_private_post_id": own_private_post_id, "late_own_private_post_id": late_own_private_post_id, "edited_own_post_id": edited_own_post_id, @@ -661,7 +745,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert created.status_code == 201 @@ -682,7 +766,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert replay.status_code == 201 @@ -694,7 +778,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_tepp", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-tepp", + "idempotency_key": "run-create-tepp", }, ) assert tepp.status_code == 422 @@ -707,7 +791,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_report", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-report", + "idempotency_key": "run-create-report", }, ) assert report.status_code == 422 @@ -720,7 +804,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-01-01T00:00:00Z", - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert conflict.status_code == 409 @@ -731,14 +815,14 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["other_corp_id"], - "idempotency_key": "buyer-create-hidden-corp", + "idempotency_key": "run-create-hidden-corp", }, ) assert hidden.status_code == 404 unauthenticated = client.post( "/api/analysis-runs", - json={"idempotency_key": "buyer-create-unauthenticated"}, + json={"idempotency_key": "run-create-unauthenticated"}, ) assert unauthenticated.status_code == 401 @@ -781,7 +865,7 @@ def test_start_analysis_run_recovers_the_a100_fork( "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-02-15T00:00:00Z", - "idempotency_key": "buyer-start-2026-w07", + "idempotency_key": "run-start-2026-w07", }, ) assert created.status_code == 201, created.text @@ -853,7 +937,7 @@ def test_start_analysis_run_recovers_the_a100_fork( "run_kind_code": "analysis_run_tepp", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-02-15T00:00:00Z", - "idempotency_key": "buyer-start-tepp-2026-w07", + "idempotency_key": "run-start-tepp-2026-w07", }, ) assert tepp_create.status_code == 422 @@ -887,7 +971,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'buyer-start-tepp-seeded', + values (%s, 'analysis_run_tepp', 'run-start-tepp-seeded', %s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s, '2026-02-15T12:30:00Z') returning analysis_run_id @@ -956,7 +1040,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_report', 'buyer-start-report', + values (%s, 'analysis_run_report', 'run-start-report', %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id @@ -999,7 +1083,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'buyer-start-running', + values (%s, 'analysis_run_lineage', 'run-start-running', %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id @@ -1072,7 +1156,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'buyer-start-outbox-resume', + values (%s, 'analysis_run_lineage', 'run-start-outbox-resume', %s, '2026-02-15T00:00:00Z', 'lineage-run-v1', %s, %s, '2026-02-15T12:30:00Z') returning analysis_run_id @@ -1127,7 +1211,30 @@ def test_start_analysis_run_recovers_the_a100_fork( assert "Pricing renegotiation: revised quote sent" in children -def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: +def test_me_reflects_the_authenticated_account(client, demo_analyst_token, seeded_db) -> None: + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute( + "select user_account_id from account_affiliation where corporate_entity_id = %s", + (seeded_db["own_corp_id"],), + ) + account_id = cur.fetchone()[0] + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "values (%s, 'TEST-PU', 'Test PU') returning process_unit_id", + (seeded_db["own_corp_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "update account_affiliation set process_unit_id = %s " + "where user_account_id = %s and corporate_entity_id = %s", + (process_unit_id, account_id, seeded_db["own_corp_id"]), + ) + admin_conn.commit() + finally: + admin_conn.close() + response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 body = response.json() @@ -1136,9 +1243,81 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No assert any( entity["entity_name"] == "Test Corp" for entity in body["corporate_entities"] ) + assert { + row["corporate_entity_code"] for row in body["account_affiliations"] + } == {"TEST-CORP", "GRANTED-CORP"} + affiliation = next( + row + for row in body["account_affiliations"] + if row["corporate_entity_id"] == seeded_db["own_corp_id"] + ) + assert affiliation == { + "corporate_entity_id": seeded_db["own_corp_id"], + "corporate_entity_code": "TEST-CORP", + "entity_name": "Test Corp", + "process_unit_id": affiliation["process_unit_id"], + "process_unit_code": "TEST-PU", + "process_unit_name": "Test PU", + } + + +def test_healthz_is_a_public_liveness_probe(client) -> None: + """Regression test: a dangling ``@app.get("/healthz")`` decorator once + attached to ``read_tenant_settings`` instead of the liveness probe, + requiring auth on ``/healthz`` and leaving the real ``healthz()`` + handler undecorated. Docker's own healthcheck (docker-compose.yml) + calls this route unauthenticated, so any auth requirement here breaks + container health and cascades into the whole compose dependency graph. + """ + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_settings_get_requires_auth_and_returns_brand_name(client, demo_analyst_token) -> None: + unauthenticated = client.get("/api/settings") + assert unauthenticated.status_code == 401 + + response = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + assert response.json()["brandName"] + + +def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_db) -> None: + denied = client.patch( + "/api/settings", + json={"brandName": "Should not apply"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert denied.status_code == 403 + + _grant_post_admin(seeded_db["dsn"]) + allowed = client.patch( + "/api/settings", + json={"brandName": "LineageWeave Demo"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert allowed.status_code == 200 + assert allowed.json() == {"brandName": "LineageWeave Demo"} + confirm = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert confirm.json() == {"brandName": "LineageWeave Demo"} -def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None: + +def test_customer_master_returns_authorized_catalog_contract( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"] + from backend.app import main as main_module + + relationship_entity_ids: list[str] = [] + original_relationship_network = main_module.fetch_relationship_network + + async def capture_relationship_entity_ids(conn, corporate_entity_ids): + relationship_entity_ids.extend(corporate_entity_ids) + return await original_relationship_network(conn, corporate_entity_ids) + + monkeypatch.setattr(main_module, "fetch_relationship_network", capture_relationship_entity_ids) admin_conn = psycopg2.connect(seeded_db["dsn"]) try: with admin_conn.cursor() as cur: @@ -1164,7 +1343,7 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["public_post_id"], @@ -1175,6 +1354,48 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys seeded_db["our_person_id"], ), ) + cur.execute( + "insert into corporate_entity " + "(parent_entity_id, corporate_entity_code, entity_name, entity_level_code) " + "values (%s, 'DEMO-GRANTED-CHILD', 'Demo Granted Child', 'company') " + "returning corporate_entity_id", + (seeded_db["granted_corp_id"],), + ) + demo_child_id = str(cur.fetchone()[0]) + cur.execute( + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "select user_account_id, %s, 'scope_granted_entity' " + "from user_account where external_subject_id = %s", + (demo_child_id, subject), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s), (%s, %s), (%s, %s), (%s, %s)", + ( + seeded_db["public_post_id"], + seeded_db["other_corp_id"], + seeded_db["other_private_post_id"], + seeded_db["hidden_corp_id"], + seeded_db["public_post_id"], + seeded_db["own_corp_id"], + seeded_db["public_post_id"], + demo_child_id, + ), + ) + cur.execute( + "insert into post_counterparty_entity " + "(post_id, counterparty_entity_name, relationship_type_code, verification_status_code) " + "values (%s, 'Private Other Corp', 'rel_voc', 'verify_pending')", + (seeded_db["other_private_post_id"],), + ) + cur.execute( + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "select user_account_id, %s, 'scope_granted_entity' " + "from user_account where external_subject_id = %s", + (seeded_db["own_group_id"], subject), + ) # A real counterparty can hold more than one role over its # lifetime -- one post classifies "Northridge Grid" as a # customer, a different visible post classifies the same @@ -1192,6 +1413,20 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys seeded_db["public_post_id"], ), ) + # Once imported source context exists, this affiliated child is + # synthetic-only and must be removed consistently from the tree, + # stale observed hierarchy facets, Keymen, and account hints. + cur.execute( + "insert into cataloged_person (person_name, person_side_code) " + "values ('Demo Grant Only', 'our_side') returning person_id", + ) + demo_person_id = cur.fetchone()[0] + cur.execute( + "insert into person_affiliation " + "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " + "values (%s, 'Demo Granted Child', %s)", + (demo_person_id, demo_child_id), + ) admin_conn.commit() finally: admin_conn.close() @@ -1215,7 +1450,18 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys # confirm this is a real common_lookup_value label, not the code echoed back. assert entity["entity_level_code"] == "company" assert entity["entity_level_label"] not in ("", "company") + assert entity["scope_facets"] == ["authorized_own", "observed_organization"] + parent = next(item for item in body["corporate_entities"] if item["entity_name"] == "Test Group") + assert parent["scope_facets"] == ["authorized_granted", "observed_hierarchy"] + granted = next(item for item in body["corporate_entities"] if item["entity_name"] == "Granted Corp") + assert granted["scope_facets"] == ["authorized_granted"] + assert not any(item["entity_name"] == "Demo Granted Child" for item in body["corporate_entities"]) + observed = next(item for item in body["corporate_entities"] if item["entity_name"] == "Other Corp") + assert observed["scope_facets"] == ["observed_organization"] + assert not any(item["entity_name"] == "Hidden Corp" for item in body["corporate_entities"]) assert isinstance(body["keymen"], list) + assert not any(item["person_name"] == "Other Corp Only" for item in body["keymen"]) + assert not any(item["person_name"] == "Demo Grant Only" for item in body["keymen"]) ada_west = next(item for item in body["keymen"] if item["person_name"] == "Ada West") assert ada_west["person_side_code"] == "our_side" # Live UI finding (2026-08-19): the Customer Master Keymen list falls @@ -1225,6 +1471,8 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys assert ada_west["person_side_label"] not in ("", "our_side") network = {row["counterparty_entity_name"]: row for row in body["relationship_network"]} + assert demo_child_id not in relationship_entity_ids + assert "Private Other Corp" not in network northridge = network["Northridge Grid"] assert northridge["multi_role"] is True assert {rel["relationship_type_code"] for rel in northridge["relationships"]} == {"rel_voc", "rel_voco"} @@ -1252,6 +1500,18 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys "provenance": "source_post.source_customer_code/source_post.source_customer_name", } ] + filtered_response = client.get( + "/api/customer-master?hint_code=TEST-CUSTOMER-001", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert filtered_response.status_code == 200 + assert filtered_response.json()["source_customer_hints"] == body["source_customer_hints"] + missing_response = client.get( + "/api/customer-master?hint_code=NOT-OBSERVED", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert missing_response.status_code == 200 + assert missing_response.json()["source_customer_hints"] == [] author_hint = body["source_author_hints"] assert len(author_hint) == 1 assert author_hint[0]["author_code"] == "TEST-AUTHOR-001" @@ -1279,9 +1539,147 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys affiliation["entity_name"] == "Test Corp" for affiliation in author_hint[0]["account_affiliations"] ) + assert any( + affiliation["entity_name"] == "Granted Corp" + for affiliation in author_hint[0]["account_affiliations"] + ) + assert not any( + affiliation["entity_name"] == "Demo Granted Child" + for affiliation in author_hint[0]["account_affiliations"] + ) assert "account_affiliation.corporate_entity_id" in author_hint[0]["provenance"] +def test_customer_master_scope_facets_reflect_authorization_and_observed_evidence( + client, demo_analyst_token, seeded_db +) -> None: + """ADR 0125: an entity's scope_facets must reflect exactly how it was + admitted -- an account's own affiliation, a granted affiliation, an + organization actually mentioned in a post the account may see -- and + private evidence must never add a node the account cannot otherwise see. + """ + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "update account_affiliation set affiliation_scope_code = 'scope_own_entity' " + "where corporate_entity_id = %s", + (seeded_db["own_corp_id"],), + ) + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('CASE-GRANTED-CORP', 'Case Granted Corp', 'company') returning corporate_entity_id" + ) + granted_corp_id = cur.fetchone()[0] + cur.execute( + "select user_account_id from account_affiliation where corporate_entity_id = %s", + (seeded_db["own_corp_id"],), + ) + account_id = cur.fetchone()[0] + cur.execute( + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "values (%s, %s, 'scope_granted_entity')", + (account_id, granted_corp_id), + ) + # Deliberately no scope_code override -- proves the column's + # own default lands new/unaudited affiliations on the honest + # "unclassified" state rather than a guessed own/granted label. + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('UNCLASSIFIED-CORP', 'Case Unclassified Corp', 'company') returning corporate_entity_id" + ) + unclassified_corp_id = cur.fetchone()[0] + cur.execute( + "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", + (account_id, unclassified_corp_id), + ) + # Observed via a post the account may see -- never affiliated, + # so it can only reach the response through evidence. + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('OBSERVED-CORP', 'Case Observed Corp', 'company') returning corporate_entity_id" + ) + observed_corp_id = cur.fetchone()[0] + # Enter the real-source branch used by production to hide + # demo-only entities once imported source context exists. + cur.execute( + "update source_post set source_customer_code = %s where post_id = %s", + ("CASE-CUSTOMER-001", seeded_db["own_private_post_id"]), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (seeded_db["own_private_post_id"], observed_corp_id), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (seeded_db["public_post_id"], observed_corp_id), + ) + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('DEMO-CAP-CORP', 'Demo Cap Corp', 'company') returning corporate_entity_id" + ) + demo_cap_corp_id = cur.fetchone()[0] + for post_id in ( + seeded_db["public_post_id"], + seeded_db["own_private_post_id"], + seeded_db["late_own_private_post_id"], + seeded_db["edited_own_post_id"], + ): + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (post_id, demo_cap_corp_id), + ) + for index in range(101): + cur.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values (%s, %s, 'company') returning corporate_entity_id", + (f"OBSERVED-FILLER-{index:03}", f"Observed Filler {index:03}"), + ) + filler_corp_id = cur.fetchone()[0] + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s)", + (seeded_db["own_private_post_id"], filler_corp_id), + ) + # Observed only via a private post from another corp -- must + # never surface, no matter how "real" the mention is. + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('HIDDEN-OBSERVED-CORP', 'Case Hidden Observed Corp', 'company') " + "returning corporate_entity_id" + ) + hidden_observed_corp_id = cur.fetchone()[0] + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (seeded_db["other_private_post_id"], hidden_observed_corp_id), + ) + admin_conn.commit() + finally: + admin_conn.close() + + response = client.get( + "/api/customer-master", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200 + entities = response.json()["corporate_entities"] + facets_by_name = {row["entity_name"]: set(row["scope_facets"]) for row in entities} + + assert facets_by_name["Test Corp"] == {"authorized_own"} + assert facets_by_name["Case Granted Corp"] == {"authorized_granted"} + assert facets_by_name["Case Unclassified Corp"] == set() + assert facets_by_name["Case Observed Corp"] == {"observed_organization"} + assert "Demo Cap Corp" not in facets_by_name + assert "Case Hidden Observed Corp" not in facets_by_name + observed_entities = [ + row for row in entities if "observed_organization" in row["scope_facets"] + ] + assert len(observed_entities) == 100 + + def test_resolve_customer_hint_creates_and_links_a_corroborated_entity( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: @@ -1381,10 +1779,127 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" assert {option["code"] for option in payload["voc_type_options"]} == {"voc"} + assert payload["source_detail_state_options"] == [] assert {option["code"] for option in payload["visibility_options"]} == {"public", "private"} assert next(option for option in payload["visibility_options"] if option["code"] == "public")["label"] == "Public" +def test_post_list_filters_and_lists_source_detail_state_codes( + client, demo_analyst_token, seeded_db +) -> None: + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute( + "select user_account_id from user_account " + "where email_address = 'other.analyst@example.test'" + ) + other_account_id = cur.fetchone()[0] + cur.execute( + """ + update source_post + set source_detail_state_code = case post_title + when 'Public post' then ' W ' + when 'Own-corp private post' then ' D ' + when 'Late own-corp private post' then ' A ' + when 'Edited own-corp private post' then ' W ' + else source_detail_state_code + end + where post_title in ( + 'Public post', 'Own-corp private post', + 'Late own-corp private post', 'Edited own-corp private post' + ) + """ + ) + cur.execute( + "update source_post set author_account_id = %s where post_title = %s", + (other_account_id, "Edited own-corp private post"), + ) + cur.execute( + "update source_post set corporate_entity_id = %s, visibility_code = 'private' " + "where post_title = %s", + (seeded_db["other_corp_id"], "Public post"), + ) + conn.commit() + finally: + conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + listed = client.get("/api/posts", headers=headers) + assert listed.status_code == 200, listed.text + assert { + option["code"] for option in listed.json()["source_detail_state_options"] + } == {"A", "D", "W"} + assert {post["post_title"] for post in listed.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + blank_filter = client.get("/api/posts?source_detail_state=", headers=headers) + assert blank_filter.status_code == 200, blank_filter.text + assert {post["post_title"] for post in blank_filter.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + blank_voc_filter = client.get("/api/posts?voc_type=", headers=headers) + assert blank_voc_filter.status_code == 200, blank_voc_filter.text + assert {post["post_title"] for post in blank_voc_filter.json()["posts"]} == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + } + + filtered = client.get("/api/posts?source_detail_state=D", headers=headers) + assert filtered.status_code == 200, filtered.text + assert [post["post_title"] for post in filtered.json()["posts"]] == [ + "Own-corp private post" + ] + + writing = client.get("/api/posts?source_detail_state=W", headers=headers) + assert writing.status_code == 200, writing.text + assert {post["post_title"] for post in writing.json()["posts"]} == {"Public post"} + + authored_detail = client.get( + f"/api/posts/{seeded_db['public_post_id']}", headers=headers + ) + assert authored_detail.status_code == 200, authored_detail.text + summary = client.get( + f"/api/posts/{seeded_db['public_post_id']}/summary", headers=headers + ) + assert summary.status_code == 422 + assert "not analysis targets" in summary.json()["detail"] + for derived_path in ( + "content", + "five-w1h", + "keymen", + "counterparties", + "lineage", + "knowledge-graph", + "evaluation", + "chat", + ): + derived = client.get( + f"/api/posts/{seeded_db['public_post_id']}/{derived_path}", headers=headers + ) + assert derived.status_code == 422, (derived_path, derived.text) + + hidden_detail = client.get( + f"/api/posts/{seeded_db['edited_own_post_id']}", headers=headers + ) + assert hidden_detail.status_code == 403 + + _grant_post_admin(seeded_db["dsn"]) + admin_list = client.get("/api/posts?source_detail_state=W", headers=headers) + assert admin_list.status_code == 200, admin_list.text + assert {post["post_title"] for post in admin_list.json()["posts"]} == { + "Public post", + "Edited own-corp private post", + } + + def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, seeded_db) -> None: response = client.get( "/api/posts?limit=1&offset=1", @@ -1435,7 +1950,7 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence( cur.execute( """ insert into post_project_mention - (post_id, project_key, project_name, evidence_text, confidence, + (post_id, project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method) values (%s, %s, %s, %s, %s, %s, %s) """, @@ -1541,7 +2056,7 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name) " "values (%s, 'Ada West', '후속 연락', 'prov_person', 'Demo Corp')", (seeded_db["public_post_id"],), ) @@ -1565,10 +2080,10 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token assert role["ontology_label"] == "Role actor (person)" -def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( +def test_stale_image_summary_is_returned_labeled_when_orchestrator_is_unavailable( client, demo_analyst_token, seeded_db ) -> None: - """A legacy saved summary preserves buyer continuity with an explicit label.""" + """A legacy image-post summary preserves continuity with an explicit label.""" os.environ.pop("ORCHESTRATOR_BASE_URL", None) os.environ.pop("ORCHESTRATOR_API_KEY", None) admin_conn = psycopg2.connect(seeded_db["dsn"]) @@ -1584,6 +2099,13 @@ def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( POST_SUMMARY_CONTRACT_VERSION - 1, ), ) + cur.execute( + "update source_post set post_body = %s where post_id = %s", + ( + 'Synthetic chart', + seeded_db["public_post_id"], + ), + ) finally: admin_conn.close() @@ -1598,6 +2120,44 @@ def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( assert body["korean_summary"] == "보관된 이전 계약 요약입니다." +def test_stale_text_summary_refreshes_when_orchestrator_is_available( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A stale summary is continuity evidence, not a permanent cache entry.""" + from lineageweave.post_summary import PostSummary + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + return PostSummary(korean_summary="새 계약으로 갱신된 요약입니다.") + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_summary_result " + "(post_id, korean_summary, summary_contract_version) values (%s, %s, %s)", + ( + seeded_db["public_post_id"], + "보관된 이전 계약 요약입니다.", + POST_SUMMARY_CONTRACT_VERSION - 1, + ), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/posts/{seeded_db['public_post_id']}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + assert response.json()["korean_summary"] == "새 계약으로 갱신된 요약입니다." + assert response.json()["summary_status"] == "current" + + def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None: """The same helper `make seed` calls must produce a row GET summary returns -- even with the orchestrator unset. @@ -2141,7 +2701,7 @@ def test_related_keymen_includes_chronological_role_history(client, demo_analyst ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["own_private_post_id"], @@ -2154,7 +2714,7 @@ def test_related_keymen_includes_chronological_role_history(client, demo_analyst ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["public_post_id"], @@ -3338,6 +3898,150 @@ def answer(self, question: str, sources) -> ChatAnswer: assert "What happened here that no seed already answers?" in events[0]["summary"] +def test_live_chat_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A provider exception becomes a stable 503 without its raw message.""" + class _FailingChatClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FailingChatClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/chat", + json={"question": "What happened in this provider failure case?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-provider-secret" not in response.text + + +def test_global_ask_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """The cross-post Ask boundary also returns a stable provider failure.""" + class _FailingAskClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-global-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FailingAskClient()) + + response = client.post( + "/api/ask", + json={"question": "What happened in this global failure case?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-global-provider-secret" not in response.text + + +def test_keymen_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Keymen provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingKeymanClient: + available = True + + def extract(self, post_title: str, post_body: str) -> object: + raise Exception("raw-keyman-provider-secret") + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FailingKeymanClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-keyman-provider-secret" not in response.text + + +def test_evaluation_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Evaluation provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingEvaluationClient: + available = True + + def evaluate(self, post_title: str, post_body: str) -> object: + raise Exception("raw-evaluation-provider-secret") + + monkeypatch.setattr( + "backend.app.main._post_evaluation_client", lambda: _FailingEvaluationClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/evaluate", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-evaluation-provider-secret" not in response.text + + +def test_commitment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Commitment provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingCommitmentClient: + available = True + + def extract(self, post_title: str, post_body: str, reference_date: str) -> object: + raise Exception("raw-commitment-provider-secret") + + monkeypatch.setattr( + "backend.app.main._commitment_extraction_client", lambda: _FailingCommitmentClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/derive-commitment", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-commitment-provider-secret" not in response.text + + +def test_summary_enrichment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Summary enrichment failures stay a stable 503 at the API boundary.""" + from lineageweave.post_summary import PostSummary + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + return PostSummary(korean_summary="합성 요약") + + async def _fail_persist(*args, **kwargs): + raise Exception("raw-summary-provider-secret") + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr("backend.app.main.persist_post_summary", _fail_persist) + + response = client.get( + f"/api/posts/{seeded_db['own_private_post_id']}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-summary-provider-secret" not in response.text + + def test_evaluate_is_unavailable_without_orchestrator(client, demo_analyst_token, seeded_db) -> None: os.environ.pop("ORCHESTRATOR_BASE_URL", None) os.environ.pop("ORCHESTRATOR_API_KEY", None) @@ -4022,6 +4726,8 @@ def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( assert commitments[0]["commitment_summary"] == "Send the revised quote" assert "visibility_code" not in commitments[0] assert "corporate_entity_id" not in commitments[0] + assert "author_account_id" not in commitments[0] + assert "source_detail_state_code" not in commitments[0] def test_calendar_keeps_real_ticket_when_demo_code_is_shared( @@ -4633,7 +5339,7 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture( client, demo_analyst_token, seeded_db ) -> None: """The first W02 report member must already have Event Lineage, - Keyman, and evaluation -- otherwise the buyer click opens a dummy + Keyman, and evaluation -- otherwise the reader click opens a dummy high/low band row. """ from lineageweave.fixtures import fixture_thread_cast, fixture_titles_in_iso_week @@ -4688,6 +5394,21 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture( first = report["members"][0] assert first["post_title"] in decorated, first["post_title"] assert not first["post_title"].startswith(("High-band", "Low-band")) + assert not { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } & first.keys() + for pair in report.get("leftover_pairs", []): + assert not { + "visibility_code", + "corporate_entity_id", + "author_account_id", + "source_detail_state_code", + "has_real_source_context", + } & pair.keys() threads = client.get("/api/reports/thread_group/2026-W02", headers=headers) a100 = next(report for report in threads.json()["reports"] if report["grouping_key"] == "A-100") diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py index 709d2c16e..b452f79e1 100644 --- a/backend/tests/test_auth_jwks.py +++ b/backend/tests/test_auth_jwks.py @@ -173,3 +173,50 @@ def test_decode_rejects_missing_subject(monkeypatch: pytest.MonkeyPatch) -> None with pytest.raises(HTTPException) as error: auth._decode_access_token("token", settings) assert error.value.status_code == 401 + + +def test_oidc_provider_failure_does_not_cross_the_auth_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identity-provider transport details stay out of the HTTP response.""" + def fail(*_args: object, **_kwargs: object) -> dict: + raise auth.HttpClientError("synthetic-provider-response") + + monkeypatch.setattr(auth, "get_json", fail) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_discovery_uri="https://id.example/.well-known/openid-configuration", + oidc_jwks_uri_override="", + ) + + with pytest.raises(HTTPException) as error: + auth._jwks(settings) + + assert error.value.status_code == 503 + assert error.value.detail == "could not fetch OIDC JWKS from the configured identity provider" + assert "synthetic-provider-response" not in str(error.value.detail) + + +def test_invalid_token_detail_does_not_cross_the_auth_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """JWT library diagnostics stay server-side through exception chaining.""" + monkeypatch.setattr(auth, "_signing_key", lambda settings, token: "signing-key") + monkeypatch.setattr( + auth.jwt, + "decode", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + auth.jwt.InvalidTokenError("synthetic-token-diagnostic") + ), + ) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_audience="lineageweave-api", + oidc_clock_skew_seconds=5, + ) + + with pytest.raises(HTTPException) as error: + auth._decode_access_token("token", settings) + + assert error.value.status_code == 401 + assert error.value.detail == "invalid access token" diff --git a/docker-compose.yml b/docker-compose.yml index 96ec0b89a..c48783a3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,7 +112,7 @@ services: # Gateway credentials and URL are supplied only by env_file (${HOME}/.env). # Do not repeat them under environment:, where Compose interpolation can # overwrite env_file values with an empty host-shell value. - # The upstream default remains 64 KiB for ordinary text APIs. Buyer + # The upstream default remains 64 KiB for ordinary text APIs. Post # image blocks are base64 data URIs, so the multimodal boundary gets an # explicit bounded 8 MiB limit rather than an unbounded request size. CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608} diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile index 69ebd26c5..049b0e975 100644 --- a/docker/keycloak/Dockerfile +++ b/docker/keycloak/Dockerfile @@ -1,5 +1,6 @@ FROM quay.io/keycloak/keycloak:26.0@sha256:09a381c715ab0b111835b70f2905955274843a219c6f27efb348e4d9f4086858 COPY realm-export.json /opt/keycloak/data/import/realm-export.json +COPY themes/lineageweave /opt/keycloak/themes/lineageweave # Official image's default non-root account (uid 1000). Declared so the # Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER 1000 diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/realm-export.json index be9826ea4..4f1920575 100644 --- a/docker/keycloak/realm-export.json +++ b/docker/keycloak/realm-export.json @@ -1,6 +1,8 @@ { "realm": "lineageweave-demo", "enabled": true, + "displayName": "LineageWeave", + "loginTheme": "lineageweave", "sslRequired": "none", "registrationAllowed": false, "accessTokenLifespan": 900, diff --git a/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css b/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css new file mode 100644 index 000000000..0ef285933 --- /dev/null +++ b/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css @@ -0,0 +1,77 @@ +/* + * LineageWeave brand overrides for the stock keycloak.v2 login theme. + * Loaded after the parent's css/styles.css (see theme.properties), so + * these rules win the cascade without needing !important. + * Values mirror frontend/src/styles/tokens.css so the OIDC redirect stays + * visually continuous with the app's own pre-redirect login card + * (UI/UX Standard Guide Ver.3.0 SS3.2). + */ + +.login-pf body { + background: #fff; + font-family: + "Noto Sans KR", + "Noto Sans", + "Nanum Gothic", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; +} + +.pf-v5-c-login, +.pf-v5-c-login__main { + font-family: inherit; +} + +/* + * Parent styles.css sets #kc-header-wrapper's color with !important + * (a light/white token meant to sit on the dark polygon background we + * just removed). Match it with !important so the brand text is legible + * on our white background instead of rendering white-on-white. + */ +#kc-header-wrapper { + color: #034ea2 !important; + font-weight: 700; + text-transform: none; + letter-spacing: normal; +} + +.pf-v5-c-login__main-body, +.card-pf { + background: #fff; + border: 1px solid #e5e4e7; + border-radius: 12px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.pf-v5-c-button.pf-m-primary { + background-color: #034ea2; + border-color: #034ea2; +} + +.pf-v5-c-button.pf-m-primary:hover, +.pf-v5-c-button.pf-m-primary:focus { + background-color: #0047bb; + border-color: #0047bb; +} + +a, +a:visited { + color: #034ea2; +} + +div.kc-logo-text { + background-image: none; + height: auto; + width: auto; +} + +div.kc-logo-text span { + display: inline; + color: #034ea2; + font-weight: 700; + font-size: 1.5rem; +} diff --git a/docker/keycloak/themes/lineageweave/login/theme.properties b/docker/keycloak/themes/lineageweave/login/theme.properties new file mode 100644 index 000000000..442ab2fde --- /dev/null +++ b/docker/keycloak/themes/lineageweave/login/theme.properties @@ -0,0 +1,4 @@ +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/lineageweave.css diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..c7194e1b3 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0131_*|0132_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0002-figma-access-boundary.md b/docs/adr/0002-figma-access-boundary.md index 90c213390..4d850afb3 100644 --- a/docs/adr/0002-figma-access-boundary.md +++ b/docs/adr/0002-figma-access-boundary.md @@ -4,6 +4,8 @@ **Date:** 2026-08-13 **Figma File ID:** `1Su3lDRmiZdcUs47t1QwIX` **Figma File URL:** https://www.figma.com/design/1Su3lDRmiZdcUs47t1QwIX +**Event Lineage desktop frame:** `5:14` +**Event Lineage mobile frame:** `5:15` ## Context @@ -40,9 +42,11 @@ statistic, or internal identifier observed while checking the file's metadata is repeated anywhere in this repository, in code, in docs, or in commit history. -The newly created file identified above is the safe design-system boundary -for LineageWeave's buyer surface. It currently contains no copied source -organization content; future token or component work must keep that boundary. +The separate Figma file identified above is the safe design-system boundary +for LineageWeave's buyer surface. It contains only sanitized, synthetic +LineageWeave content. Its local `LineageWeave / Buyer Surface Tokens` +collection mirrors the checked-in light/dark CSS tokens rather than copying +confidential source styles or values. ## Rationale @@ -55,23 +59,66 @@ organization content; future token or component work must keep that boundary. -- structurally mimicking it would risk reintroducing exactly the kind of identification this project has otherwise been careful to avoid. - Separately and independently of the confidentiality question: there is - currently no actual popup/Event-Lineage frame in the file to build + currently no actual popup/Event-Lineage frame in the source file to build against even if that concern didn't apply -- only a cover page exists. -- Guessing a "close enough" layout and *calling it* Figma-matched would - misrepresent a source that was neither consulted for its content nor - (yet) contains the relevant screen. +- Guessing a "close enough" layout and *calling it* source-Figma-matched would + misrepresent a source that was neither consulted for its content nor (yet) + contains the relevant screen. Sanitized LineageWeave-owned frames avoid that + claim while still providing an editable product design contract. ## Consequences - The popup UI ships and is tested (both backend contract and frontend - render logic) against the textual spec, not any Figma file content. -- If the organization later adds the actual popup frame to a Figma file - and wants a real design-to-code pass, that is a distinct, explicit - follow-up -- likely still needing the same care ADR 0001 already - established for identity/content (build the *mechanism* faithfully, - keep any organization-identifying specifics out of the public repo). -- This is consistent with, not an exception to, ADR 0001's reasoning -- - both ADRs name a real gap explicitly rather than fake or stall. + render logic) against the textual spec, not confidential source-file content. +- The sanitized LineageWeave Figma file may hold buyer-surface tokens, + interaction states, and synthetic frames whose IDs are recorded in ADRs. +- If the organization later adds the actual popup frame to the source Figma + file and wants a real design-to-code pass, that is a distinct, explicit + follow-up -- likely still needing the same care ADR 0001 already established + for identity/content. +- This is consistent with, not an exception to, ADR 0001's reasoning -- both + ADRs name a real gap explicitly rather than fake or stall. + +## 2026-08-21 Event Lineage DAG refinement + +The buyer DAG remains a **reconstructed record/Event Lineage view**, not a +complete OWL class/property explorer. Its horizontal position represents +lineage depth, not elapsed time. The safe buyer-surface refinement therefore +makes the existing meaning explicit instead of implying a different ontology +product: + +- parent-to-child edges have visible arrowheads and stop outside node circles; +- every node shows its source event date without pretending that X distance is + a duration scale; +- deep graphs keep their deterministic layout width inside a keyboard-focusable + horizontal region rather than shrinking labels into unreadability; +- the SVG is an accessible group, not an ARIA image that hides its interactive + descendant node controls; +- an open-by-default, collapsible exact-value table preserves each visible + relation, source/target date, and fused reconstruction score for keyboard, + touch, print, and audit use; +- a zero-edge group keeps its isolated root interactive without rendering an + empty evidence table; and +- branching, selected-node, isolated-root, and empty states are represented + with synthetic fixtures in Storybook. + +The editable Figma contract uses the same synthetic `DEMO-PROJECT` thread as +the product fixture and records two direct targets: + +- desktop: https://www.figma.com/design/1Su3lDRmiZdcUs47t1QwIX?node-id=5-14 +- mobile: https://www.figma.com/design/1Su3lDRmiZdcUs47t1QwIX?node-id=5-15 + +Both frames show direction, visible dates, a non-causal inference notice, and +the same exact `fused_score` evidence available in the implementation. The +mobile frame makes horizontal inspection explicit and changes the table into +stacked exact-value cards rather than shrinking the graph or clipping scores. +No confidential frame or production record is copied. + +A future graph that renders `Post`, `Person`, `CorporateEntity`, `Project`, +OWL properties, SKOS relations, provenance status, and temporal validity as +heterogeneous nodes and edges is a separate ontology-explorer capability and +must not be implied by this Event Lineage renderer. Buyer Gap #341 tracks that +separate product slice. ## Related diff --git a/docs/adr/0040-source-state-provenance.md b/docs/adr/0040-source-state-provenance.md index 33c70e923..1bf743154 100644 --- a/docs/adr/0040-source-state-provenance.md +++ b/docs/adr/0040-source-state-provenance.md @@ -33,3 +33,30 @@ The product can inspect original state evidence without losing distinctions between lifecycle dimensions. Until a source codebook is provided, users see codes rather than invented labels and the Board remains complete rather than silently excluding records. + +## Product display mapping + +For the current Board workflow, the product owner supplied a display +interpretation for the observed detail-state codes: + +- `W` — Writing in progress (`작성 중`) +- `D` — Pending approval (`결재 중`) +- `A` — Approved (`결재 완료`) + +This is a reader-facing explanation, not a rewrite of the raw source field or +an assertion that the source system's full codebook has been verified. The API +continues to return the raw code, unknown codes remain visible as unmapped, and +`source_draft_code` remains a separate signal. The Board may filter by the raw +detail-state code while showing this mapping beside it. + +## Writing-state access and derivation boundary + +W is an original-source record that is still being written. It is not a +service target. The author account and post_admin may open the raw source +record so the author can continue reviewing their own work, but W is excluded +from all derived reads and writes: summaries, 5W1H, ontology/Keyman and +relationship extraction, knowledge-graph projections, lineage, rankings, +reports, calendar commitments, chat/Ask sources, and content-analysis +projections. A persisted summary does not make W eligible; the API refuses +analysis requests and the summary backfill query excludes W. D and A remain +the service summary targets. diff --git a/docs/adr/0063-third-normal-form-bookmarks.md b/docs/adr/0063-third-normal-form-bookmarks.md index 4ba2f5b4f..95a83fea7 100644 --- a/docs/adr/0063-third-normal-form-bookmarks.md +++ b/docs/adr/0063-third-normal-form-bookmarks.md @@ -14,7 +14,7 @@ attributes. ## Decision -Use a `bookmark` table with: +Use a `post_bookmark` table with: - `bookmark_id` as the independent surrogate primary key; - `user_account_id` as a foreign key to `user_account`; @@ -25,10 +25,15 @@ Use a `bookmark` table with: The composite account/post pair is therefore a business invariant only, never the primary identity of the bookmark entity. No display name, post title, or -authorization scope is duplicated in `bookmark`; those values remain in their +authorization scope is duplicated in `post_bookmark`; those values remain in their normalized source tables. Every read and write still performs the normal visible-post ABAC check for the requesting account. +The historical migration introduced this entity as `bookmark`; ADR 0120 +renames the persistent relation to `post_bookmark` to enforce the repository's +two-word database-identifier rule. The entity and authorization decision stay +unchanged. + ## Consequences - A bookmark has a stable identifier and remains in third normal form: every diff --git a/docs/adr/0105-mathematical-script-semantic-normalization.md b/docs/adr/0105-mathematical-script-semantic-normalization.md new file mode 100644 index 000000000..839c3c98c --- /dev/null +++ b/docs/adr/0105-mathematical-script-semantic-normalization.md @@ -0,0 +1,29 @@ +# ADR 0105: Preserve explicit metric scripts in semantic text + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Source posts may encode a metric unit such as `m3` or an indexed +quantity such as `m3`. Removing the script element loses searchable +and buyer-visible mathematical meaning, while treating every numeric +superscript as mathematics would break the existing footnote contract. + +## Decision + +1. Preserve the original source body unchanged. +2. In derived semantic text, normalize only an explicit bounded metric base + (`m`, `cm`, `mm`, `km`, or `kg`, optionally preceded by a number) followed + by one-to-three numeric `sup` or `sub` elements into Unicode + superscript/subscript digits. For example, `5m3` becomes `5m³`. +3. Leave ordinary numeric superscripts on prose under the existing footnote + role contract. +4. Apply the same normalization in backend chunks and frontend rendering. + +## Consequences + +Metric exponents remain searchable and readable without inventing formula +semantics. Arbitrary mathematical markup beyond this bounded case remains an +explicit open gap and must be covered by a later ADR and fixture before being +normalized. diff --git a/docs/adr/0119-retire-buyer-terminology.md b/docs/adr/0119-retire-buyer-terminology.md new file mode 100644 index 000000000..4124e1e0a --- /dev/null +++ b/docs/adr/0119-retire-buyer-terminology.md @@ -0,0 +1,56 @@ +# ADR 0119: Retire "Buyer" as the reader-facing terminology + +**Status:** Accepted +**Date:** 2026-08-21 + +**Context:** ADR 0037 named the four-destination frontend shell the "Buyer +GNB" and the term spread into component names (`BuyerNav`, +`BuyerDestination`), CSS classes (`.buyer-gnb`), i18n keys ("Buyer +navigation"), a visible legend label ("BUYER EVIDENCE"), Python identifiers +(`_buyer_evidence_kind`), and prose across `AGENTS.md`, `ARCHITECTURE.md`, +and docstrings. LineageWeave has no explicit buyer actor — it is an internal +analyst/marketing-intelligence workspace, not a storefront with a buyer +role. "Buyer" was a leftover label from early drafting, not a modeled +domain entity, and reads as confusing or inaccurate to anyone reading the +code or product surface. + +**Decision:** +1. Rename the frontend navigation shell: `BuyerNav` → `WorkspaceNav`, + `BuyerDestination` → `WorkspaceDestination`, `.buyer-gnb*` CSS → + `.workspace-gnb*`, `.buyer-destination*` → `.workspace-destination*`, + the "Buyer navigation" i18n key/aria-label → "Workspace navigation", and + the `#mobile-buyer-navigation` id → `#mobile-workspace-navigation`. +2. Rename the Event Lineage legend label "BUYER EVIDENCE" → "LINEAGE + EVIDENCE". +3. Rename backend/Python identifiers that described the same concept: + `_buyer_evidence_kind` → `_cited_evidence_kind`, + `_buyer_evidence_text` → `_cited_evidence_text`. +4. Replace prose that referred to "the buyer" as the person reading the + product with "the reader" (docstrings, comments, `AGENTS.md`, + `ARCHITECTURE.md`, living docs under `docs/`) or with "workspace" where + the prose named the navigation shell itself. +5. Do not rewrite historical ADRs (0002–0118) or `CHANGELOG.md` / + `CHANGELOG.d/*.md` entries — those are point-in-time records of the + decisions and releases made under the terminology that existed then. + This ADR documents the rename going forward; historical documents keep + their original wording for an accurate record. +6. Leave "buyer" where it appears as ordinary English inside simulated + post/table content (`lineageweave/fixtures.py`, + `tests/test_chunking.py`) — that is domain content a real sales note + could plausibly contain, not this project's own naming. + +**Consequences:** +- No source, test, or living-doc identifier or user-facing string uses + "Buyer" going forward; `grep -ri buyer` outside historical ADRs, + `CHANGELOG*`, and fixture/test content returns nothing. +- Historical ADRs and changelog entries remain internally consistent with + the PRs they describe; readers encountering "Buyer GNB" in ADR 0037 or + CHANGELOG 2.13.0 know it is the old name for what this ADR renames. +- Component/file rename (`BuyerNav.tsx` → `WorkspaceNav.tsx`) is a breaking + change for any external Storybook story or import path that referenced + the old name; none exist outside this repo at the time of this ADR. + +**References:** +- ADR 0037 (Buyer GNB and product-facing frontend surface) — superseded + terminology only, decision content unchanged. +- ADR 0118 (UI·UX Standard Guide Ver.3.0 Design Overhaul) diff --git a/docs/adr/0120-two-word-database-identifiers.md b/docs/adr/0120-two-word-database-identifiers.md new file mode 100644 index 000000000..1849fe1c1 --- /dev/null +++ b/docs/adr/0120-two-word-database-identifiers.md @@ -0,0 +1,58 @@ +# ADR 0120: Normalize persistent database identifiers to two-word snake_case + +- Status: Accepted +- Date: 2026-08-21 +- Supersedes: the single-token naming exception in ADR 0063 and the + single-token column names inherited by the analysis/report/content slices + +## Context + +The product standard requires every persistent database object name to use at +least two lowercase `snake_case` words. The live schema still contains the +legacy `bookmark` relation and several single-token columns. Keeping those +names would make the database itself contradict the current product contract, +even though the surrounding application and ADRs describe a normalized model. + +## Decision + +Migration `0104_two_word_database_identifiers.sql` renames only persistent +database identifiers; it does not change payload semantics or public JSON +field names: + +| Existing identifier | Canonical identifier | +| --- | --- | +| `bookmark` | `post_bookmark` | +| `analysis_run_status_event.retryable` | `analysis_run_status_event.is_retryable` | +| `post_content_image.caption` | `post_content_image.image_caption` | +| `post_content_image_region.caption` | `post_content_image_region.image_caption` | +| `post_content_unit_structure.confidence` | `post_content_unit_structure.structure_confidence` | +| `post_project_mention.confidence` | `post_project_mention.mention_confidence` | +| `post_summary_role.responsibility` | `post_summary_role.responsibility_text` | +| `report_item_information.information` | `report_item_information.information_value` | +| `report_item_parameter.slope` | `report_item_parameter.item_slope` | +| `tenant_settings.id` | `tenant_settings.tenant_settings_id` | + +The `analysis_run_current_status` view is recreated with +`is_retryable`. Application-facing JSON continues to use established names +such as `information`, `caption`, and `responsibility`; those are translation +boundaries, not persistent database identifiers. The migration is idempotent +for fresh and already-initialized Compose volumes and has a matching rollback. + +ADR 0063 remains the source of the bookmark entity's third-normal-form and +authorization decisions; this ADR changes only its table identifier. + +## Consequences + +- Schema audits can enforce the two-word naming rule without exceptions. +- SQL, migrations, and database integration tests must use the canonical names. +- Public API compatibility is preserved at the application serialization + boundary. +- Historical migration files remain immutable; replay reaches the canonical + schema through the additive rename migration. + +## Verification + +Acceptance requires applying the migration to a real PostgreSQL volume, +replaying it twice, checking that no public table/view/column violates the +two-word rule, and exercising bookmark, image evidence, summaries, reports, +tenant settings, and analysis-run status queries. diff --git a/docs/adr/0121-mhtml-source-body-resolution.md b/docs/adr/0121-mhtml-source-body-resolution.md new file mode 100644 index 000000000..ece8670d4 --- /dev/null +++ b/docs/adr/0121-mhtml-source-body-resolution.md @@ -0,0 +1,46 @@ +# ADR 0121: Resolve source bodies from governed MHTML artifacts + +## Status + +Accepted + +## Context + +The authorized export used by the private runtime contains post metadata and +MHTML artifact provenance, but some rows do not contain a body column. ADR +0056/0057 prohibit turning a title, summary, or inferred content into a +source body. The importer therefore needs an explicit, auditable path from a +source row to its separately stored MHTML artifact. + +## Decision + +- A body-bearing import uses exactly one of two mappings: an explicit body + column, or an artifact path column plus an artifact SHA-256 column and an + operator-supplied artifact root. +- Artifact paths must resolve beneath the configured root after symlink + resolution. Missing files, traversal outside the root, non-regular files, + malformed hashes, and digest mismatches fail preflight before any target + mutation. +- The resolver accepts RFC 2557 `multipart/related` messages and selects the + first leaf `text/html` part as the source body. It never falls back to a + title, plain-text summary, generated content, or an unrelated MIME part. +- Every non-excluded source row is resolved and validated before the target + scope or any `source_post` row is written. The artifact bytes remain + operator-local; only the source body and existing provenance-bearing target + fields are persisted. +- The source UUID/record-key mapping remains explicit and independent from + the artifact path. An artifact match cannot repair a missing immutable + source identity. + +## Consequences + +The private runtime can consume an authorized MHTML export without weakening +the fail-closed source-body contract. A missing or incorrect artifact is an +actionable import error rather than a silently incomplete post. The public +repository continues to contain only synthetic artifact fixtures. + +## References + +Palme, J., Hopmann, A., & Shelness, N. (1999). *MIME encapsulation of +aggregate documents, such as HTML (MHTML)* (RFC 2557). RFC Editor. +https://www.rfc-editor.org/rfc/rfc2557.html diff --git a/docs/adr/0123-provider-error-boundary.md b/docs/adr/0123-provider-error-boundary.md new file mode 100644 index 000000000..48610ebeb --- /dev/null +++ b/docs/adr/0123-provider-error-boundary.md @@ -0,0 +1,57 @@ +# ADR 0123: Provider failures never become product error payloads + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Provider responses and exception messages can contain credentials, gateway +diagnostics, prompts, model output, or other internal transport detail. A +provider outage is not buyer evidence and must not be returned as an API error +or persisted as a durable ingestion detail. + +## Decision + +Every contextual-orchestrator, VISION, search, RankWeave, and TEPP boundary +returns a stable product-level unavailable message. Route handlers catch both +known transport/parse failures and unexpected provider exceptions, while +retaining the original exception only as an in-process chained cause for +operator logging. Provider response parsers use generic validation errors and +never interpolate the raw response into an exception message. All +OpenAI-compatible chat-completion consumers use the shared +``chat_completion_content`` validator, so malformed ``choices`` envelopes +cannot escape as raw ``KeyError`` or type-error payloads from a library +boundary. + +The browser API client is a second trust boundary: HTTP 5xx details are +discarded, and transport failures become a stable status-0 client error +before any UI handler can render them. Client-error details remain available +only for actionable validation or authorization responses. + +Missing or malformed evidence remains unavailable; it is never converted into +a fabricated negative result. Existing input-validation errors outside a +provider boundary retain their client-actionable 422 detail. + +## Consequences + +- API clients receive a safe retry/configuration action rather than provider + internals. +- Browser clients cannot turn an upstream 5xx detail or transport exception + into buyer-visible provider diagnostics. +- Server-side debugging keeps exception chaining without exposing it to buyers. +- Malformed provider success envelopes fail closed with a stable validation + error before any channel parser sees them. +- Regression tests exercise unexpected exceptions, not only known transport + subclasses, and assert that provider secrets do not appear in responses. + +## References — APA 7th + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2025). *Improper error handling*. OWASP Application +Security Verification Standard. https://owasp.org/www-project-application-security-verification-standard/ + +MITRE. (2026). *CWE-209: Generation of error message containing sensitive +information*. https://cwe.mitre.org/data/definitions/209.html diff --git a/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md b/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md new file mode 100644 index 000000000..2b1ddbe08 --- /dev/null +++ b/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md @@ -0,0 +1,72 @@ +# ADR 0124: Model operational controlled vocabularies as SKOS concepts + +## Status + +Accepted + +## Context + +`common_lookup_value` already centralizes configuration-like values used by +post visibility, VOC type, permissions, and issue-ticket state. The existing +OWL/RDFS vocabulary modeled the knowledge-graph predicates and actor types, +but left these operational codes as untyped strings. That weakened the +semantic layer exactly where the product exposes public/private access, +VOC/VOM/VOP classification, RBAC permissions, and ticket/calendar workflow. + +## Decision + +Represent these four lookup categories as SKOS concept schemes in +`docs/ontology/lineageweave-kg.ttl`: + +- `post_visibility`: public and private post visibility concepts; +- `voc_type`: VOC, VOCC, VOCO, VOM, and VOP concepts; +- `permission`: post-read and post-admin concepts; +- `ticket_status`: open, in-progress, and closed concepts. + +The relational lookup code remains the stable `:lookupCode` annotation and +PostgreSQL remains the source of record. OWL object properties describe the +semantic use of a concept (`Post -> hasPostVisibility`, `Post -> hasVocType`, +`AccessRole -> hasPermission`, and `IssueTicket -> hasTicketStatus`) without +turning workflow state into a knowledge-graph edge predicate. + +The ontology round-trip test now includes these categories. A code is not +considered semantically available merely because it exists in the database; +it must resolve to a SKOS concept with a scheme and label. + +## Options considered + +1. Keep operational values as database-only strings. Rejected: this leaves + authorization, filtering, and workflow semantics outside the governed + ontology. +2. Add a second runtime taxonomy database. Rejected: it duplicates the + existing normalized lookup source and creates synchronization risk. +3. Publish the existing lookup values as SKOS concepts over the current + relational source. Selected: it adds machine-readable semantics without a + second store or a change to the API wire codes. + +## Consequences + +Positive: + +- public/private, VOC classification, RBAC permissions, and ticket state have + stable IRIs, labels, schemes, and domain/range semantics; +- drift between seeded lookup values and the published ontology fails tests; +- consumers can use SKOS alongside the existing OWL/RDFS and PROV-O profile. + +Negative: + +- adding a new operational lookup value now requires an ontology term and a + round-trip test update; +- the current profile still does not model every analysis-run and content + processing status as a semantic concept, so those remain explicitly tracked + gaps rather than being silently treated as complete. + +## References (APA 7th) + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS Simple Knowledge +Organization System reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +World Wide Web Consortium. (2017). *Shapes Constraint Language (SHACL)*. +https://www.w3.org/TR/shacl/ + diff --git a/docs/adr/0125-customer-master-scope-facets.md b/docs/adr/0125-customer-master-scope-facets.md new file mode 100644 index 000000000..3099448be --- /dev/null +++ b/docs/adr/0125-customer-master-scope-facets.md @@ -0,0 +1,106 @@ +# ADR 0125 — Customer Master separates authorization scope from observed relationship facets + +**Decision status:** Proposed +**Date:** 2026-08-21 + +## Context + +`/api/customer-master` currently reads its `corporate_entities` list only from +`account_affiliation`. That is a valid authorization boundary, but it is not a +customer hierarchy: verified organization mentions and counterparty entities +created by ADR 0010 are not necessarily account affiliations. The resulting UI +can show an authorized employer while hiding an observed customer or affiliate, +and it cannot distinguish an account's own company from an explicitly granted +company because the current affiliation row has no such attribute. + +The fix must preserve the existing ABAC decision. A visible relationship in a +public or authorized post is evidence for navigation, not permission to read a +private post. An unresolved counterparty name is a hint, not a catalog entity. + +## Decision + +1. Keep `account_affiliation` as the authorization source. Every post and + entity returned by Customer Master remains subject to the existing + per-request visibility and source-post eligibility predicates. +2. Add an explicit, nullable `affiliation_scope_code` to + `account_affiliation`, backed by `common_lookup_value`, with the controlled + values `scope_own_entity`, `scope_granted_entity`, and + `scope_unclassified`. Existing rows are migrated to `scope_unclassified`; + no own/customer identity is inferred from a login token, a PU, a post title, + or a corporate name. Authentication continues to authorize every existing + affiliation row regardless of this display facet. +3. Extend the Customer Master entity contract with repeatable, provenance-bearing + `scope_facets`: `authorized_own`, `authorized_granted`, + `scope_unclassified`, `observed_organization`, and `observed_hierarchy`. + Multiple facets are + allowed because one organization may be both an authorized entity and an + observed counterparty in different evidence. +4. Build `observed_organization` only from a visible, eligible post's resolved + `post_organization_mention` (or an equivalently persisted, verified catalog + binding). `post_counterparty_entity` names that remain unresolved or + uncorroborated stay in the existing `source_customer_hints` / relationship + evidence surfaces and do not become tree nodes. +5. Traverse `parent_entity_id` only across entities already admitted by an + authorization or visible evidence path. If a parent is not admitted, render + the admitted child as a root; never widen access merely to complete a tree. +6. The UI's own-company/customer filters consume these facets and expose + `scope_unclassified` as an honest third state. No confirmation dialog or + guessed label is introduced. The API remains the single place that applies + authorization and provenance rules. + +## Implementation sequence + +The implementation is intentionally split so an ABAC regression cannot hide in +a large customer-tree change: + +1. Add the lookup values and nullable affiliation column with a migration and + update provisioning paths to write an explicit value. +2. Add an API integration test with own, granted, unclassified, visible + organization-mention, and private-post cases. Assert that private evidence + never adds a node and unresolved names remain hints. +3. Add the API contract and frontend filter/tree tests, then implement the + query projection and UI facets. +4. Backfill only from an authoritative account-scope source. Until that source + exists, retain `scope_unclassified`; do not infer it from the corpus. + +## Consequences + +- The tree can become useful without weakening ABAC: observed nodes are bounded + by visible evidence and do not authorize unrelated reads. +- Existing deployments will initially show an explicit unknown scope instead of + a misleading own/customer label. This is preferable to a silent false fact. +- `account_affiliation` remains a normalized authorization relation; the facet + is an attribute of that relation, not a second account-to-entity authority + table. A later need for time-bounded grants requires a separate ADR rather + than overloading this column. +- The entity query needs an entity-first index on persisted organization + mentions if live corpus size requires it. Add that index with the same + migration after measuring the query plan; do not pre-emptively shard a small + table. + +## Related decisions + +- [ADR 0004](0004-knowledge-graph-ontology.md) — ontology and semantic layer. +- [ADR 0010](0010-corporate-hierarchy-auto-creation.md) — verified counterparty + hierarchy creation. +- [ADR 0041](0041-source-context-vs-authorization-scope.md) — source context + must not be confused with authorization scope. +- [ADR 0042](0042-source-hints-before-customer-binding.md) — unresolved source + customer values remain hints. +- [ADR 0052](0052-plain-orchestrator-semantic-evidence.md) — semantic evidence + must retain provenance and uncertainty. + +## References (APA 7th) + +Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., & + Scarfone, K. (2019). *Guide to attribute based access control (ABAC) + definition and considerations* (NIST Special Publication 800-162, updated + 2019). National Institute of Standards and Technology. + https://doi.org/10.6028/NIST.SP.800-162 + +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust + architecture* (NIST Special Publication 800-207). National Institute of + Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + +World Wide Web Consortium. (2009). *SKOS simple knowledge organization system + reference*. https://www.w3.org/TR/skos-reference/ diff --git a/docs/adr/0126-global-ask-conversation-history.md b/docs/adr/0126-global-ask-conversation-history.md new file mode 100644 index 000000000..cf0ad2e0c --- /dev/null +++ b/docs/adr/0126-global-ask-conversation-history.md @@ -0,0 +1,42 @@ +# ADR 0126: Persisted Global Ask Conversation History + +* Status: Accepted +* Date: 2026-08-21 +* Supersedes: the non-persistence consequence in ADR 0090 + +## Context + +Global Ask currently keeps its rendered turns only in the browser. Leaving the +Ask destination loses the question history, which makes the product behave +differently from the conversation surface it presents. ADR 0090 deliberately +left persisted multi-turn state for a later phase; the reader surface now +explicitly requires that state. + +## Decision + +Persist Global Ask conversations and completed turns under the authenticated +`user_account`. Store the question, answer, next action, retrieved source-post +ids, cited post ids, and reader-safe evidence facts in normalized tables. A +conversation id is explicit API state; it is never represented as a fake +post-scoped orchestrator session id. + +The existing evidence retrieval and contextual-orchestrator boundary remain +unchanged. A turn is written only after the orchestrator returns a complete +answer object (including the authorized-no-source result). The history read +path owns only the requesting account's conversations and re-applies the +current post visibility rule before returning source titles, citations, or +evidence. + +This is transcript persistence, not a new long-context prompt contract. The +orchestrator continues to receive the current question and its bounded, +authorized evidence set. Conversation summarization or cross-turn reasoning +requires a separate ADR and upstream orchestrator contract. + +## Consequences + +* Ask history survives navigation and a new authenticated browser session. +* A user cannot read another account's conversation by changing a UUID. +* Revoked post visibility removes that post's source/citation projection from + history; the stored answer remains account-owned transcript data. +* The UI can select an existing conversation or start a new one without + changing the existing `/api/ask` evidence contract. diff --git a/docs/adr/0127-role-affiliation-catalog-identity.md b/docs/adr/0127-role-affiliation-catalog-identity.md new file mode 100644 index 000000000..ddb288a85 --- /dev/null +++ b/docs/adr/0127-role-affiliation-catalog-identity.md @@ -0,0 +1,47 @@ +# ADR 0127: R&R affiliations become separate catalog-backed organization evidence + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +R&R already preserved a person's or team's extracted +`affiliated_organization_name`, but only an organization actor could receive +the `post_summary_role` corporate-entity identity. A person such as a member +of a named company therefore remained free text, so the company could not +enter the post-scoped organization mention or Knowledge Graph projection. + +The same extraction also allowed a generic label such as `사업부` to become a +cataloged team without a named unit or affiliation. That is not an ontology +identity: a source process-unit code/name is source context and must not be +silently promoted into a team actor. + +## Decision + +- Add `cataloged_affiliated_corporate_entity_id` to `post_summary_role`. + It is separate from the actor's own catalog identity, so a person/team can + retain its own identity and its organization affiliation at the same time. +- Resolve an extracted R&R affiliation through the existing organization + name-resolution, hierarchy, and verification clients. Preserve the raw + extracted name; only a unique or verified catalog result receives the FK. + A miss, tie, or unavailable enrichment channel remains visibly unresolved. +- Write every resolved R&R affiliation to `post_organization_mention` so the + existing Knowledge Graph edge projection can expose the organization. +- Do not catalog generic team labels (`사업부`, `부서`, `팀`, `business unit`, + `department`, or `division`) without a specific named unit. Keep the source + process-unit code/name as separately labeled source evidence. +- Increase the summary contract version so existing summaries are regenerated + under the corrected extraction contract. + +## Consequences + +The popup can make a resolved affiliation clickable while still showing the +wording extracted from the source. It can also explain that an unnamed +business-unit label is not a resolved organization or team. Existing rows are +not guessed during migration; they receive the new identity when their +summary is regenerated with source evidence. + +## Related + +Extends ADR 0006, ADR 0009, ADR 0010, and ADR 0019. Source process-unit +display remains subject to ADR 0051: it is a hint, not a catalog binding. diff --git a/docs/adr/0128-source-grounded-quantitative-observations.md b/docs/adr/0128-source-grounded-quantitative-observations.md new file mode 100644 index 000000000..8b841cdba --- /dev/null +++ b/docs/adr/0128-source-grounded-quantitative-observations.md @@ -0,0 +1,42 @@ +# ADR 0128: Source-grounded quantitative observations + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +The summary projection preserves some evidence as free text, but a budget, +capacity, or counted asset cannot be reliably searched or displayed as an +independent semantic fact from that text alone. Numeric normalization must +also preserve what the source actually said: units, qualifiers, and the exact +supporting phrase. + +## Decision + +Store each source-grounded quantitative fact as one +`post_summary_quantitative_observation` row linked to its post. A row keeps: + +- a controlled measurement type and unit; +- the normalized numeric value, and an optional counted quantity such as + `2 tractors`; +- the source label, raw value text, qualifier text, and exact evidence text; +- the ontology IRI and extraction method. + +The contextual orchestrator must return these observations as part of the +summary semantic contract. The application does not create observations by a +local regex or by guessing from a filing timestamp. Missing orchestrator +output remains unavailable, while an existing stale summary remains clearly +stale. The source phrase remains the reader-facing evidence and the numeric +value is a search/filter projection, not a newly inferred business fact. + +The post list search and post detail API read the same normalized projection. +The ontology describes the observation class and controlled measurement/unit +terms, but observations are not promoted to a new polymorphic knowledge-graph +node until a graph edge contract is needed. + +## Consequences + +One fact with two capacities is represented by two observations, while the +count of each asset is retained on its corresponding capacity observation. +Regenerating a summary replaces the post-owned observation rows atomically +with the other summary projections. diff --git a/docs/adr/0129-standards-aligned-event-clue-ontology-profile.md b/docs/adr/0129-standards-aligned-event-clue-ontology-profile.md new file mode 100644 index 000000000..eaad8ebae --- /dev/null +++ b/docs/adr/0129-standards-aligned-event-clue-ontology-profile.md @@ -0,0 +1,146 @@ +# ADR 0129: Standards-aligned event and clue ontology profile + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +The product must answer questions such as `왜?` from a connected graph, not +from a body-text keyword hit. A key event therefore needs explicit, searchable +clues for its actors, action, time, place, cause, goal, object, result, +condition, quantity, and next step. A clue must retain the source evidence and +the assertion state; a guessed relationship must never look like an observed +fact. + +The existing `knowledge_graph_edge` table is a compact navigation projection. +It cannot represent the qualified evidence, literal values, selectors, or +inference provenance required by this use case. The semantic assertions and +event clues remain normalized source projections and can be joined into KG +rendering without pretending that every semantic row is already a polymorphic +KG node. + +## Decision + +Publish a standards-aligned ontology profile in +`docs/ontology/lineageweave-kg.ttl` and a validation profile in +`docs/ontology/lineageweave-shapes.ttl`. + +### Standard mappings + +- W3C PROV-O is the provenance backbone. Extracted assertions use + `prov:Entity`; extraction is a `prov:Activity`; qualified influence, + derivation, attribution, usage, association, delegation, and time remain + available through the existing `lineageweave.prov_o` registry. +- W3C Organization Ontology supplies organization, organizational-unit, + membership, role, reporting, and sub-organization patterns. A generic + `사업부` is not bound to a concrete unit without source evidence. +- OWL 2/RDFS supply class inheritance, inverse properties, property chains, + equivalence/disjointness, and restrictions. These are ontology axioms, not + rows in `knowledge_graph_edge`. +- OWL-Time supplies instant/interval and before/after/during relations. A + normalized date is retained with precision and normalization evidence. +- SOSA/SSN supplies the observation/result distinction. A + `QuantitativeObservation` or `EventObservation` in this product is an + extracted source assertion, not an asserted physical sensor act; it is + therefore aligned through `rdfs:seeAlso` and does not claim `sosa:Observation` + without an actual procedure, feature of interest, phenomenon time, and + result. +- Web Annotation supplies the evidence-target and selector pattern. A clue + may target an event, an assertion, or a source content unit. Exact source + text and position remain private runtime evidence and are not copied into + repository fixtures. +- SHACL supplies the machine-readable graph contract. It validates minimum + evidence, value types, and the distinction between extracted and inferred + assertions; it is also usable for UI generation and data integration. +- ODRL supplies normative rule vocabulary. A source condition such as “not + commercial” is a fact/condition only. It becomes a prohibition, permission, + or duty only when the source expresses a normative modality and action. +- QUDT is the external quantity/unit reference vocabulary. Local measurement + codes remain the application-controlled vocabulary and use conservative + `rdfs:seeAlso`/mapping annotations until an exact unit identity is verified. +- DCMI Terms supplies general metadata relations such as provenance, + references, and conformance for export-facing metadata. +- ISO/IEC 21838 BFO, ISO/IEC 19510 BPMN, and ISO 21127 CIDOC CRM are mapping + references for upper-level entity, process/event, and actor/event patterns. + ISO 14224, ISA-95/IEC 62264, OPC UA Events/Alarms, and ISO 31000 extend the + industrial asset, process, condition, maintenance, and risk vocabulary. + They are not imported as runtime axioms because their domain commitments are + broader than this product's evidence contract. + +### Profile extensions for meanings no single standard covers + +The profile may add a local term when no adopted vocabulary has the exact +meaning needed for source-grounded business correspondence. Every extension +must have: + +1. a precise definition and domain/range; +2. source evidence and assertion status requirements; +3. a nearest standard mapping (`rdfs:seeAlso`, `skos:closeMatch`, or a + qualified PROV relation), without overstating equivalence; +4. a declared inference policy; and +5. a decision on whether it is only a semantic-layer resource or is hydrated + into the compact KG projection. + +The first such extensions cover the whole source-to-question path: +`ObservationRecord`, `EventObservation`, `EvidenceClue`, `TemporalClaim`, +`OrganizationContext`, `IndustrialAsset`, `IndustrialProcess`, +`NormativeStatement`, `QualityAssessment`, `RiskStatement`, +`clueSupports`, `clueFor`, `hasCause`, `hasGoal`, `hasConsequence`, +`hasNextStep`, `assertionStatus`, and `inferenceRule`. They are not aliases +for W3C PROV properties: provenance uses the canonical PROV direction, while +these terms express the product's evidence and question-answering semantics. + +The source-facing dimensions are deliberately explicit: time, place, actor, +cause, purpose/goal, result, next step, quantity, condition, quality, risk, +and source segment. Organization and industrial context are modeled as +separate entities so a plant, team, equipment item, process, and company are +not collapsed into one actor label. Normative statements are separate from +descriptive conditions. + +### Assertion and inference boundary + +The graph renderer may show asserted, derived, and inferred paths, but must +label them separately. A standard inverse, subclass, subproperty, or property +chain is deterministic entailment. A business conclusion such as “this event +was caused by X” is not entailed merely because X appears nearby; it needs a +source clue or a qualified inference record. No local heuristic may silently +upgrade a clue into a fact. + +### Hydration rule + +Declaring a class or relation in the profile does not create a database node. +New `knowledge_graph_edge` node/edge lookup codes require a relational source, +authorization-aware hydration, evidence rows, and a graph regression test. +Until then, the semantic layer exposes the resource and the Ask retriever may +join it as a provenance-bearing fact. + +## Consequences + +- Event-centered retrieval can traverse `event -> clue -> source unit -> + post -> KG neighbor`, then answer `why` with a visible evidence path. +- Numeric, temporal, conditional, and role clues remain independently + searchable without copying private source values into repository artifacts. +- The profile is broad enough for future event/entity classes while refusing + false certainty where a standard does not define the business meaning. +- Shapes are a contract and regression guard; they do not replace the + orchestrator's source-grounded extraction or database authorization. + +## References + +- W3C PROV-O: https://www.w3.org/TR/prov-o/ +- W3C Organization Ontology: https://www.w3.org/TR/vocab-org/ +- W3C OWL-Time: https://www.w3.org/TR/owl-time/ +- W3C SOSA/SSN 2023: https://www.w3.org/TR/vocab-ssn-2023/ +- W3C Web Annotation Data Model: https://www.w3.org/TR/annotation-model/ +- W3C SHACL: https://www.w3.org/TR/shacl/ +- W3C ODRL Information Model: https://www.w3.org/TR/odrl-model/ +- QUDT Schema: https://www.qudt.org/doc/2025/03/DOC_SCHEMA-QUDT.html +- ISO/IEC 21838-2:2021 BFO: https://www.iso.org/standard/74572.html +- OMG BPMN 2.0: https://www.omg.org/spec/BPMN/2.0 +- CIDOC CRM / ISO 21127:2023: https://cidoc-crm.org/Event/iso-211272023-has-been-released +- DCMI Metadata Terms: https://www.dublincore.org/specifications/dublin-core/dcmi-terms/ +- ISO 14224: https://www.iso.org/standard/64076.html +- ISA-95 / IEC 62264: https://www.isa.org/standards-and-publications/isa-standards/isa-95-standard +- OPC UA Event Model: https://reference.opcfoundation.org/specs/OPC-10000-3/4.7 +- OPC UA Alarms and Conditions: https://reference.opcfoundation.org/Core/Part9/v105/docs/4.1 +- ISO 31000: https://committee.iso.org/sites/tc262/home/projects/published/iso-31000-2009-risk-management.html diff --git a/docs/adr/0130-source-commercial-context-hints.md b/docs/adr/0130-source-commercial-context-hints.md new file mode 100644 index 000000000..b972342f2 --- /dev/null +++ b/docs/adr/0130-source-commercial-context-hints.md @@ -0,0 +1,33 @@ +# ADR 0130: Source commercial-context combination hints + +## Status + +Accepted + +## Decision + +The import boundary accepts explicit mappings for source customer, order-pool, +sales-order, sales-order-item, and inspection/status-point fields. Raw values +are retained on `source_post` with their source-state fields; the importer does +not infer a catalog identity or a lifecycle label from a code. + +The product computes a small combination code from field presence. For the +sales-order-item field, a positive numeric value is present and the source +zero sentinel is absent. Combination labels such as +`customer_only_candidate` and `no_sales_identifier_candidate` are explicitly +inferred candidates, not facts. The exact raw lifecycle vector remains visible +alongside the inference. + +The same bounded hint is passed to contextual-orchestrator, Ask Agent evidence, +and the post knowledge-graph view. Customer-name resolution continues through +the existing corroborated customer-hint path; a raw customer code never creates +a catalog entity by itself. + +## Rationale + +Independent null rates cannot distinguish a customer-only record from an +order-pool record or an order item. The current source distribution shows that +the four presence bits form multiple materially different populations. Keeping +the combination deterministic, provenance-bearing, and weakly labeled gives +lineage reconstruction and readers the useful distinction without turning +unknown SAP codes into invented semantics. diff --git a/docs/adr/0131-explicit-organization-project-relations.md b/docs/adr/0131-explicit-organization-project-relations.md new file mode 100644 index 000000000..d0785f2bd --- /dev/null +++ b/docs/adr/0131-explicit-organization-project-relations.md @@ -0,0 +1,27 @@ +# ADR 0131: Explicit organization-to-project semantic relations + +## Status + +Accepted + +## Decision + +The semantic relationship contract may persist an organization-to-project +`lw_supports` relation only when the source explicitly assigns that +organization work, ownership, contracting, or support for the named project. +`lw_supports` is a LineageWeave profile property with `prov:Agent` domain and +`prov:Entity` range. It is not a PROV alias and does not create an inferred +inverse, ownership, or causal edge. + +Explicit organization membership uses W3C Organization Vocabulary +`org_member_of` (`org:memberOf`). A project mention, affiliation, or shared +meeting does not create either relation. Unresolved names remain text in the +qualified semantic table and its evidence-bearing navigation projection. + +## Consequences + +- A post can show distinct organization-to-project responsibilities instead + of collapsing every organization under one project label. +- The graph remains a navigation projection; evidence and confidence stay in + `post_summary_semantic_relationship`. +- Attendance-only actors remain event clues, not role/responsibility rows. diff --git a/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md b/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md new file mode 100644 index 000000000..a808a268c --- /dev/null +++ b/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md @@ -0,0 +1,197 @@ +# ADR 0132 — TEPP topic-lineage consumption boundary (TRSL-TM + CHRONOS/TDT) + +**Decision status:** Accepted +**Implementation maturity:** boundary-accepted; evidence/inference/prediction +mark primitive implemented (`frontend/src/components/EvidenceStatusMark.tsx`, +below); DAG topic-thread wiring and runtime remain open +**Date:** 2026-08-22 +**Depends on:** ADR 0022 (authorized TEPP start); ADR 0064 (lineage evidence +and tree assembly); ADR 0084 (research-grounded lineage and ontology policy) +**Refs:** TEPP ADR 0012 (Temporal Relational Shared-Latent Topic +Measurement); TEPP ADR 0016 (TDT, CHRONOS, and Event Ontology intelligence +boundary) + +## Context + +ADR 0084 already separates mention detection / lineage-instance +construction (LineageWeave's own evidence-fusion engine) from calibrated +measurement, which stays a TEPP wire-contract boundary that is never +reimplemented locally. That separation did not yet have a topic-identity +counterpart: `zcrht811_export_rows` board posts are scattered across time +with no thread connecting a post to the earlier/later posts that share its +underlying commercial topic (a competitor mention, a market trend, a sales +opportunity) the way a Git branch connects commits. + +TEPP's own accepted-target architecture already defines this problem +precisely: + +- TEPP ADR 0012 adopts **Temporal Relational Shared-Latent Topic + Measurement (TRSL-TM)**: one global topic identity set per modeled + period, where topics may be active, dormant, or reactivated over time + without losing identity, and topic birth/split/merge/retirement is an + explicit lineage extension — not an implicit side effect of fitting + unrelated time slices. +- TEPP ADR 0016 separates Event Ontology, TDT-style detection/tracking, and + CHRONOS-style reasoning (semantic/neural event-schema + extraction/prediction plus symbolic temporal-consistency reasoning). + Every output carries an evidence / inference / prediction status and + provenance; a prediction is never silently converted into historical + fact. + +TEPP is currently a foundation-slice (`crates/tepp_api` exposes only +`AnalysisRunRequest` / `AnalysisRunAccepted`; a completed-result contract is +still open — TEPP issue #156). Standing up a local topic model or event +predictor to fill that gap would repeat exactly the "invented psychometric +substitute" ADR 0084 and the tepp-readiness discipline already forbid. + +## Decision + +1. LineageWeave does not compute topic identity, topic + birth/split/merge/retirement, event-schema predictions, or temporal- + consistency verdicts locally. These remain TEPP's TRSL-TM (ADR 0012) and + CHRONOS/TDT (ADR 0016) computations, requested through `tepp_client` the + same way `analysis_run_start.py` requests a measurement run (ADR 0022). +2. A new analysis-run kind, topic-lineage, is added alongside the existing + lineage / TEPP / period-report kinds (ADR 0013 registry). Requesting it + builds a TEPP request payload from the run's idempotency key, snapshot + digest, knowledge cutoff, and corporate-entity workspace id — never post + bodies or a fabricated topic label — and submits through `TeppClient`. +3. An empty `TEPP_TRANSPORT_URL`, or a TEPP response that omits the + versioned topic-identity/CHRONOS-status envelope, appends Failed with a + machine-readable reason (`tepp_not_available` / + `tepp_topic_contract_unavailable`), mirroring ADR 0022. Failed is + terminal; the operator reconnects TEPP and re-requests. +4. When TEPP does publish the topic-identity and CHRONOS-status envelope, + LineageWeave persists it into a new topic-identity-thread projection + (3NF, two-word snake_case, partitioned by corporate-entity + observed + period to avoid a hot partition on the shared post table) that links + existing posts to TEPP topic ids and carries each edge's evidence / + inference / prediction status verbatim. This projection extends — it + does not replace — the existing evidence-fusion lineage tree (ADR + 0064/0084); a post can appear in both the fusion-based lineage DAG and a + TEPP topic thread, and the UI keeps the two visually distinct. +5. The frontend Event Lineage DAG gains a topic-thread overlay: nodes/edges + sourced from TEPP render with a distinct visual channel (color/pattern + token, not color alone, for accessibility) per evidence / inference / + prediction status, and render nothing (not a placeholder guess) for a + topic-lineage run that is Pending or Failed. Storybook stories cover + Pending, Failed (`tepp_not_available`, `tepp_topic_contract_unavailable`), + and each CHRONOS status; a Playwright e2e spec exercises the golden path + once a topic-identity envelope exists and the fail-closed path when it + does not. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant TeppClient + participant Registry + Operator->>API: POST /api/analysis-runs (kind=topic_lineage) + API->>Registry: create Pending topic_lineage run + Operator->>API: POST /api/analysis-runs/{id}/start + Registry->>Registry: Running + API->>TeppClient: TopicLineageRequest v1 (TRSL-TM + CHRONOS/TDT) + alt TeppNotAvailable + Registry->>Registry: Failed tepp_not_available + else envelope lacks topic-identity/CHRONOS contract + Registry->>Registry: Failed tepp_topic_contract_unavailable + else versioned envelope present + Registry->>Registry: Succeeded; persist topic-identity threads + CHRONOS status + end + API-->>Operator: run status + evidence/inference/prediction detail +``` + +### Implementation note: the status-mark primitive ships ahead of the wiring + +Decision item 5's "distinct visual channel, not color alone" requirement is +implemented now as `EvidenceStatusMark` (`frontend/src/components/ +EvidenceStatusMark.tsx`, i18n in `evidenceStatusI18n.ts`, tokens in +`styles/tokens.css`): a reusable badge distinguishing evidence / inference / +prediction by label text and glyph shape (`●` / `◆` / `△`) in addition to +color, satisfying WCAG 1.4.1 with redundant, testable channels (see its +Storybook stories and `EvidenceStatusMark.test.tsx`). It is presentational +only — every call site must supply `status` from a real TEPP-sourced +envelope; the component never infers or invents one. Wiring it into +`LineageDag`'s topic-thread overlay is the remaining step once TEPP issue +#156 publishes the topic-identity/CHRONOS-status envelope this ADR's +decision 3-4 depend on; until then, no topic-lineage run reaches Succeeded, +so there is no envelope to source a `status` prop from. + +No Figma frame exists yet for this primitive (cf. ADR 0002's precedent for +recording that gap rather than fabricating a frame reference); add the file +ID here when a designer produces one. + +Decision 5's fail-closed Playwright half also ships ahead of the wiring: +`frontend/e2e/topic-lineage-fail-closed.spec.ts` (config in +`frontend/playwright.config.ts`, `npm run test:e2e`) walks the seeded +Failed topic-lineage row end to end — Keycloak login, the ADR 0014 +`kind · status · entity` caption, the connect-a-transport next action, the +detail-only machine failure code, and the absence of any evidence/ +inference/prediction mark without a TEPP envelope. On a deployment holding +real imported source data the spec skips itself with a named reason, +because ADR 0001/0042 deliberately hide the synthetic Demo Corp runs +there; it runs fully against the seed-only stack `make seed` builds. The +golden-path e2e half still waits on TEPP issue #156. + +## Considered alternatives + +1. **Fit a local LDA/BERTopic-style model over `zcrht811_export_rows` now, + swap to TEPP later** — rejected: an ungrounded local topic model is + exactly the invented substitute ADR 0084/CLAUDE.md forbid, and its + outputs (unstable topic identity, no posterior uncertainty, no temporal- + identity contract) would not be swap-compatible with TRSL-TM's + logistic-normal, posterior-bearing topic coordinates. +2. **Treat the existing evidence-fusion lineage DAG (ADR 0064/0084) as + sufficient and skip a topic-identity dimension** — rejected: fusion-based + lineage links posts by lexical/embedding/temporal similarity, not by a + calibrated, longitudinally stable topic identity; it cannot express + topic birth/split/merge/retirement the way TRSL-TM can. +3. **Block all topic-lineage UI until TEPP ships the full contract** — + rejected: the fail-closed Pending/Failed states are themselves a + product (ADR 0022 precedent), and the UI/Storybook/e2e scaffolding can + and should be built and reviewable now so the feature activates the + moment TEPP's contract lands, instead of starting from zero then. + +## Consequences + +- LineageWeave's topic-lineage feature is fully specified and reviewable + before TEPP exposes its topic-identity/CHRONOS envelope; only the + transport needs to be connected once TEPP is ready (tracked in memory + `tepp-readiness-watch` and TEPP issue #156). +- The evidence / inference / prediction distinction from TEPP ADR 0016 + propagates end to end — API response, DB row, and UI rendering — so a + CHRONOS prediction can never be flattened into a fact anywhere in this + product. +- Coordination cost: this ADR's runtime activation depends on a + cross-repository contract change in TEPP; `docs/product-technical-gap- + baseline.md` must track that dependency explicitly rather than mark this + gap closed prematurely. + +## References — APA 7th + +ContextualWisdomLab. (2026). *TEPP* [Computer software]. GitHub. +https://github.com/ContextualWisdomLab/TEPP + +ContextualWisdomLab. (2026). *ADR 0012: Temporal relational shared-latent +topic measurement* [ADR]. GitHub. +https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md + +ContextualWisdomLab. (2026). *ADR 0016: TDT, CHRONOS, and Event Ontology +intelligence boundary* [ADR]. GitHub. +https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md + +Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for +structural topic models. *Journal of Statistical Software, 91*(2), 1–40. +https://doi.org/10.18637/jss.v091.i02 + +Mimno, D., Wallach, H. M., Naradowsky, J., Smith, D. A., & McCallum, A. +(2009). Polylingual topic models. In *Proceedings of the 2009 Conference on +Empirical Methods in Natural Language Processing* (pp. 880–889). Association +for Computational Linguistics. https://aclanthology.org/D09-1092/ + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based +information organization*. Springer. https://doi.org/10.1007/978-1-4615-0933-2 + +Kalashnikov, D. V., Chen, Z., Mehrotra, S., & Nuray-Turan, R. (2007). CHRONOS: +Facilitating history discovery by linking temporal records. *Proceedings of +the VLDB Endowment*. https://doi.org/10.14778/2367502.2367559 diff --git a/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md new file mode 100644 index 000000000..5e5607975 --- /dev/null +++ b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md @@ -0,0 +1,29 @@ +# Customer Master scope references + +This register grounds the proposed Customer Master scope-facet decision in +authoritative access-control and semantic-hierarchy sources. Keep the +authorization boundary, observed evidence, and catalog identity separate when +implementing ADR 0125. + +## Evidence mapping + +| Source | Product decision | +| --- | --- | +| NIST SP 800-162 | Treat account, resource, action, and environment attributes as inputs to an ABAC decision; do not turn a display classification into a permission grant. | +| NIST SP 800-207 | Re-evaluate access at the resource boundary and minimize implicit trust; visible relationship evidence cannot widen private-post access. | +| W3C SKOS | Represent the corporate hierarchy as a broader/narrower concept relation while retaining the evidence and authorization facets separately. | + +## APA 7th references + +Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., & +Scarfone, K. (2019). *Guide to attribute based access control (ABAC) +definition and considerations* (NIST Special Publication 800-162, updated +2019). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-162 + +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust +architecture* (NIST Special Publication 800-207). National Institute of +Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + +World Wide Web Consortium. (2009). *SKOS simple knowledge organization system +reference*. https://www.w3.org/TR/skos-reference/ diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 717954045..7ae1afbdc 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -9,7 +9,7 @@ the Storybook inventory. | Source | Product implication | Implemented evidence | |---|---|---| | W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. | -| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | +| Storybook for React & Vite | Catalog repeated controls so a reader can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | | WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. | ## APA 7th references diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index d8e490b80..7574db752 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -93,7 +93,7 @@ picture sat relative to the surrounding paragraphs." The demo popup does not yet read these tables. It splits the live `post_body` the same way `extract_base64_images` does: each `data:image/...;base64,...` payload becomes an `` at its original -character offset, and the surrounding HTML is shown as text. A buyer who +character offset, and the surrounding HTML is shown as text. A reader who opens the post sees the picture that sat between the paragraphs, not the base64 wall. Remote `src="https://..."` tags are stripped, never fetched. OCR, caption, and tag search still require the vision client on extract / diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 94f99d73d..cfde3151b 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -322,7 +322,7 @@ node, and an unresolved free-text affiliation is left as its own root rather than attached to the nearest name. VOC evidence is extractive, not abstractive. The post already carries a -closed `voc_type_code`; the buyer-felt gap was the missing span that +closed `voc_type_code`; the reader-felt gap was the missing span that justifies that label. `sentence_excerpts` returns the sentences that contain a classified organization name -- the ACE mention extent (Doddington et al., 2004) already used for Keyman -- and returns @@ -370,11 +370,11 @@ source limit -- expanding every keyword hit instead of only the top one was rejected because a loosely related term would otherwise drag in an unrelated lineage chain into the model's context. -Global Ask's chat turns are not yet persisted as a running conversation -- -each question is answered independently, so there is no multi-turn -context to compress. Recursive dialogue summarization (Wang et al., 2023) -is the grounding this repository would use if/when Global Ask grows a -persisted conversation thread that can exceed a bounded context window: +Global Ask's transcript is now persisted per authenticated account under ADR +0126, but each question is still answered independently, so there is no +multi-turn context to compress. Recursive dialogue summarization (Wang et al., +2023) is the grounding this repository would use if/when Global Ask grows a +context-aware conversation thread that can exceed a bounded context window: summarize-and-replace older turns instead of an unbounded transcript or a hard truncation that silently drops earlier decisions. This is recorded here as the citation this feature would build on, not as a claim that diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index d9156d332..d978a1848 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -6,15 +6,28 @@ @prefix xsd: . @prefix prov: . @prefix org: . +@prefix sh: . +@prefix time: . +@prefix sosa: . +@prefix ssn: . +@prefix qudt: . +@prefix odrl: . +@prefix oa: . +@prefix dcterms: . ################################################################# # LineageWeave Knowledge Graph Ontology # # The formal OWL 2 / RDFS / SKOS vocabulary for the # `knowledge_graph_edge` table's node/edge types, the -# `entity_relationship_type` / `person_side` / `corporate_entity_level` -# controlled vocabularies in migrations/0001_initial_schema.sql, and +# `entity_relationship_type` / `person_side` / `corporate_entity_level`, +# `post_visibility` / `voc_type` / `permission` / `ticket_status` controlled +# vocabularies in migrations/0001_initial_schema.sql, and # `post_summary_role.actor_type_code` (migrations/0012). +# The semantic assertion classes and verbs below are backed by normalized +# summary/content tables. They are not silently promoted to +# `knowledge_graph_edge` node/edge codes; the navigation projection remains +# the compact graph and provenance/semantic assertions remain qualified data. # # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- # [edge_type_code] --> (target_node_type_code, target_node_id) is @@ -22,6 +35,7 @@ # this file is the formal semantic layer over it -- PostgreSQL stays # the source of record. See docs/adr/0004-knowledge-graph-ontology.md # for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md +# and docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md # for the R&R actor-type rationale (grounded in W3C PROV-O), and # tests/test_ontology.py for the round-trip check that every code below # actually exists as a common_lookup_value row, and vice versa. @@ -33,7 +47,7 @@ a owl:Ontology ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . + rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge graph, operational controlled vocabularies, and post-summary actor types." . :lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; @@ -44,11 +58,13 @@ ################################################################# :Post a owl:Class ; + rdfs:subClassOf prov:Entity ; rdfs:label "Post" ; rdfs:comment "A source_post row: one VOC/VOM/VOP/VOCC/VOCO/VOS record." ; :lookupCode "node_post" . :Person a owl:Class ; + rdfs:subClassOf prov:Person ; rdfs:label "Person" ; rdfs:comment "A cataloged_person row: a Keyman mentioned in one or more posts." ; :lookupCode "node_person" . @@ -64,7 +80,7 @@ :lookupCode "counterparty" . :CorporateEntity a owl:Class ; - rdfs:subClassOf skos:Concept ; + rdfs:subClassOf prov:Organization, org:Organization, skos:Concept ; rdfs:label "Corporate entity" ; rdfs:comment "A corporate_entity row. Also a skos:Concept so the self-referencing parent_entity_id hierarchy (e.g. Group -> Company -> Plant) is expressible with skos:broader/skos:narrower on instances." ; :lookupCode "node_corporate_entity" . @@ -97,6 +113,7 @@ :affiliatedWith a owl:ObjectProperty ; rdfs:domain :Person ; rdfs:range :CorporateEntity ; + rdfs:subPropertyOf org:memberOf ; rdfs:label "affiliated with" ; rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; :lookupCode "edge_affiliation" . @@ -127,6 +144,7 @@ :teamAffiliatedWith a owl:ObjectProperty ; rdfs:domain :Team ; rdfs:range :CorporateEntity ; + rdfs:subPropertyOf org:unitOf ; rdfs:label "team affiliated with" ; rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; :lookupCode "edge_team_affiliation" . @@ -160,19 +178,131 @@ :hasVoccRelationship a owl:ObjectProperty ; rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:subPropertyOf :hasCounterpartyRelationship ; rdfs:label "has Voice-of-Customer's-Customer relationship" ; :lookupCode "rel_vocc" . :hasVocoRelationship a owl:ObjectProperty ; rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:subPropertyOf :hasCounterpartyRelationship ; rdfs:label "has Voice-of-Competitor relationship" ; :lookupCode "rel_voco" . :hasVosRelationship a owl:ObjectProperty ; rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:subPropertyOf :hasCounterpartyRelationship ; rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . +################################################################# +# Operational controlled vocabularies +# +# These values are persisted in common_lookup_value and participate in +# authorization, filtering, and workflow state. They are SKOS concepts, +# not KG edge predicates: the object properties below make the semantic +# relationship explicit without pretending that a visibility or ticket +# status is a graph edge. +################################################################# + +:postVisibilityScheme a skos:ConceptScheme ; + rdfs:label "Post visibility scheme"@en . + +:PublicVisibility a skos:Concept ; + skos:inScheme :postVisibilityScheme ; + skos:prefLabel "Public"@en ; + :lookupCode "public" . + +:PrivateVisibility a skos:Concept ; + skos:inScheme :postVisibilityScheme ; + skos:prefLabel "Private"@en ; + :lookupCode "private" . + +:vocTypeScheme a skos:ConceptScheme ; + rdfs:label "Voice-of relationship type scheme"@en . + +:VoiceOfCustomer a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Customer"@en ; + :lookupCode "voc" . + +:VoiceOfCustomersCustomer a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Customer's Customer"@en ; + :lookupCode "vocc" . + +:VoiceOfCompetitor a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Competitor"@en ; + :lookupCode "voco" . + +:VoiceOfMarket a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Market"@en ; + :lookupCode "vom" . + +:VoiceOfPartner a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Partner"@en ; + :lookupCode "vop" . + +:permissionScheme a skos:ConceptScheme ; + rdfs:label "Application permission scheme"@en . + +:ReadPostsPermission a skos:Concept ; + skos:inScheme :permissionScheme ; + skos:prefLabel "Read posts"@en ; + :lookupCode "post_read" . + +:AdministerPostsPermission a skos:Concept ; + skos:inScheme :permissionScheme ; + skos:prefLabel "Administer posts"@en ; + :lookupCode "post_admin" . + +:ticketStatusScheme a skos:ConceptScheme ; + rdfs:label "Issue ticket status scheme"@en . + +:OpenTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "Open"@en ; + :lookupCode "open" . + +:InProgressTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "In progress"@en ; + :lookupCode "in_progress" . + +:ClosedTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "Closed"@en ; + :lookupCode "closed" . + +:IssueTicket a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Issue ticket"@en . + +:hasPostVisibility a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range skos:Concept ; + rdfs:label "has post visibility"@en . + +:hasVocType a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range skos:Concept ; + rdfs:label "has Voice-of type"@en . + +:AccessRole a owl:Class ; + rdfs:label "Access role"@en . + +:hasPermission a owl:ObjectProperty ; + rdfs:domain :AccessRole ; + rdfs:range skos:Concept ; + rdfs:label "has permission"@en . + +:hasTicketStatus a owl:ObjectProperty ; + rdfs:domain :IssueTicket ; + rdfs:range skos:Concept ; + rdfs:label "has ticket status"@en . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# @@ -201,6 +331,107 @@ :GroupLevel skos:narrower :CompanyLevel . :CompanyLevel skos:narrower :PlantLevel . +################################################################# +# Source-grounded quantitative observations (ADR 0128) +################################################################# + +:measurementTypeScheme a skos:ConceptScheme ; + rdfs:label "Measurement type scheme"@en . + +:BudgetAmountMeasurement a skos:Concept ; + skos:inScheme :measurementTypeScheme ; + skos:prefLabel "Budget amount"@en ; + :lookupCode "measurement_budget_amount" . + +:CapacityMeasurement a skos:Concept ; + skos:inScheme :measurementTypeScheme ; + skos:prefLabel "Capacity"@en ; + :lookupCode "measurement_capacity" . + +:DailyCapacityMeasurement a skos:Concept ; + skos:inScheme :measurementTypeScheme ; + skos:prefLabel "Daily capacity"@en ; + :lookupCode "measurement_daily_capacity" . + +:measurementUnitScheme a skos:ConceptScheme ; + rdfs:label "Measurement unit scheme"@en . + +:KoreanWonUnit a skos:Concept ; + skos:inScheme :measurementUnitScheme ; + skos:prefLabel "Korean won"@en ; + :lookupCode "unit_krw" . + +:KilogramUnit a skos:Concept ; + skos:inScheme :measurementUnitScheme ; + skos:prefLabel "Kilogram"@en ; + :lookupCode "unit_kg" . + +:TractorUnit a skos:Concept ; + skos:inScheme :measurementUnitScheme ; + skos:prefLabel "Tractor"@en ; + :lookupCode "unit_tractor" . + +:factTypeScheme a skos:ConceptScheme ; + rdfs:label "Source fact type scheme"@en . + +:ConditionFactType a skos:Concept ; + skos:inScheme :factTypeScheme ; + skos:prefLabel "Source condition"@en ; + :lookupCode "fact_condition" . + +:DateFactType a skos:Concept ; + skos:inScheme :factTypeScheme ; + skos:prefLabel "Source date"@en ; + :lookupCode "fact_date" . + +:factAssertionScheme a skos:ConceptScheme ; + rdfs:label "Fact assertion scheme"@en . + +:AffirmedAssertion a skos:Concept ; + skos:inScheme :factAssertionScheme ; + skos:prefLabel "Affirmed"@en ; + :lookupCode "assertion_affirmed" . + +:NegatedAssertion a skos:Concept ; + skos:inScheme :factAssertionScheme ; + skos:prefLabel "Negated"@en ; + :lookupCode "assertion_negated" . + +:UnknownAssertion a skos:Concept ; + skos:inScheme :factAssertionScheme ; + skos:prefLabel "Unknown"@en ; + :lookupCode "assertion_unknown" . + +:BroadFactTypeScheme a skos:ConceptScheme ; + rdfs:label "Broad source fact type scheme"@en . + +:ObservationFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Observation fact"@en ; :lookupCode "fact_observation" . +:OrganizationFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Organization fact"@en ; :lookupCode "fact_organization" . +:IndustrialAssetFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Industrial asset fact"@en ; :lookupCode "fact_industrial_asset" . +:IndustrialProcessFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Industrial process fact"@en ; :lookupCode "fact_industrial_process" . +:NormativeFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Normative fact"@en ; :lookupCode "fact_normative" . +:QualityFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Quality fact"@en ; :lookupCode "fact_quality" . +:RiskFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Risk fact"@en ; :lookupCode "fact_risk" . +:PlaceFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Place fact"@en ; :lookupCode "fact_place" . +:ActorFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Actor fact"@en ; :lookupCode "fact_actor" . +:CauseFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Cause fact"@en ; :lookupCode "fact_cause" . +:GoalFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Goal fact"@en ; :lookupCode "fact_goal" . +:ResultFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Result fact"@en ; :lookupCode "fact_result" . +:NextStepFactType a skos:Concept ; skos:inScheme :BroadFactTypeScheme ; + skos:prefLabel "Next-step fact"@en ; :lookupCode "fact_next_step" . + ################################################################# # Classes -- prov_agent_type (post_summary_role.actor_type_code) # @@ -225,23 +456,300 @@ ################################################################# :RoleActorPerson a owl:Class ; - rdfs:subClassOf prov:Person ; + rdfs:subClassOf :RoleActorAgent, prov:Person ; rdfs:label "Role actor (person)" ; rdfs:comment "An R&R actor that is a named individual, per prov:Person." ; :lookupCode "prov_person" . :RoleActorOrganization a owl:Class ; - rdfs:subClassOf prov:Organization ; + rdfs:subClassOf :RoleActorAgent, prov:Organization, org:Organization ; rdfs:label "Role actor (organization)" ; rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; :lookupCode "prov_organization" . :RoleActorTeam a owl:Class ; - rdfs:subClassOf org:OrganizationalUnit ; + rdfs:subClassOf :RoleActorAgent, org:OrganizationalUnit ; rdfs:label "Role actor (team)" ; rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ; :lookupCode "prov_team" . +# PROV-O also defines software agents. They are valid acting parties in a +# responsibility assertion, but remain unbound to person/team/organization +# catalog identities unless a future source-grounded catalog relation exists. +:RoleActorAgent a owl:Class ; + rdfs:subClassOf prov:Agent ; + rdfs:label "Role actor (agent)"@en ; + rdfs:comment "Abstract acting-party class for a persisted role responsibility."@en . + +:RoleActorSoftwareAgent a owl:Class ; + rdfs:subClassOf :RoleActorAgent, prov:SoftwareAgent ; + rdfs:label "Role actor (software agent)"@en ; + rdfs:comment "An R&R actor that is a bot, scheduler, or other software agent, per prov:SoftwareAgent."@en ; + :lookupCode "prov_software_agent" . + +################################################################# +# Semantic resources and ontology verbs +# +# These are normalized, evidence-bearing resources. They deliberately do +# not become compact knowledge_graph_edge predicates; their inverse links +# expose provenance and navigation without losing qualification fields. +################################################################# + +:SemanticAssertion a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Semantic assertion"@en . + +:PostSummary a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Post summary"@en . + +:KeyEvent a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Key event"@en . + +:RoleResponsibility a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Role responsibility"@en . + +:MajorEventAction a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Major event action"@en . + +:FiveW1HClaim a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "5W1H claim"@en . + +:PostChatResult a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Post chat result"@en . + +:ContentUnit a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Content unit"@en . + +:ImageRegion a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Image region"@en . + +:Project rdfs:subClassOf prov:Entity . + +:hasSemanticAssertion a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :SemanticAssertion ; + rdfs:label "has semantic assertion"@en . + +:hasSummary a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :PostSummary ; + rdfs:label "has summary"@en . + +:summaryOf a owl:ObjectProperty ; + rdfs:domain :PostSummary ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasSummary ; + rdfs:label "summary of"@en . + +:hasKeyEvent a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :KeyEvent ; + rdfs:label "has key event"@en . + +:keyEventOf a owl:ObjectProperty ; + rdfs:domain :KeyEvent ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasKeyEvent ; + rdfs:label "key event of"@en . + +:hasRoleResponsibility a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :RoleResponsibility ; + rdfs:label "has role responsibility"@en . + +:roleResponsibilityOf a owl:ObjectProperty ; + rdfs:domain :RoleResponsibility ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasRoleResponsibility ; + rdfs:label "role responsibility of"@en . + +:hasMajorEventAction a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :MajorEventAction ; + rdfs:label "has major event action"@en . + +:majorEventActionOf a owl:ObjectProperty ; + rdfs:domain :MajorEventAction ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasMajorEventAction ; + rdfs:label "major event action of"@en . + +:hasFiveW1HClaim a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :FiveW1HClaim ; + rdfs:label "has 5W1H claim"@en . + +:fiveW1HClaimOf a owl:ObjectProperty ; + rdfs:domain :FiveW1HClaim ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasFiveW1HClaim ; + rdfs:label "5W1H claim of"@en . + +:hasChatResult a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :PostChatResult ; + rdfs:label "has chat result"@en . + +:chatResultOf a owl:ObjectProperty ; + rdfs:domain :PostChatResult ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasChatResult ; + rdfs:label "chat result of"@en . + +:hasProjectMention a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :ProjectMention ; + rdfs:label "has project mention"@en . + +:projectMentionOfPost a owl:ObjectProperty ; + rdfs:domain :ProjectMention ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasProjectMention ; + rdfs:label "project mention of post"@en . + +:projectMentionFor a owl:ObjectProperty ; + rdfs:domain :ProjectMention ; + rdfs:range :Project ; + rdfs:label "project mention for"@en . + +:hasQuantitativeObservation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :QuantitativeObservation ; + rdfs:label "has quantitative observation"@en . + +:quantitativeObservationOf a owl:ObjectProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasQuantitativeObservation ; + rdfs:label "quantitative observation of"@en . + +:hasSourceGroundedFact a owl:ObjectProperty ; + rdfs:subPropertyOf :hasSemanticAssertion ; + rdfs:domain :Post ; + rdfs:range :SourceGroundedFact ; + rdfs:label "has source-grounded fact"@en . + +:sourceGroundedFactOf a owl:ObjectProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasSourceGroundedFact ; + rdfs:label "source-grounded fact of"@en . + +:hasCommitmentTicket a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :IssueTicket ; + rdfs:label "has commitment ticket"@en . + +:commitmentTicketOf a owl:ObjectProperty ; + rdfs:domain :IssueTicket ; + rdfs:range :Post ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + owl:inverseOf :hasCommitmentTicket ; + rdfs:label "commitment ticket of"@en . + +:hasContentUnit a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :ContentUnit ; + rdfs:label "has content unit"@en . + +:contentUnitOf a owl:ObjectProperty ; + rdfs:domain :ContentUnit ; + rdfs:range :Post ; + owl:inverseOf :hasContentUnit ; + rdfs:label "content unit of"@en . + +:hasImageRegion a owl:ObjectProperty ; + rdfs:domain :ContentUnit ; + rdfs:range :ImageRegion ; + rdfs:label "has image region"@en . + +:imageRegionOf a owl:ObjectProperty ; + rdfs:domain :ImageRegion ; + rdfs:range :ContentUnit ; + owl:inverseOf :hasImageRegion ; + rdfs:label "image region of"@en . + +:actorName a owl:DatatypeProperty ; + rdfs:domain :RoleResponsibility ; + rdfs:range xsd:string . + +:responsibilityText a owl:DatatypeProperty ; + rdfs:domain :RoleResponsibility ; + rdfs:range xsd:string . + +:actorType a owl:ObjectProperty ; + rdfs:domain :RoleResponsibility ; + rdfs:range :RoleActorAgent . + +:SemanticRelationship a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Semantic relationship"@en ; + rdfs:comment "An explicit relation extracted from source text with a controlled predicate, endpoints, evidence, and confidence."@en . + +:responsibleFor a owl:ObjectProperty ; + rdfs:domain :RoleActorAgent ; + rdfs:range :SemanticAssertion ; + rdfs:label "responsible for"@en . + +:supports a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:range prov:Entity ; + rdfs:label "supports"@en ; + rdfs:comment "LineageWeave profile relation for an explicit source statement that an agent supports an entity such as a named project. It is not inferred from co-occurrence or role evidence."@en . + +:subjectName a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:subjectType a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:predicateCode a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:objectName a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:objectType a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:relationEvidence a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:string . + +:relationConfidence a owl:DatatypeProperty ; + rdfs:domain :SemanticRelationship ; + rdfs:range xsd:decimal . + ################################################################# # organization_name_resolution (raw/canonical organization-name pairs) # @@ -266,6 +774,7 @@ rdfs:comment "A business project referred to by a source post."@en . :ProjectMention a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; rdfs:label "Project mention"@en ; rdfs:comment "An evidence-backed semantic assertion that a post refers to a project."@en . @@ -279,3 +788,778 @@ :semanticConfidence a owl:DatatypeProperty ; rdfs:range xsd:decimal . + +:QuantitativeObservation a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Quantitative observation"@en ; + rdfs:comment "A source-grounded numeric fact preserved with its raw value, unit, qualifier, and evidence."@en . + +:hasQuantitativeObservation a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :QuantitativeObservation . + +:numericValue a owl:DatatypeProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range xsd:decimal . + +:measurementUnit a owl:DatatypeProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range xsd:string . + +:measurementQualifier a owl:DatatypeProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range xsd:string . + +:sourceEvidence a owl:DatatypeProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range xsd:string . + +:SourceGroundedFact a owl:Class ; + rdfs:subClassOf :SemanticAssertion ; + rdfs:label "Source-grounded fact"@en ; + rdfs:comment "A source-backed condition or date fact with explicit normalization evidence."@en . + +:hasSourceGroundedFact a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :SourceGroundedFact . + +:factValue a owl:DatatypeProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range xsd:string . + +:factNormalizedDate a owl:DatatypeProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range xsd:date . + +:factAssertion a owl:DatatypeProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range xsd:string . + +:factNormalizationEvidence a owl:DatatypeProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range xsd:string . + +:factSourceEvidence a owl:DatatypeProperty ; + rdfs:domain :SourceGroundedFact ; + rdfs:range xsd:string ; + rdfs:label "fact source evidence"@en . + +################################################################# +# Broad standards profile -- observation, time, organization, industry, +# normative meaning, quality/risk, documents, and evidence paths (ADR 0129) +################################################################# + +# The following classes are semantic-layer resources. They become hydrated KG +# nodes only when a source table, authorization-aware projection, and evidence +# contract are added. Declaring them here never fabricates a database node. +:ObservationRecord a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Observation record"@en ; + rdfs:comment "An extracted, source-grounded observation record; not necessarily a physical sensor observation."@en ; + rdfs:seeAlso sosa:Observation . + +:Event a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Event"@en ; + rdfs:comment "A transient or time-bounded occurrence represented by source evidence."@en ; + rdfs:seeAlso . + +:EventObservation a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Event observation"@en ; + rdfs:comment "An evidence-bearing assertion that a source describes an event; it is not asserted as sosa:Observation without a procedure, feature, time, and result."@en ; + rdfs:seeAlso sosa:Result . + +:Place a owl:Class ; + rdfs:subClassOf prov:Location ; + rdfs:label "Place"@en ; + rdfs:comment "A source-grounded location or site label; it is not geocoded without an authorized source."@en . + +:Observation a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Observation"@en ; + rdfs:seeAlso sosa:Observation . + +:Activity a owl:Class ; + rdfs:subClassOf prov:Activity ; + rdfs:label "Activity"@en . + +:TemporalEntity a owl:Class ; + rdfs:label "Temporal entity"@en ; + rdfs:seeAlso time:TemporalEntity . + +:EvidenceClue a owl:Class ; + rdfs:subClassOf prov:Entity, oa:Annotation ; + rdfs:label "Evidence clue"@en ; + rdfs:comment "A source-grounded clue that can connect an observation to an actor, time, place, cause, purpose, result, next step, quantity, condition, or source segment."@en . + +:TemporalClaim a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Temporal claim"@en ; + rdfs:seeAlso time:TemporalEntity . + +:OrganizationContext a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Organization context"@en ; + rdfs:comment "Source-grounded organization, unit, membership, reporting, or role context."@en ; + rdfs:seeAlso org:Membership . + +:IndustrialAsset a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Industrial asset"@en ; + rdfs:comment "Equipment, facility, device, material, or other industrial object named by source evidence."@en ; + rdfs:seeAlso ; + rdfs:seeAlso . + +:IndustrialProcess a owl:Class ; + rdfs:subClassOf prov:Activity ; + rdfs:label "Industrial process"@en ; + rdfs:comment "A manufacturing, logistics, maintenance, engineering, or operational process supported by source evidence."@en ; + rdfs:seeAlso . + +:IndustrialCondition a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Industrial condition"@en ; + rdfs:comment "A source-grounded operating, availability, alarm, quality, or state condition."@en ; + rdfs:seeAlso . + +:FailureEvent a owl:Class ; + rdfs:subClassOf :Event ; + rdfs:label "Failure event"@en ; + rdfs:seeAlso . + +:MaintenanceAction a owl:Class ; + rdfs:subClassOf prov:Activity ; + rdfs:label "Maintenance action"@en ; + rdfs:seeAlso . + +:NormativeStatement a owl:Class ; + rdfs:subClassOf :ObservationRecord, odrl:Rule ; + rdfs:label "Normative statement"@en ; + rdfs:comment "An explicit permission, prohibition, duty, constraint, or policy statement; descriptive conditions are not normative by default."@en ; + rdfs:seeAlso odrl:Rule . + +:NormativeConstraint a owl:Class ; + rdfs:subClassOf :NormativeStatement, odrl:Constraint ; + rdfs:label "Normative constraint"@en . + +:NormativePermission a owl:Class ; + rdfs:subClassOf :NormativeStatement, odrl:Permission ; + rdfs:label "Normative permission"@en . + +:NormativeProhibition a owl:Class ; + rdfs:subClassOf :NormativeStatement, odrl:Prohibition ; + rdfs:label "Normative prohibition"@en . + +:NormativeDuty a owl:Class ; + rdfs:subClassOf :NormativeStatement, odrl:Duty ; + rdfs:label "Normative duty"@en . + +:QualityAssessment a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Quality assessment"@en ; + rdfs:comment "A source-grounded assessment of data, product, process, or service quality."@en ; + rdfs:seeAlso . + +:RiskStatement a owl:Class ; + rdfs:subClassOf :ObservationRecord ; + rdfs:label "Risk statement"@en ; + rdfs:comment "An explicit source statement about uncertainty, exposure, risk cause, or consequence."@en ; + rdfs:seeAlso . + +:Document a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Document"@en ; + rdfs:seeAlso dcterms:BibliographicResource . + +:Actor a owl:Class ; + rdfs:subClassOf prov:Agent ; + rdfs:label "Actor"@en . + +:Stakeholder a owl:Class ; + rdfs:subClassOf :Actor ; + rdfs:label "Stakeholder"@en . + +:ActorRole a owl:Class ; + rdfs:subClassOf prov:Role, org:Role ; + rdfs:label "Actor role"@en . + +:Membership a owl:Class ; + rdfs:subClassOf prov:Entity, org:Membership ; + rdfs:label "Organization membership"@en . + +:RoleActorPerson rdfs:subClassOf :Actor . +:RoleActorOrganization rdfs:subClassOf :Actor . +:RoleActorTeam rdfs:subClassOf :Actor . +:RoleActorSoftwareAgent rdfs:subClassOf :Actor . +:CorporateEntity rdfs:subClassOf org:FormalOrganization . +:Team rdfs:subClassOf org:OrganizationalUnit . + +# Event, observation, clue, source, and question-answering relations. +:hasObservation a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :ObservationRecord ; + rdfs:label "has observation"@en . + +:observationOf a owl:ObjectProperty ; + rdfs:domain :ObservationRecord ; + rdfs:range :Post ; + owl:inverseOf :hasObservation ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + rdfs:label "observation of"@en . + +:hasEventObservation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasObservation ; + rdfs:domain :Post ; + rdfs:range :EventObservation ; + rdfs:label "has event observation"@en . + +:eventObservationOf a owl:ObjectProperty ; + rdfs:domain :EventObservation ; + rdfs:range :Post ; + owl:inverseOf :hasEventObservation ; + rdfs:subPropertyOf :observationOf . + +:observesEvent a owl:ObjectProperty ; + rdfs:domain :EventObservation ; + rdfs:range :Event ; + rdfs:label "observes event"@en . + +:eventObservedBy a owl:ObjectProperty ; + rdfs:domain :Event ; + rdfs:range :EventObservation ; + owl:inverseOf :observesEvent . + +:hasEvidenceClue a owl:ObjectProperty ; + rdfs:domain :ObservationRecord ; + rdfs:range :EvidenceClue ; + rdfs:label "has evidence clue"@en . + +:clueFor a owl:ObjectProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range prov:Entity ; + owl:inverseOf :hasEvidenceClue ; + rdfs:label "clue for"@en . + +:clueSupports a owl:ObjectProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf oa:hasTarget ; + rdfs:label "clue supports"@en . + +:clueSource a owl:ObjectProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf prov:hadPrimarySource ; + rdfs:label "clue source"@en . + +:clueText a owl:DatatypeProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range xsd:string . + +:clueTargetLabel a owl:DatatypeProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range xsd:string . + +:normalizedValueText a owl:DatatypeProperty ; + rdfs:domain :EvidenceClue ; + rdfs:range xsd:string . + +:assertionStatus a owl:ObjectProperty ; + rdfs:domain :ObservationRecord ; + rdfs:range skos:Concept ; + rdfs:label "assertion status"@en . + +:inferredFrom a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + rdfs:label "inferred from"@en . + +:inferenceRule a owl:DatatypeProperty ; + rdfs:domain prov:Entity ; + rdfs:range xsd:string . + +:inferenceMethod a owl:DatatypeProperty ; + rdfs:domain prov:Entity ; + rdfs:range xsd:string . + +# Explicit clue dimensions. These are profile extensions because no single +# standard gives this exact source-grounded business-question vocabulary. +:hasTimeClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has time clue"@en . +:hasPlaceClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has place clue"@en . +:hasActorClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has actor clue"@en . +:hasActionClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has action clue"@en . +:hasCauseClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has cause clue"@en . +:hasGoalClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has goal clue"@en . +:hasObjectClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has object clue"@en . +:hasResultClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has result clue"@en . +:hasNextStepClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has next-step clue"@en . +:hasQuantityClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has quantity clue"@en . +:hasConditionClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has condition clue"@en . +:hasSourceClue a owl:ObjectProperty ; + rdfs:subPropertyOf :hasEvidenceClue ; + rdfs:label "has source clue"@en . + +:hasCause a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf prov:wasInfluencedBy ; + rdfs:label "has cause"@en . + +:hasGoal a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity ; + rdfs:label "has goal"@en . + +:hasConsequence a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity ; + rdfs:label "has consequence"@en . + +:hasNextStep a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity ; + rdfs:label "has next step"@en . + +:atPlace a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Location ; + rdfs:subPropertyOf prov:atLocation ; + rdfs:label "at place"@en . + +:hasPlace a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Location ; + owl:inverseOf :atPlace ; + rdfs:label "has place"@en . + +:hasEventTime a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range time:TemporalEntity ; + rdfs:subPropertyOf time:hasTime ; + rdfs:label "has event time"@en . + +:factNormalizedDate rdfs:subPropertyOf time:inXSDDate . + +# Organization and actor combinations follow org:Membership rather than +# joining display names. The property chain is a deterministic inference +# pattern; it does not resolve an ambiguous name into a catalog identity. +:hasMembership a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:range org:Membership ; + rdfs:subPropertyOf org:hasMembership . + +:memberOf a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:range org:Organization ; + rdfs:subPropertyOf org:memberOf . + +:memberRole a owl:ObjectProperty ; + rdfs:domain org:Membership ; + rdfs:range org:Role ; + rdfs:subPropertyOf org:role . + +:memberOrganization a owl:ObjectProperty ; + rdfs:domain org:Membership ; + rdfs:range org:Organization ; + rdfs:subPropertyOf org:organization . + +:memberDuring a owl:ObjectProperty ; + rdfs:domain org:Membership ; + rdfs:range time:TemporalEntity ; + rdfs:subPropertyOf org:memberDuring . + +:reportsTo a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:range prov:Agent ; + rdfs:subPropertyOf org:reportsTo . + +:headOf a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:range org:Organization ; + rdfs:subPropertyOf org:headOf . + +:subOrganizationOf a owl:ObjectProperty ; + rdfs:domain org:Organization ; + rdfs:range org:Organization ; + rdfs:subPropertyOf org:subOrganizationOf ; + owl:inverseOf :hasSubOrganization . + +:hasSubOrganization a owl:ObjectProperty ; + rdfs:domain org:Organization ; + rdfs:range org:Organization ; + rdfs:subPropertyOf org:hasSubOrganization . + +:affiliatedWith owl:propertyChainAxiom ( org:hasMembership org:organization ) . + +# Existing quantity facts are source assertions, not unqualified sensor data. +:QuantitativeObservation rdfs:subClassOf :ObservationRecord ; + rdfs:seeAlso sosa:Result ; + rdfs:seeAlso qudt:QuantityValue . + +:hasQuantityValue a owl:ObjectProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range qudt:QuantityValue . + +:quantityKind a owl:ObjectProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range sosa:Property ; + rdfs:seeAlso qudt:hasQuantityKind . + +:measurementValue a owl:DatatypeProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range xsd:decimal ; + rdfs:seeAlso qudt:numericValue . + +:measurementUnitResource a owl:ObjectProperty ; + rdfs:domain :QuantitativeObservation ; + rdfs:range qudt:Unit ; + rdfs:seeAlso qudt:unit . + +# Normative relation combinations. A descriptive condition may use +# :hasConditionClue; only an explicit policy/rule may use these ODRL-aligned +# properties. +:hasNormativeConstraint a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range odrl:Constraint ; + rdfs:subPropertyOf odrl:constraint . + +:normativeTarget a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf odrl:target . + +:normativeAction a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range prov:Entity ; + rdfs:subPropertyOf odrl:action . + +:normativeDuty a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range odrl:Duty ; + rdfs:subPropertyOf odrl:duty . + +:normativePermission a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range odrl:Permission ; + rdfs:subPropertyOf odrl:permission . + +:normativeProhibition a owl:ObjectProperty ; + rdfs:domain :NormativeStatement ; + rdfs:range odrl:Prohibition ; + rdfs:subPropertyOf odrl:prohibition . + +# Metadata/provenance relations for export and KG rendering. +:hasProvenance a owl:ObjectProperty ; + rdfs:subPropertyOf dcterms:provenance ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity . + +:referencesDocument a owl:ObjectProperty ; + rdfs:subPropertyOf dcterms:references ; + rdfs:domain prov:Entity ; + rdfs:range :Document . + +:conformsTo a owl:ObjectProperty ; + rdfs:subPropertyOf dcterms:conformsTo ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity . + +# Common inverse and inheritance axioms used by KG drawing/reasoning. +:hasObservation owl:inverseOf :observationOf . +:hasEvidenceClue owl:inverseOf :clueFor . +:hasCause owl:inverseOf :causedBy . +:hasConsequence owl:inverseOf :consequenceOf . +:hasNextStep owl:inverseOf :nextStepOf . +:causedBy a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity . +:consequenceOf a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity . +:nextStepOf a owl:ObjectProperty ; + rdfs:domain prov:Entity ; + rdfs:range prov:Entity . + +################################################################# +# Machine-readable semantic predicate registry. The relational +# predicate_code is an application code; this registry resolves it to the +# standard/profile IRI used by KG drawing and export. +################################################################# + +:predicateCode a owl:AnnotationProperty . +:predicateIri a owl:AnnotationProperty . +:SemanticPredicateMapping a owl:Class . +:hasSemanticPredicateMapping a owl:ObjectProperty . +:semanticPredicateRegistry a owl:Thing ; + :hasSemanticPredicateMapping + [ a :SemanticPredicateMapping ; :predicateCode "org_member_of" ; :predicateIri org:memberOf ; rdfs:label "Organization member of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_unit_of" ; :predicateIri org:unitOf ; rdfs:label "Organization unit of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_reports_to" ; :predicateIri org:reportsTo ; rdfs:label "Reports to"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_has_membership" ; :predicateIri org:hasMembership ; rdfs:label "Has membership"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_role" ; :predicateIri org:role ; rdfs:label "Organization role"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_organization" ; :predicateIri org:organization ; rdfs:label "Membership organization"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_member_during" ; :predicateIri org:memberDuring ; rdfs:label "Member during"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_head_of" ; :predicateIri org:headOf ; rdfs:label "Head of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "org_suborganization_of" ; :predicateIri org:subOrganizationOf ; rdfs:label "Sub-organization of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "skos_broader" ; :predicateIri skos:broader ; rdfs:label "Broader concept"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "skos_related" ; :predicateIri skos:related ; rdfs:label "Related concept"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_was_derived_from" ; :predicateIri prov:wasDerivedFrom ; rdfs:label "Was derived from"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_used" ; :predicateIri prov:used ; rdfs:label "Used"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_was_generated_by" ; :predicateIri prov:wasGeneratedBy ; rdfs:label "Was generated by"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_was_attributed_to" ; :predicateIri prov:wasAttributedTo ; rdfs:label "Was attributed to"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_was_associated_with" ; :predicateIri prov:wasAssociatedWith ; rdfs:label "Was associated with"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_acted_on_behalf_of" ; :predicateIri prov:actedOnBehalfOf ; rdfs:label "Acted on behalf of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_had_primary_source" ; :predicateIri prov:hadPrimarySource ; rdfs:label "Had primary source"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_was_influenced_by" ; :predicateIri prov:wasInfluencedBy ; rdfs:label "Was influenced by"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_specialization_of" ; :predicateIri prov:specializationOf ; rdfs:label "Specialization of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_alternate_of" ; :predicateIri prov:alternateOf ; rdfs:label "Alternate of"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "prov_had_member" ; :predicateIri prov:hadMember ; rdfs:label "Had member"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "time_has_time" ; :predicateIri time:hasTime ; rdfs:label "Has time"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "time_before" ; :predicateIri time:before ; rdfs:label "Before"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "time_after" ; :predicateIri time:after ; rdfs:label "After"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "time_interval_during" ; :predicateIri time:intervalDuring ; rdfs:label "Interval during"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "sosa_has_result" ; :predicateIri sosa:hasResult ; rdfs:label "Has observation result"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "sosa_observed_property" ; :predicateIri sosa:observedProperty ; rdfs:label "Observed property"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "sosa_phenomenon_time" ; :predicateIri sosa:phenomenonTime ; rdfs:label "Phenomenon time"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "sosa_has_feature_of_interest" ; :predicateIri sosa:hasFeatureOfInterest ; rdfs:label "Has feature of interest"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_target" ; :predicateIri odrl:target ; rdfs:label "Normative target"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_action" ; :predicateIri odrl:action ; rdfs:label "Normative action"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_constraint" ; :predicateIri odrl:constraint ; rdfs:label "Normative constraint"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_duty" ; :predicateIri odrl:duty ; rdfs:label "Normative duty"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_permission" ; :predicateIri odrl:permission ; rdfs:label "Normative permission"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "odrl_prohibition" ; :predicateIri odrl:prohibition ; rdfs:label "Normative prohibition"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "dct_references" ; :predicateIri dcterms:references ; rdfs:label "References"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "dct_provenance" ; :predicateIri dcterms:provenance ; rdfs:label "Provenance"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "dct_conforms_to" ; :predicateIri dcterms:conformsTo ; rdfs:label "Conforms to"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_observes_event" ; :predicateIri :observesEvent ; rdfs:label "Observes event"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_clue_for" ; :predicateIri :clueFor ; rdfs:label "Clue for"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_clue_supports" ; :predicateIri :clueSupports ; rdfs:label "Clue supports"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_cause" ; :predicateIri :hasCause ; rdfs:label "Has cause"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_goal" ; :predicateIri :hasGoal ; rdfs:label "Has goal"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_consequence" ; :predicateIri :hasConsequence ; rdfs:label "Has consequence"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_next_step" ; :predicateIri :hasNextStep ; rdfs:label "Has next step"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_time" ; :predicateIri :hasEventTime ; rdfs:label "Has time"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_at_place" ; :predicateIri :atPlace ; rdfs:label "At place"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_actor" ; :predicateIri :hasActorClue ; rdfs:label "Has actor clue"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_result" ; :predicateIri :hasResultClue ; rdfs:label "Has result clue"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_has_condition" ; :predicateIri :hasConditionClue ; rdfs:label "Has condition clue"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_inferred_from" ; :predicateIri :inferredFrom ; rdfs:label "Inferred from"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_responsible_for" ; :predicateIri :responsibleFor ; rdfs:label "Responsible for"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_supports" ; :predicateIri :supports ; rdfs:label "Supports"@en ] . + +:EventObservation rdfs:subClassOf :SemanticAssertion . +:EvidenceClue rdfs:subClassOf :SemanticAssertion . +:TemporalClaim rdfs:subClassOf :SemanticAssertion . +:OrganizationContext rdfs:subClassOf :SemanticAssertion . +:IndustrialCondition rdfs:subClassOf :SemanticAssertion . +:NormativeStatement rdfs:subClassOf :SemanticAssertion . +:QualityAssessment rdfs:subClassOf :SemanticAssertion . +:RiskStatement rdfs:subClassOf :SemanticAssertion . + +# Fine-grained fact classes let Ask and KG renderers retain the distinction +# between an organization, an industrial object/process, a normative rule, +# and a descriptive cause/result without relying on a free-text label. +:ObservationFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :ObservationRecord . +:OrganizationFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :OrganizationContext . +:IndustrialAssetFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :IndustrialAsset . +:IndustrialProcessFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact ; rdfs:seeAlso :IndustrialProcess . +:NormativeFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :NormativeStatement . +:QualityFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :QualityAssessment . +:RiskFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact, :RiskStatement . +:PlaceFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact . +:ActorFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact ; rdfs:seeAlso :Actor . +:CauseFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact . +:GoalFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact . +:ResultFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact . +:NextStepFact a owl:Class ; rdfs:subClassOf :SourceGroundedFact . + +################################################################# +# Relationship combinations, hierarchy, and inference +################################################################# + +:hasCorporateEntityRelation a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:label "has corporate-entity relation"@en . + +:hasCounterpartyRelationship a owl:ObjectProperty ; + rdfs:subPropertyOf :hasCorporateEntityRelation ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:label "has counterparty relationship"@en . + +:hasVocRelationship rdfs:subPropertyOf :hasCounterpartyRelationship . +:hasVomRelationship rdfs:subPropertyOf :hasCounterpartyRelationship . +:hasVopRelationship rdfs:subPropertyOf :hasCounterpartyRelationship . + +:hasOrganizationMention a owl:ObjectProperty ; + rdfs:subPropertyOf :hasCorporateEntityRelation ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + owl:inverseOf :mentionsOrganization ; + rdfs:label "has organization mention"@en . + +:postMentionsTeam a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Team ; + owl:inverseOf :mentionsTeam ; + rdfs:label "post mentions team"@en . + +:hasParentCorporateEntity a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:subPropertyOf skos:broader ; + rdfs:label "has parent corporate entity"@en . + +:hasChildCorporateEntity a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:subPropertyOf skos:narrower ; + owl:inverseOf :hasParentCorporateEntity ; + rdfs:label "has child corporate entity"@en . + +:hasAffiliatedCorporateContext a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:comment "Inferred post context through a mentioned person and that person's affiliation; not a direct organization mention."@en ; + owl:propertyChainAxiom ( :mentions :affiliatedWith ) . + +:hasTeamCorporateContext a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:comment "Inferred post context through a mentioned team and that team's organizational unit."@en ; + owl:propertyChainAxiom ( :postMentionsTeam :teamAffiliatedWith ) . + +:mentionsProject owl:propertyChainAxiom ( :hasProjectMention :projectMentionFor ) . + +################################################################# +# SHACL norms for persisted semantic resources +################################################################# + +:PostSemanticShape a sh:NodeShape ; + sh:targetClass :Post ; + sh:property [ + sh:path :hasSemanticAssertion ; + sh:class :SemanticAssertion ; + sh:severity sh:Violation ; + sh:message "A semantic assertion attached to a post must be an evidence-bearing resource."@en + ] . + +:RoleResponsibilityShape a sh:NodeShape ; + sh:targetClass :RoleResponsibility ; + sh:property [ + sh:path :actorName ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :responsibilityText ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] . + +:ProjectMentionShape a sh:NodeShape ; + sh:targetClass :ProjectMention ; + sh:property [ + sh:path :projectMentionFor ; + sh:class :Project ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :projectEvidence ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :semanticConfidence ; + sh:datatype xsd:decimal ; + sh:minCount 1 ; + sh:minInclusive 0 ; + sh:maxInclusive 1 ; + sh:severity sh:Violation + ] . + +:QuantitativeObservationShape a sh:NodeShape ; + sh:targetClass :QuantitativeObservation ; + sh:property [ + sh:path :numericValue ; + sh:datatype xsd:decimal ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :measurementUnit ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :sourceEvidence ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] . + +:SemanticRelationshipShape a sh:NodeShape ; + sh:targetClass :SemanticRelationship ; + sh:property [ + sh:path :subjectName ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :predicateCode ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :objectName ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :relationEvidence ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:severity sh:Violation + ] ; + sh:property [ + sh:path :relationConfidence ; + sh:datatype xsd:decimal ; + sh:minCount 1 ; + sh:minInclusive 0 ; + sh:maxInclusive 1 ; + sh:severity sh:Violation + ] . diff --git a/docs/ontology/lineageweave-shapes.ttl b/docs/ontology/lineageweave-shapes.ttl new file mode 100644 index 000000000..ae64c5c07 --- /dev/null +++ b/docs/ontology/lineageweave-shapes.ttl @@ -0,0 +1,115 @@ +@prefix lw: . +@prefix sh: . +@prefix prov: . +@prefix xsd: . + +################################################################# +# SHACL contract for the standards-aligned semantic layer (ADR 0129). +# This graph describes the minimum evidence needed for KG rendering and +# Ask retrieval; it does not authorize or hydrate a private database row. +################################################################# + +lw:ObservationRecordShape a sh:NodeShape ; + sh:targetClass lw:ObservationRecord ; + sh:property [ + sh:path lw:observationOf ; + sh:class prov:Entity ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:message "An observation must point to its source-derived entity."@en + ] ; + sh:property [ + sh:path lw:assertionStatus ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:message "An observation must expose asserted/derived/inferred status."@en + ] . + +lw:EventObservationShape a sh:NodeShape ; + sh:targetClass lw:EventObservation ; + sh:property [ + sh:path lw:observesEvent ; + sh:class lw:Event ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:hasEvidenceClue ; + sh:class lw:EvidenceClue ; + sh:minCount 1 + ] . + +lw:EvidenceClueShape a sh:NodeShape ; + sh:targetClass lw:EvidenceClue ; + sh:property [ + sh:path lw:clueText ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:clueFor ; + sh:class prov:Entity ; + sh:minCount 1 + ] ; + sh:property [ + sh:path lw:clueSource ; + sh:class prov:Entity ; + sh:minCount 1 + ] . + +lw:QuantitativeObservationShape a sh:NodeShape ; + sh:targetClass lw:QuantitativeObservation ; + sh:property [ + sh:path lw:numericValue ; + sh:datatype xsd:decimal ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:measurementUnit ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:sourceEvidence ; + sh:datatype xsd:string ; + sh:minCount 1 + ] . + +lw:SourceGroundedFactShape a sh:NodeShape ; + sh:targetClass lw:SourceGroundedFact ; + sh:property [ + sh:path lw:factValue ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:factAssertion ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 + ] ; + sh:property [ + sh:path lw:factSourceEvidence ; + sh:datatype xsd:string ; + sh:minCount 1 + ] . + +lw:NormativeStatementShape a sh:NodeShape ; + sh:targetClass lw:NormativeStatement ; + sh:property [ + sh:path lw:normativeTarget ; + sh:minCount 1 + ] ; + sh:property [ + sh:path lw:normativeAction ; + sh:minCount 1 + ] ; + sh:property [ + sh:path lw:assertionStatus ; + sh:minCount 1 ; + sh:maxCount 1 + ] . diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e65883463..2bef424ee 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,26 +1,510 @@ # Product & Technical Gap Baseline -## 1. Known Parsing & Frontend Display Gaps -- **Footnote Parsing**: `post=00505695-3e61-1fd1-83c5-263f88a9e77a` fails to recognize footnotes (li/oi level errors). -- **Table Parsing**: `post=00505695-3e61-1fd1-80c6-86bb61c8ddc5` completely fails at parsing tables. -- **Indentation**: Incorrect indentation rendering in `post=00505695-7571-1fd1-83c3-d521b187ad5b` and `post=00505695-3e61-1fd1-83c0-497b3c1c455e`. -- **Image/Table OCR**: `post=00505695-7571-1fd1-83dd-3d22a61a5734` fails text recognition for tables inside images, markdown parsing fails, and image OCR description is too shallow for Ontology & Semantics. -- **Math/Superscripts**: `post=00505695-9612-1fe1-83a7-e30153323f25` fails to parse superscripts like m^3 properly. Needs strict Ontology grammar for math formulas. -- **Missing UI Elements**: DAG (Directed Acyclic Graph) view is currently missing from the frontend for `post=00505695-7571-1fd1-83c5-895ed333cdbc`. - -## 2. LLM Extraction & Knowledge Graph Gaps -- **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly. -- **5W1H Missing**: (Resolved) LLM prompt updated to explicitly request 5W1H evidence items in the JSON output array. -- **R&R and Keyman Missing**: (Resolved) LLM prompt updated to explicitly instruct using actual stated names rather than collective titles. -- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration. -- **Meso-level Team Mapping**: (Resolved) Checked extraction logic; `team` mapping logic is present and correct, but LLM needed better explicit instruction which is covered by R&R resolution. -- **Base64 Image Omni-modal**: Current text-only embedding fails on images. Omni-modal LLM processing is required for images to capture layout, font size, colors, and spatial meaning. - -## 3. General Architecture Gaps -- **DB Architecture**: Ensure PostgreSQL is strictly used (no file DBs), 3rd normal form is maintained, and Hot Partitions are handled. DB locks must be managed (or use read/write replicas). -- **Zotero Integration**: Papers and standards referenced by TEPP must be synced via Local Zotero API (http://localhost:23119/api/) and cited using APA 7th edition in docstrings. -- **Testing**: We need actual testing of Psychometrics (Fast-MLSIRM parameter calibration, RMSE of estimates, Fixed-Item Parameter Calibration, CAT) against synthetic/demo data. -- **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. -- **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). - -*This document is continuously updated by the hourly automated agent loop.* +> Audit scope: the current LineageWeave buyer-surface/source-context worktree +> and open PR #384, compared with its exact base and the UI/UX Standard Guide v3.0 supplied for this +> product, ADR 0118, the accepted TEPP PRD/contracts, and the +> contextual-orchestrator architecture. Real source identifiers are deliberately +> replaced with case labels; they must not enter repository artifacts. + +## 1. Exact-head evidence + +### 1.1 Current continuation head + +The current buyer-surface/source-context implementation head for PR #384 is +`d2d4b9209caed81cf4506908207e7b03142a1af6` (the exact head pushed after the +remote fixture cleanup and vendor-skill revert), observed at `2026-08-21T15:35:55Z` +(`2026-08-22` KST). Its base is +`83ace331edc982208c290763cb0d389c1884e21b` (`docs/customer-master-scope-adr`). +The repaired implementation includes the customer-master scope-facet base +and preserves the source-detail-state fixes. Local acceptance evidence is +backend `127 passed, 6 skipped`, frontend `199 passed`, lint, TypeScript, and +production build success. The baseline update itself follows this code +commit; hosted checks, formal review, and merge remain unclaimed until the +remote PR is queried at its newly pushed exact head. + +The historical evidence below remains valid only at the exact heads and dates +stated in each entry. It must not be used as proof that the current continuation +head has passed the same checks. + +### 1.2 Historical audit anchor + +Audit anchor: the exact source state carried by this commit at 2026-08-21; +record the final PR head with `git rev-parse HEAD` during acceptance. + +Current source/test exact head observed before this documentation update: +`8bed77e7e7b91b633bb92d3a82d0187c387206af`, the squash merge of PR #364 +(docs-only) on top of PR #350. The runtime source was last tested at +`0e63ba0a2e23949630f8997cbe001b6e13b2d274`; this ADR/docs update creates the +next exact head and therefore requires the protected checks to rerun. + +- **Implemented in source:** PostgreSQL-backed API boundaries, Keyverse/OIDC + identity boundary, workspace navigation, post popup, ABAC/RBAC surfaces, Korean + summary, 5W1H, R&R/Keyman, customer hierarchy, tickets/calendar, chat, + provenance/evidence, and reconstructed lineage API/DAG layout. +- **Implemented in source, runtime evidence still required:** TEPP import/API + transport, contextual-orchestrator processing of the authorized corpus, + SearXNG corroboration, Local Zotero ingestion, real PostgreSQL import, and + complete accessibility/edge-case browser workflows. Synthetic routes and + health checks are recorded separately and are not corpus proof. +- **Implemented in source:** an explicit body-column or hash-verified + `multipart/related` MHTML artifact resolver now gives the private importer a + fail-closed path for exports whose PostgreSQL rows contain artifact + provenance but no body column. The operator artifact root and raw artifacts + remain outside the repository. +- **Figma reference:** ADR 0118 records file `1Su3lDRmiZdcUs47t1QwIX`; the + inspected Event Lineage frames are desktop `5:14` and mobile `5:15`. +- **Local quality evidence at the source/test head:** backend `uv run pytest -q` + passed `788` tests with `17` skips; frontend Vitest passed `177` tests in `19` + files, frontend lint/build passed, and Storybook build completed. These are + local checks, not hosted protected-gate or independent-review evidence. +- **Current PR gate:** PR #350 merged at + `0e63ba0a2e23949630f8997cbe001b6e13b2d274` after its source head + `819ef876270212305c89743e5443b3ce0b871e66` was reviewed and squashed into + `feat/lineage-dag-regression`; PR #364 then merged the evidence-only + baseline at `8bed77e7e7b91b633bb92d3a82d0187c387206af`. This ADR/docs + follow-up requires its own protected checks and independent approval; + neither is claimed yet. PR #366 remains open at code head + `a5aa0daa`; its hosted Tests run is queued, + Devin Review is pending, and no independent approval or merge is claimed. + The prior PR #347 merged at + `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. +- **Historical parsing PR:** PR #367 subsequently merged at + `7a0d025215fbd9f6510727c7139885b561296149` after exact head + `5194d267b90430d7a27a9752a49d73617cb5756c`, based on + `docs/customer-master-scope-adr` at `f66991699506ef14607de5946da1efcfd20ae6da`. + It preserves numbered footnotes and empty-cell positions, avoids short-id + collisions, and drops table rows made only of empty cells. The focused + parser gate is `47 passed`; `compileall` and `git diff --check` passed. + Hosted Checks remain queued and no approval or merge is claimed. + +### 1.3 Current related PR queue + +The following exact-head states were observed during this continuation and are +part of the acceptance queue, not completion evidence: + +| Repository | PR | Exact head | State | Remaining gate | +| --- | ---: | --- | --- | --- | +| LineageWeave | #384 | `d2d4b9209caed81cf4506908207e7b03142a1af6` | open, mergeable, unstable | hosted checks and review | +| LineageWeave | #383 | `720004942dd155a85020af32da402d320038f46a` | open, blocked | required checks and review | +| LineageWeave | #355 | `b606c2553f877fa85968d90dc46598ce16897fbf` | open, coverage pending | coverage gate and review | +| contextual-orchestrator | #765 | `d19e3492192e21e4a040fa3fc13a0793443731bf` | open, blocked | required checks and review | +| governance-risk-compliance | #50 | `ba78e4790f3e361826991455ce83634004f2875d` | open, mergeable, unstable | central OSV provenance gate | +| ContextualWisdomLab/.github | #1158 | `6e93fd0b65c159c7b168d83579e5b8282096480e` | open, behind | required checks and review | + +GRC stack PRs #20 and #21 were merged in order before #50. These merge SHAs do +not make #50 or the other listed PRs merged; each remains subject to its own +exact-head protected gate. + +## 2. UI/UX Standard Guide v3.0 comparison + +### 2.1 Satisfied or substantially present + +- Desktop shell has a sticky header, top-right user/logout controls, GNB, + footer with brand/copyright, standard breakpoints, 1920px maximum layout, + Noto Sans family, CI/BI palette tokens, table alignment tokens, focus styles, + required-field marker, and 50% modal backdrop. +- GNB active state is exposed with `aria-current`; the lineage DAG has keyboard + activation and branch/root/current visual states. +- PostgreSQL, orchestrator, TEPP, provenance, and synthetic-fixture boundaries + are documented in `ARCHITECTURE.md` and the applicable ADRs. + +### 2.2 Gaps and status + +- **Mobile drawer — fixed in this worktree:** CSS referenced a drawer trigger but + the authenticated shell rendered no trigger or drawer. The shell now renders + an accessible hamburger button, close action, overlay, and reusable WorkspaceNav. +- **Event Lineage Figma parity — fixed in this worktree:** the DAG now includes + lineage-evidence context, legend, horizontal overflow on phones, inference + boundary, direction markers, and an evidence trail table/cards treatment. +- **Post detail modal keyboard access — fixed in this worktree:** the existing + 50% backdrop now exposes a named modal dialog with `aria-modal`, moves focus + into the panel, closes on Escape, contains Tab focus, and restores focus to + the opener. Native interactive controls also share the token-based + `:focus-visible` ring. The behavior is covered by the authenticated React + and CSS tests; fresh browser evidence remains open. +- **Approved CI/BI asset — open:** the header/footer currently render the + tenant brand name as text. Do not invent or alter a corporate logo; add the + approved asset only after the tenant CI/BI source and usage permission are + available. +- **Header utilities/search — partial:** the authenticated header now exposes a + global Search action that focuses the existing board search, and its pending + focus request is cleared when navigation leaves the board. A desktop site-map + utility now reuses `WorkspaceNav`, closes on Escape or destination selection, + and is omitted on phones where the drawer owns navigation; approved CI/BI + assets and a no-JavaScript fallback remain open. +- **Header top-menu language placement — fixed in this worktree:** UI/UX + Standard Guide v3.0 §2.2.2 assigns 언어설정 (language setting) to the header + top menu alongside user info, login/logout, search, and utility items. + `LanguageSwitcher` now renders inside `.app-header-top-menu` (`App.tsx`) + instead of the GNB row; the now-unused `WorkspaceNav` `tools` prop and + `.workspace-gnb-tools` CSS were removed. +- **Authorized corp/PU scope — fixed in this worktree:** `/api/me` remains the + only source for GNB scope values, and that response is built from the + authenticated account's DB-backed `account_affiliation` rows. The header now + presents a compact code summary with a keyboard-operable disclosure for the + complete corporation/business-unit list, keeps corporation-only affiliations, + and omits the scope when no affiliation is authorized. Desktop and 390px + mobile Playwright checks cover the disclosure, no-unassigned-code behavior, + and no horizontal overflow; the external Keyverse/OIDC runtime gate remains + open below. +- **Locale document metadata — substantially present:** `i18n.ts` synchronizes + `document.documentElement.lang` after locale selection and `i18n.test.ts` + covers the supported locales. `frontend/index.html` remains an English + pre-JavaScript fallback, so a no-JavaScript locale check is still open. +- **Event Lineage locale parity — fixed in this worktree:** the PR review + exposed ten Event Lineage, graph-evidence, navigation, and authorization + labels translated only for Korean while Chinese, Japanese, and Vietnamese + fell back to English. The three locale maps now contain those translations, + and the i18n test rejects raw-key fallback for every supported non-English + locale. This does not close the separate no-JavaScript fallback gap. +- **Phone content affordance — fixed and covered:** the authenticated 390px + browser sweep had a scrollable page and rendered the post list below the + sticky shell. A duplicate phone `.app-header` rule that overrode the required + vertical padding was removed; `mobileHeaderCss.test.ts` now enforces one + phone rule with `0.6rem 1rem` padding. The current exact-head frontend image + was rebuilt and the authenticated 390px sweep reconfirmed the header and + below-the-fold content affordance. +- **Large-body search migration — fixed in this worktree:** current PostgreSQL + migration replay initially exhausted the 58.8 GB container overlay while + building a raw HTML/Base64 body FTS index. The normalized search function and + intermediate FTS index now bound indexed rendered text to 16,384 characters; + the raw indexes are dropped by migration 0036. After reclaiming only Docker + build cache (never the PostgreSQL volume), the live migration completed with + exit 0 and the bounded function/index aggregate was verified. +- **Tenant settings replay — fixed in this worktree:** the idempotent migration + runner stopped at `0102`, so existing volumes returned a misleading CORS + symptom for `/api/settings` while the table was absent. The allowlist now + replays `0103_tenant_settings.sql`; the existing PostgreSQL volume applied it + with exit 0 and contains one tenant-settings row. Migration `0104` now + preserves the canonical `tenant_settings_id` column during replay. +- **Database identifier contract — fixed in this worktree:** the live public + schema audit found one single-token table and nine single-token persistent + columns, including the legacy bookmark, status, content, report, and tenant + settings fields. ADR 0120 and migration `0104_two_word_database_identifiers` + rename them to two-word snake_case names, retain stable API JSON names, and + recreate the current-status view. The replayed live schema now reports zero + single-token table/view/column violations and zero invalid indexes. 3NF, + hot-partition, lock, and read/write contention evidence remains open. +- **Metric superscript/subscript display — partially fixed in this worktree:** + bounded metric markup such as `m3` and `m3` is now + normalized consistently in the backend semantic parser and React renderer, + with focused tests. Arbitrary mathematical formula semantics remain open. + +### 2.3 Authenticated runtime evidence + +The local Compose stack accepted the synthetic Keycloak OIDC account and the +real React client rendered the protected board. Aggregate evidence only: + +- `/api/me` returned two authorized corporate entities and eight account + affiliations, with corporate and process-unit code/name fields. +- At a 390×958 viewport, logout, scope display, language control, 50 visible + posts, drawer open/close, and global-search-to-input focus all worked. +- On the rebuilt authenticated frontend, switching the header language to + Chinese set `html[lang]` to `zh`, localized the drawer, authorized-scope, + logout, summary, and Event Lineage labels, opened a post popup, and produced + zero popup errors. +- The authorized PostgreSQL export relation contained 43,814 rows with + complete title, source-key, and artifact-path metadata, but its schema had + no body/content/HTML column. The importer now accepts an explicit + body-column mapping or a path-column plus SHA-256-column mapping beneath an + operator-supplied artifact root; this is source and synthetic-fixture + evidence only because the authorized raw artifacts were not present in the + repository runtime. No real-corpus import or multimodal backfill is claimed + from this relation. +- After replaying the tenant-settings migration on the existing PostgreSQL + volume, authenticated `/api/settings` returned HTTP 200 and the fresh React + browser session recorded zero console errors and zero warnings. At 390×958, + the document had no horizontal overflow (`scrollWidth=390`) and the protected + board rendered the authenticated content surface. +- All 11 supplied post cases opened a popup with a loaded title and zero popup + error elements. The footnote case rendered one footnote, the table case one + semantic table, the known lineage case one DAG, and the image-table case + rendered five images but zero persisted image-region panels. +- The full lineage endpoint returned 500 bounded nodes and one edge; the focused + isolated case correctly returned an empty graph. PostgreSQL aggregates were + 43,839 source posts, 54 knowledge-graph edges, 65 edge-evidence rows, 1,308 + persisted post-lineage edges, and 1,929 posts participating in those edges. + This is evidence of a sparse current relationship projection, not evidence + that the Event Lineage product goal is complete. +- After the health-probe correction, the rebuilt backend returned unauthenticated + `/healthz` HTTP 200. The focused representative case in the later mixed + workspace image returned no DAG, so the earlier single-case DAG observation + must not be generalized to corpus coverage. + +## 3. Requirement traceability + +Status is intentionally evidence-specific: `source` means the implementation +boundary exists; `unit` means synthetic automated coverage exists; +`local-integration` means the local PostgreSQL/Keycloak/Valkey stack exercised +the path; `live-external` means the requested external service or authorized +corpus was exercised; `open` means the requirement is not yet proven. A source +adapter, fixture, or HTTP-shaped test double never upgrades a row to +`live-external`. + +| Requirement | Evidence at this audit | Status | +| --- | --- | --- | +| 1024/1280/1920 layout and three responsive tiers | ADR 0118, `App.css`, responsive popup width/secondary evidence grid, frontend build/tests | source + unit | +| Sticky header, footer, GNB, active state, phone drawer | `App.tsx`, `WorkspaceNav.tsx`, `App.test.tsx` | source + unit | +| Approved CI/BI logo asset | Tenant text is present; approved asset and permission are absent | open | +| User/logout/language/global search header actions | `App.tsx`, `i18n.ts`, handled/pending search-focus tests | source + unit | +| Site map / utility menu | `SiteMapUtility`, accessible toggle/region, Escape and destination-close behavior, responsive CSS contract, locale coverage | source + unit; authenticated browser evidence open | +| Noto Sans, palette, table/form/button conventions, modal 50% mask and keyboard semantics | ADR 0118, token CSS, popup dialog implementation, frontend tests | source + unit | +| Keyverse/OIDC login with real account | `auth.py`, OIDC discovery/JWKS boundary, local redirect check | source + local-integration; Keyverse open | +| Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU`, the GNB disclosure is covered by `App.test.tsx`, and desktop/390px Playwright QA verifies the rendered scope | source + unit + local-integration + browser-mocked | +| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post` plus W author/admin raw-source exception, analysis eligibility excluding W, API authorization tests, aggregate-only runtime checks | source + local-integration | +| React product surface and PostgreSQL boundary | React routes/components, asyncpg API, Compose stack | source + local-integration | +| Authorized PostgreSQL export import mapping | `scripts/import_postgresql_posts.py`, ADR 0121, hash-verified RFC 2557 MHTML resolver, and synthetic preflight/import tests; authorized relation has artifact-path metadata but no body/content/HTML field | source + unit + local-integration partial; operator artifact files and authorized live import open | +| Bounded large-body search migration | `0035_body_search_prefix.sql`, `0036_normalized_body_search.sql`; live replay completed after bounded rendered-text indexing | source + local-integration | +| Public Compose liveness and tenant settings boundary | health-probe regression test, `0103_tenant_settings.sql`, replayed existing volume, one tenant-settings row, canonical `tenant_settings_id` after `0104`, rebuilt backend `/healthz` and authenticated `/api/settings` HTTP 200 | source + unit + local-integration | +| Two-word snake_case database identifiers | ADR 0120, idempotent migration `0104`, live public-schema audit, zero invalid indexes | source + unit + local-integration | +| Post list/detail popup, Korean summary, 5W1H, R&R, tickets/calendar | API routes, popup panels, backend/frontend tests; W is raw-source-only for author/admin and is excluded from summary and derived analysis targets | source + unit | +| Keyman on both sides, titles, affiliations, related KG nodes | Keyman/affiliate-tree/related-node routes and popup | source + unit; live extraction open | +| Ontology, semantic layer, provenance, W3C PROV-O projection | normalized schema, SKOS operational vocabulary concepts, `ontology_annotations` label fallback, ADR 0124, provenance modules, ADRs, evidence UI | source + unit; corpus verification open | +| Branching Event Lineage DAG with evidence trail | `LineageDag.tsx`, Storybook story, Figma frames, accessible node-kind names for screen readers/tooltips, frontend tests; runtime cases include both a rendered DAG and honest empty states, while current corpus coverage remains sparse | source + unit + local-integration partial | +| Customer master and hierarchy tree | `/api/customer-master`, `scope_facets`, visible `post_organization_mention` enrichment, affiliate tree, migration `0105`, scope filter | source + unit + local-integration partial; authorized own/granted/unclassified facets, visible observed organizations, and admitted observed hierarchy facets are implemented, while authoritative scope backfill and broader hierarchy traversal remain open | +| VOC/VOM/VOP/VOCC/VOCO/VOS role classification | common lookup values and relationship APIs | source + unit; live classification open | +| Evidence-grounded chat and source navigation | `/chat`, `/ask`, citation/evidence UI | source + unit; synthetic orchestrator judge route verified, corpus chat/runtime evidence open | +| OpenTelemetry across LineageWeave, contextual-orchestrator, Valkey, and GRC | LineageWeave PR #383 adds API/Valkey/session spans; contextual-orchestrator PR #765 carries session/provider telemetry; governance-risk-compliance PR #50 adds request telemetry, W3C trace context, OTLP export, and ADR 0009 | source + PR; protected merge and end-to-end collector evidence open | +| PU/team/project weekly/monthly reports | report API/UI and grouping controls | source + unit; TEPP-backed live report open | +| TEPP calibrated measurement, dichotomous items, multilevel/MMM/time model | published import/REST boundary and TEPP ADR/PRD references | boundary-only; live-external open | +| TEPP topic-identity lineage (TRSL-TM) and CHRONOS/TDT event-intelligence overlay on the Event Lineage DAG | ADR 0132 defines the consumption contract (citing TEPP ADR 0012 TRSL-TM and ADR 0016 CHRONOS/TDT, APA 7th); `analysis_run_topic_lineage` kind registered (migration 0131), `analysis_run_topic_lineage_result` envelope table (migration 0132), `tepp_client` request/submit/persist path mirroring TEPP's fail-closed pattern (`backend/app/analysis_run_start.py`), `make seed` Demo Corp topic-lineage row verified end-to-end (Pending → Running → Failed `tepp_not_available`) against the live dev Postgres; `zcrht811_export_rows` posts are the source corpus; extends, does not replace, the existing evidence-fusion lineage tree (ADR 0064/0084) | source + unit (34 backend tests across `test_analysis_run_start.py`/`test_analysis_run_create.py`/`test_migration_replay.py`, 200 frontend tests, full 852-test backend suite green); DAG topic-thread overlay, Storybook states, and Playwright e2e coverage are open; the API/UI cannot show a real result yet because it is blocked upstream on TEPP's topic-identity/CHRONOS result contract (TEPP `tepp_api` currently exposes only `AnalysisRunRequest`/`AnalysisRunAccepted`; TEPP issue #156 tracks the completed-result contract) | +| contextual-orchestrator routing, VISION, embedding, schema repair | clients and provenance/session boundary; synthetic authenticated route returned a judge score of `0.98`, OCR succeeded, and region location returned five regions | source + local-integration partial; corpus backfill, capability/readiness evidence, and schema-repair workflow open | +| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; adjacent open PR #367 at exact head `b628722cb000717b0198e4337d12306d4306922d` adds numbered-footnote, leading-empty-cell, and short-ID regressions; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; PR #367 protected checks, arbitrary formula/semantic correctness, and corpus re-backfill remain open | +| Base64/file image regions and multimodal evidence | image-region schema and VISION client boundary; live aggregate has 12,823 images, 22 described images/regions, and 422 failed images; current synthetic VISION route returned five regions | source + local-integration partial; supplied image-table case re-backfill and complete corpus coverage open | +| Abbreviation/multilingual alias/entity disambiguation | catalog hints and resolver boundary | source; live corroboration open | +| SearXNG/internal relation fact check | verification endpoint and unavailable handling; local SearXNG health and JSON query both returned HTTP 200, while some upstream engines reported rate-limit/CAPTCHA results | source + local-integration partial; corroboration policy and reliable external coverage open | +| Valkey event queue and cloud-native Compose stack | queue modules, Compose services, health checks | source + local-integration; delivery stress open | +| 3NF, hot partitions, locks, read/write contention | canonical identifier migration plus existing migrations | source; operational evidence open | +| Rust/GPU/CPU psychometric computation | delegated to TEPP, not reimplemented here | boundary accepted; live TEPP evidence open | +| APA 7 doctoring and Zotero OA records | baseline bibliography, local Zotero API reachable, known metadata found | source + local-integration; OA attachment audit open | +| Browser E2E from login through evidence | authenticated local OIDC login, protected list, drawer/search, popup sweep, and aggregate evidence checks at 390x958; post-migration fresh session had `htmlLang=zh`, localized protected shell, zero console errors/warnings, no horizontal overflow, popup, summary, and Event Lineage | source + local-integration; external provider/runtime evidence remains open | +| Storybook scenes/edge events and design-token coverage | `LineageDag.stories.tsx`, inventory, Storybook build | source + unit | +| External email/project lineage package boundary | PR #343 merged at `125a8069a1554874d8067a15047e19d780ea6b7b` with strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; immutable release open | +| Naruon calendar projection boundary | PR #337 is closed as superseded; PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, and provider conformance remain open | +| Hourly PR review/repair/merge loop | Central `ContextualWisdomLab/.github` scheduler owns `*/15 * * * *` sweep and `0 * * * *` heartbeat; no duplicate repo-local scheduler is required | boundary accepted; current-head runtime open | +| 100% coverage/docstrings/edge-case/release gates | current local checks pass, but repository-wide coverage/docstring reports, hosted checks, independent review, and release evidence are not complete on PR #366 | open | + +## 4. Supplied parsing and semantic cases + +The following user-reported cases remain tracked without storing real post IDs: + +- `case-footnote-01`: footnote/list `li`/`ol` boundary is misclassified. +- `case-table-01`: HTML table parsing fails. +- `case-indent-01` and `case-indent-02`: semantic indentation is wrong. +- `case-multi-project-01`: two projects must produce separate event streams; + internal facilities must not be guessed as Partner/Supplier. +- `case-image-table-01`: image tables need region-aware OCR/description and + rendered Markdown/table support. +- `case-summary-affiliation-01`: a role such as PM needs person, title, and + organization evidence rather than an unqualified collective label. +- `case-r-and-r-01`: requester, assignee, action, and cost/payment owner must + remain explicit in R&R evidence. +- `case-math-01`: bounded metric units such as m³ now have + superscript/subscript-preserving source/semantic normalization in backend and + frontend tests; arbitrary formula parsing, ontology-safe formula semantics, + and the authorized runtime case remain open. + +These are not “resolved” merely because a prompt or heuristic was changed. +Each requires synthetic unit coverage plus an authorized runtime reproduction +or an explicit unavailable result. + +## 5. Product and technical gaps + +- **R&R role/relationship conflation and catalog-linking boundary — evidence-backed (2026-08-21):** + Live UI/UX feedback on a real R&R extraction surfaced three related gaps, + root-caused against source rather than assumed: + 1. **"카탈로그 미연결" is the designed fail-closed behavior, not a bug, but + is undiagnosable from the UI.** `_resolve_existing_cataloged_person_id` + (`backend/app/post_summary_ingestion.py`) is documented as never + inserting a `cataloged_person` row (ADR 0009 — "a missing catalog row + stays unbound rather than inventing a person"). Organization actors do + go through `get_or_create_corporate_entity` (ADR 0010), but that + function's `hierarchy_inference_client`/`verification_client` default to + Null clients whenever no live orchestrator/SearXNG corroboration is + wired — in that state it can only match an *already-cataloged* entity, + never create one, so an organization actor stays unlinked too. The + resulting "카탈로그 미연결" label is a correct, honest reflection of + missing live infrastructure, not a code defect — but it gives the + reader no way to tell "not yet processed" from "no live orchestrator in + this environment" from "verification declined to corroborate." + 2. **Job title and relationship type are conflated into one free-text + field.** `RoleResponsibility.responsibility` (`lineageweave/post_summary.py`) + is documented as "what they are responsible for or did" — a single + string. An observed extraction produced `"상담고객 연구원"` in that one + field, gluing a *relationship-type* signal ("상담 고객", i.e. a + consulting/customer relationship — the same concept already modeled + elsewhere as `post_counterparty_entity.relationship_type_code`, e.g. + `rel_voc`) together with a *job title* ("연구원") that has no home of + its own on `RoleResponsibility` the way `last_known_job_title` does on + `Keyman`. Fixing this needs a new field plus an + `POST_SUMMARY_CONTRACT_VERSION` bump (currently `13`) and an extraction- + prompt change — not a display-layer patch, and not something to + implement without review given every future extraction depends on the + contract version. + 3. **Planned-facility events don't become project/entity evidence, and no + operator inference exists.** A key event whose text names a specific + planned facility (e.g. "X 충전소 구축 계획") produces `key_events`/ + `key_event_details` prose only — it is never checked against + `post_project_mention`/`ProjectEvidence`, so the facility itself is + not recognized as an entity, and no relationship is inferred between it + and the organization the post's own R&R evidence says would operate it. + This is a deliberate ontology-design question, not a quick fix: + inferring an "operates" relationship from event-adjacent context risks + inventing a fact the source text does not state, which is exactly what + ADR 0010's fail-closed design exists to prevent. Needs its own ADR + (a new PROV-O/SKOS relationship class and a conservative admission + rule) before any inference code is written. + Two related, safely-scoped UI fixes shipped alongside this finding: R&R + rows now nest under their affiliated organization's row instead of each + repeating "· 소속: X" as flat text (`buildRoleTree`, `frontend/src/App.tsx`), + and `key_events` sharing the same `project_name` now nest under one + heading instead of repeating the project name as a flat prefix + (`groupKeyEventsByProject`). Neither fix touches extraction, the summary + contract, or catalog creation. +- **Customer master "customer tree" — scope gap, evidence-backed (2026-08-21):** + `/api/customer-master`'s `corporate_entities` list (`backend/app/main.py` + `read_customer_master`, `entity_rows` query) is scoped to + `account.corporate_entity_ids`, which comes only from `account_affiliation` + rows (`backend/app/auth.py` `get_current_account`) — the account's own + employer plus any explicitly granted entities. A live query against the + seeded stack confirms the Demo Corp account's `account_affiliation` grants + exactly one entity (Demo Corp itself) with zero `source_customer_code`/ + `source_customer_name` hints on its posts, so `buildCustomerEntityTree` + (`frontend/src/App.tsx`) renders a single un-nested node, not the "Harbor + Group -> Harbor Devices Korea" customer-affiliate tree ADR 0004 and ADR 0010 + describe as a standing requirement. Counterparty `corporate_entity` rows + that ADR 0010's `get_or_create_corporate_entity` auto-creates are never + linked via `account_affiliation`, so they cannot reach this endpoint no + matter how well-populated the corpus becomes — the tree needs to traverse + observed post/VOC/affiliate-tree evidence, not `account_affiliation` alone. + Separately, there is no schema signal distinguishing "own company" from + "granted customer entity" inside `account_affiliation`: both use the same + `process_unit_id`-bearing row shape (the Demo Analyst account's grants into + "Source company H504"/"H904" carry `process_unit_id` exactly like Demo + Corp's own grant does), so a same-screen filter separating 자사(own + company) attributes from customer attributes has no field to filter on + yet. Needs an ADR before implementation: either an explicit + `account_affiliation`/`corporate_entity` scope flag, or a customer-tree + query redesign: guessing at either without a reviewed decision risks an + ABAC-adjacent regression. +- **Entity and abbreviation resolution — open:** canonical names, aliases, + multilingual labels, team-vs-organization typing, title-aware person + disambiguation, and SearXNG/internal corroboration need end-to-end evidence. +- **Image/HTML semantic units — partially implemented:** source DOM, layout + metadata, region evidence, and provenance must remain separate from embedding + text; transparent/unsupported image conversion and multimodal processing need + live verification. +- **Metric/formula semantics — partially fixed:** bounded metric markup is now + preserved across the backend parser and React renderer. Full formula AST, + units, exponents, and ontology mapping remain an evidence-backed follow-up, + not a claim of mathematical completeness. +- **Authorized source mapping — partially implemented:** the inspected export + relation exposes metadata and artifact paths but no body/content/HTML field. + ADR 0121 and the importer now connect a path plus SHA-256 mapping to an + operator-local MHTML root, while rejecting traversal, missing files, and + digest mismatches before writes. The remaining acceptance work is to mount + the authorized raw artifacts and run the real import/backfill; do not map an + unrelated metadata column as body. +- **TEPP measurement — boundary accepted, runtime open:** LineageWeave must + call TEPP through its published import/REST contract and must not implement a + local theta, psychometric calibration, CAT, or judge score. TEPP owns the + Rust numerical/psychometric layer and its multilevel/multiple-membership/time + model. +- **Orchestration — boundary accepted, runtime open:** all LLM/VISION/embedding + work must cross contextual-orchestrator with provenance, session, cost, schema + validation, synthesis/repair, and capability discovery. No provider key or + model selector belongs here. +- **Authorization — runtime open:** verify actual Keyverse/OIDC login, corp/PU + attributes, post visibility, ABAC/RBAC denials, and no cross-tenant evidence + leakage using aggregate, non-identifying results. +- **Database/operations — audit open:** verify 3NF constraints, hot-partition + behavior, lock boundaries, Valkey event delivery, multithreaded server + behavior, retention grants, and read/write contention on the local Compose + stack. +- **Lineage coverage — open:** the persisted graph has 1,308 post-lineage edges + across 1,929 participating posts, while the bounded current view exposed one + edge and some focused posts had no component. Add a rebuild/coverage gate that + distinguishes genuinely isolated posts from missing extraction or grouping + evidence before presenting a reader-facing branching DAG as complete. +- **Cross-repository email/project lineage — provider boundary implemented, + consumer open:** PR #343 merged at + `125a8069a1554874d8067a15047e19d780ea6b7b`, but the contract remains + unreleased. Naruon issue #1437 still needs a disabled-by- + default admission policy, durable idempotent analysis job, immutable artifact + pin, result projection, accept/correct/reject audit, and integration into the + existing email/thread/project surfaces. No draft branch, direct SQL, shared ORM, + credential forwarding, or automatic promotion of inferred facts is allowed. +- **Calendar interoperability — contract-only:** PR #337 defines the LineageWeave + consumer contract. Naruon must still publish the provider-side read endpoint, + service audience, provider conformance fixtures, sync/revision semantics, and + failure/reconciliation behavior before fail-closed runtime wiring is enabled. +- **Literature/Zotero — open:** record APA 7 references and verify Local Zotero + API availability before claiming synchronization. The repository must retain + only metadata/citations appropriate for public artifacts. +- **Release/quality gates — open:** current PR checks and required reviews must + complete on the exact current head; frontend, backend, browser, accessibility, + Storybook, security, and coverage evidence must be collected before release. + +## 6. Next acceptance loop + +1. Re-fetch PR #366's exact head and required reviews/checks, then separately + audit PR #343 and the superseding calendar contract PR #355. +2. Run frontend lint, tests, build, Storybook, backend tests, and authenticated + browser checks when the local stack is available. +3. Reproduce each case label using synthetic fixtures or authorized runtime + aggregates, preserving `unavailable` as an explicit result. +4. Complete exact-head review and immutable release of the LineageWeave provider + contract before creating the Naruon consumer implementation; do not import a + draft branch or share application database state. +5. Fix only evidence-backed failures, then repeat the exact-head protected merge + gate. Do not self-approve, bypass protection, or claim a PR is merged without + a merge SHA. + +## 7. References (APA 7th) + +ContextualWisdomLab. (2026). *TEPP* [Computer software]. GitHub. +https://github.com/ContextualWisdomLab/TEPP + +ContextualWisdomLab. (2026). *contextual-orchestrator architecture notes* +[Computer software]. GitHub. +https://github.com/ContextualWisdomLab/contextual-orchestrator/blob/main/docs/architecture.md + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). +*Learning to orchestrate agents in natural language with the Conductor*. +arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Tang, Y., Cetin, E., Xu, J., Sun, Q., Nielsen, S., Richard, V., Goda, H., +Tymchenko, I., Nguyen, N., Lee, H., Ashiga, M., Kotyan, S., & Kuroki, S. +(2026). *Sakana Fugu technical report*. arXiv. +https://arxiv.org/abs/2606.21228 + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). +*TRINITY: An evolved LLM coordinator*. arXiv. +https://doi.org/10.48550/arXiv.2512.04695 + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines + (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +ContextualWisdomLab. (2026). *ADR 0012: Temporal relational shared-latent +topic measurement* [ADR]. GitHub. +https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md + +ContextualWisdomLab. (2026). *ADR 0016: TDT, CHRONOS, and Event Ontology +intelligence boundary* [ADR]. GitHub. +https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md + +Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for +structural topic models. *Journal of Statistical Software, 91*(2), 1–40. +https://doi.org/10.18637/jss.v091.i02 + +Mimno, D., Wallach, H. M., Naradowsky, J., Smith, D. A., & McCallum, A. +(2009). Polylingual topic models. In *Proceedings of the 2009 Conference on +Empirical Methods in Natural Language Processing* (pp. 880–889). Association +for Computational Linguistics. https://aclanthology.org/D09-1092/ + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based +information organization*. Springer. https://doi.org/10.1007/978-1-4615-0933-2 + +Kalashnikov, D. V., Chen, Z., Mehrotra, S., & Nuray-Turan, R. (2007). CHRONOS: +Facilitating history discovery by linking temporal records. *Proceedings of +the VLDB Endowment*. https://doi.org/10.14778/2367502.2367559 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 28c59bd48..334001f5d 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -1,14 +1,15 @@ # Storybook inventory Open the catalog after `cd frontend && pnpm run storybook`. Each story is a -buyer-facing control you can click before changing product CSS. +reader-facing control you can click before changing product CSS. -| Story | Buyer next action | Token / module | +| Story | Reader next action | Token / module | |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Evidence/LineageDag` | Inspect a branching Event Lineage, then open a record or read its evidence trail. | `--color-primary`, `--color-accent-orange`, `LineageDag` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/docs/superpowers/plans/2026-08-21-forward-only-fast-stabilization.md b/docs/superpowers/plans/2026-08-21-forward-only-fast-stabilization.md new file mode 100644 index 000000000..8d5cad9e5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-forward-only-fast-stabilization.md @@ -0,0 +1,145 @@ +# Forward-Only Fast Stabilization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stabilize the Event Lineage and ontology buyer surfaces quickly without deleting, shrinking, or reverting accepted product capability. + +**Architecture:** Keep feature work intact and converge it into one reviewable forward stack. Fix exact-head defects test-first, merge the current protected `main` normally into stale feature branches, then stack overlapping follow-up UI work on the stabilized Event Lineage head. The heterogeneous Ontology Explorer remains an independent capability and is hardened rather than folded into the record-lineage renderer. + +**Tech Stack:** React 19, TypeScript 6, Vite 8, Vitest 4, Storybook 10, CSS design tokens, FastAPI/PostgreSQL for independent ontology work, GitHub protected pull requests. + +**Spec:** `docs/adr/0002-figma-access-boundary.md`, PR #330, PR #349, PR #350, issue #341. + +## Global Constraints + +- Do not delete, narrow, or revert buyer-visible capability merely to reduce PR size. +- Do not force-push or rewrite shared branch history. +- Use ordinary merges for stack repair. +- Never transfer predecessor-head checks or reviews to a new head. +- Preserve synthetic-only public fixtures and the Figma confidentiality boundary. +- Keep Event Lineage as reconstructed post/record lineage; keep Ontology Explorer heterogeneous and provenance-aware. +- Every production change starts with a failing regression test. +- Required hosted checks and an independent exact-head approval remain merge gates. + +--- + +### Task 1: Close the Event Date CSS Specificity Defect + +**Files:** +- Create: `frontend/src/LineageDag.css.test.ts` +- Modify: `frontend/src/LineageDag.css` + +**Interfaces:** +- Consumes: the existing `.lineage-dag-node text` rule in `frontend/src/App.css`. +- Produces: a selector whose specificity guarantees the date's 9px muted style wins without `!important`. + +- [ ] **Step 1: Write the failing CSS contract test** + +```ts +/// +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(join(here, "LineageDag.css"), "utf-8"); + +describe("LineageDag CSS contracts", () => { + it("keeps the event-date selector at least as specific as the shared node-text rule", () => { + expect(css).toContain(".lineage-dag-node text.lineage-dag-node-date {"); + expect(css).not.toMatch(/(^|\n)\.lineage-dag-node-date\s*\{/); + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `cd frontend && pnpm exec vitest run src/LineageDag.css.test.ts` + +Expected: FAIL because the stylesheet still declares only `.lineage-dag-node-date`. + +- [ ] **Step 3: Apply the minimal specificity fix** + +```css +.lineage-dag-node text.lineage-dag-node-date { + font-size: 9px; + opacity: 0.72; + fill: var(--text); +} +``` + +- [ ] **Step 4: Run focused and full frontend verification** + +Run: + +```bash +cd frontend +pnpm exec vitest run src/LineageDag.css.test.ts src/LineageDag.test.tsx src/lineageDagI18n.test.ts +pnpm run lint +pnpm run test +pnpm run build +pnpm run build-storybook +``` + +Expected: all commands pass without warnings introduced by this task. + +- [ ] **Step 5: Resolve the exact review thread only after the fix is committed** + +Reply with the new exact head, focused/full verification receipt, and the selector-specificity rationale; then resolve `PRRT_kwDOT22WIM6bAzd8`. + +### Task 2: Repair the Event Lineage Stack Without Dropping Capability + +**Files:** +- Modify only conflict files produced by the ordinary merge of current protected `main` into `feat/lineage-dag-regression`. +- Update: PR #330 description and `docs/product-technical-gap-baseline.md` exact-head receipt. + +**Interfaces:** +- Consumes: protected `main@ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7` or its accepted successor. +- Produces: a branch that is not behind protected `main` and retains every PR #330 behavior and test. + +- [ ] **Step 1: Record pre-merge exact identities and changed-file inventory** +- [ ] **Step 2: Ordinary-merge protected `main` into `feat/lineage-dag-regression`; never rebase or force-push** +- [ ] **Step 3: Resolve conflicts by retaining both current-main product changes and the complete Event Lineage contract** +- [ ] **Step 4: Run the focused DAG suite, all frontend tests, lint, production build, Storybook, documentation hygiene, and `git diff --check`** +- [ ] **Step 5: Update the PR body with the resulting exact parent/head and request a new independent exact-head review** + +### Task 3: Stack the UI/UX Guide Follow-Up on the Stabilized DAG + +**Files:** +- PR #350 branch conflict files only. +- PR #350 description and exact-head receipt. + +**Interfaces:** +- Consumes: the accepted current head of PR #330. +- Produces: one forward stack where #350 adds the authenticated mobile shell and UI/UX Guide v3 work without reimplementing or weakening #330. + +- [ ] **Step 1: Compare #350 against the stabilized #330 head and list overlapping Event Lineage files** +- [ ] **Step 2: Write or retain regression tests for every behavior unique to either PR before conflict resolution** +- [ ] **Step 3: Ordinary-merge the stabilized #330 head into `fix/uiux-standard-guide-v3-postmerge`** +- [ ] **Step 4: Resolve overlap by preserving #330 direction/evidence/accessibility semantics and #350 navigation/responsive shell semantics** +- [ ] **Step 5: Retarget #350 to `feat/lineage-dag-regression` once GitHub reports the forward stack mergeable** +- [ ] **Step 6: Run frontend, Storybook, accessibility, i18n, and browser-width regression gates; request exact-head review** + +### Task 4: Harden the Independent Ontology Explorer in Parallel + +**Files:** +- Only files changed by PR #349 and focused follow-up tests. + +**Interfaces:** +- Consumes: the protected `main` ontology/ABAC/provenance contracts. +- Produces: a merge-ready heterogeneous explorer without moving its capability into Event Lineage. + +- [ ] **Step 1: Re-read every unresolved exact-head review thread on #349** +- [ ] **Step 2: For each valid defect, add the smallest failing test and verify RED** +- [ ] **Step 3: Implement one root-cause fix per test cycle** +- [ ] **Step 4: Run PostgreSQL, backend, frontend, Storybook, export-equivalence, accessibility, i18n, SAST, and documentation gates** +- [ ] **Step 5: Normally merge current `main` if the branch becomes behind; update exact-head evidence without deleting scope** + +### Task 5: Protected Integration + +- [ ] **Step 1: Merge #330 only after current-head terminal checks, zero unresolved threads, and independent approval** +- [ ] **Step 2: Revalidate and merge #350 after #330 using the new protected base** +- [ ] **Step 3: Merge #349 independently when its own exact-head gates qualify** +- [ ] **Step 4: Re-run the buyer Event Lineage and Ontology Explorer end-to-end journeys on protected `main`** +- [ ] **Step 5: Update `CHANGELOG.md`, the gap baseline, release/version evidence, and remove only temporary repair machinery whose purpose is complete** diff --git a/frontend/.gitignore b/frontend/.gitignore index 87b58f06f..75e353d75 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,7 @@ storybook-static *.njsproj *.sln *.sw? + +# Playwright e2e output +test-results/ +playwright-report/ diff --git a/frontend/e2e/topic-lineage-fail-closed.spec.ts b/frontend/e2e/topic-lineage-fail-closed.spec.ts new file mode 100644 index 000000000..bda2d8c30 --- /dev/null +++ b/frontend/e2e/topic-lineage-fail-closed.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "playwright/test"; + +/** + * ADR 0132 decision 5, fail-closed half: a Failed topic-lineage run + * renders its terminal state and operator next action, and renders NO + * topic content — no invented topic identity, no evidence/inference/ + * prediction marks, because those may only come from a real TEPP + * envelope and a Failed run has none. + * + * Requires the seeded compose stack (`make seed`): the seed writes a + * Demo Corp topic-lineage run that is Failed with `tepp_not_available` + * because no TEPP transport is connected (ADR 0022 / ADR 0132). + */ +// demo.admin: the analysis-run panel sits behind Advanced review tools, +// which requires the post_admin realm role (demo.analyst does not have it). +const DEMO_USERNAME = "demo.admin"; +const DEMO_PASSWORD = "lineageweave-demo-only"; +const FAILED_ROW_CAPTION = "Topic lineage · Failed · Demo Corp"; + +test("a Failed topic-lineage run fails closed with a named next action", async ({ page }) => { + await page.goto("/"); + + // Log in through the real Keycloak realm the compose stack ships. + await page.getByRole("button", { name: "Log in" }).click(); + await page.locator("#username").fill(DEMO_USERNAME); + await page.locator("#password").fill(DEMO_PASSWORD); + await page.locator("#kc-login").click(); + + // The analysis-run home is one of the Advanced review tools. + await page.getByText("Advanced review tools").click(); + + // On a deployment holding real imported source data, the synthetic + // Demo Corp runs are hidden on purpose (ADR 0001 / ADR 0042: a reader + // must not mistake the seeded narrative for real evidence). The + // fail-closed walk below needs the seed-only stack `make seed` builds, + // so skip — with the reason named — rather than fail on real data. + const emptyState = page.getByText("No analysis runs visible to this account yet", { + exact: false, + }); + const failedRow = page.getByRole("button", { + name: `Open analysis run: ${FAILED_ROW_CAPTION}`, + }); + await expect(emptyState.or(failedRow).first()).toBeVisible(); + test.skip( + await emptyState.isVisible(), + "Seeded Demo Corp runs are hidden because this deployment has real source data (ADR 0001/0042); run against a make-seed-only stack.", + ); + + // Home list caption stays "kind · status · entity" (ADR 0014), and the + // Failed row's list-level copy names the next action, not the failure code. + await expect(failedRow).toBeVisible(); + await expect(failedRow).toContainText( + "Open this run to see why it failed, then connect the topic-lineage service and re-run.", + ); + await failedRow.click(); + + // Detail: terminal Failed copy tells the operator to connect a + // transport; it never promises a locally invented topic model. + await expect(page.getByRole("heading", { name: FAILED_ROW_CAPTION })).toBeVisible(); + await expect( + page.getByText("Connect a topic-lineage transport from this Failed row", { + exact: false, + }), + ).toBeVisible(); + + // The machine failure code is detail-only, on the status history (ADR 0014). + const history = page.getByRole("list", { name: "Analysis run status history" }); + await expect(history).toContainText("tepp_not_available"); + + // Fail-closed rendering: no topic-thread content and no evidence/ + // inference/prediction marks exist anywhere — a status mark without a + // TEPP envelope would be an invented status (ADR 0132 / TEPP ADR 0016). + await expect(page.locator(".evidence-status-mark")).toHaveCount(0); +}); diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..d8e4dfc87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run --no-file-parallelism --maxWorkers=1", + "test:e2e": "playwright test", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..aa25e0b0d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "playwright/test"; + +/** + * End-to-end tests run against the already-running docker compose stack + * (`make seed` data, Keycloak login) rather than a Playwright-managed dev + * server, because the flows under test span frontend + backend + Postgres + * + Keycloak. Point E2E_BASE_URL elsewhere to target another deployment. + */ +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + retries: 0, + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:15173", + trace: "retain-on-failure", + }, +}); diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..a4f3e7631 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -8,7 +8,10 @@ .app-shell > main { flex: 1; - padding: 1.5rem; + width: 100%; + box-sizing: border-box; + padding: clamp(1.5rem, 3vw, 2.75rem) clamp(1rem, 4vw, 3rem); + background: var(--bg); } /* Login Screen (§3.2 로그인 페이지) */ @@ -64,6 +67,26 @@ } /* App Header (§2.2.1 & §2.2.2) */ +/* Skip link: hidden off-canvas until keyboard-focused, so Tab from the + top of the page reaches main content without stepping through the + header top-menu and the five-item GNB first. */ +.skip-link { + position: absolute; + top: 0.75rem; + left: 1rem; + z-index: var(--z-skip-link); + padding: 0.6rem 1rem; + background: var(--color-primary); + color: var(--color-btn-primary-text); + border-radius: var(--radius-control); + transform: translateY(-4rem); + transition: transform 0.15s ease-in-out; +} + +.skip-link:focus { + transform: translateY(0); +} + .app-header { position: sticky; top: 0; @@ -97,6 +120,108 @@ align-items: center; } +.app-header-search-wrap { + position: relative; +} + +.global-search-panel { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: var(--z-gnb-pulldown); + width: min(24rem, calc(100vw - 2rem)); + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: var(--shadow); +} + +.global-search-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.5rem; + align-items: center; +} + +.global-search-panel label { + min-width: 0; +} + +.global-search-panel input { + width: 100%; + min-height: var(--size-control-min); + padding: 0.65rem 0.8rem; + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + +.global-search-help { + margin: 0.5rem 0 0; + color: var(--text-muted); + font-size: 0.75rem; +} + +.global-search-close { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + cursor: pointer; +} + +.global-search-close svg { + width: 1rem; + height: 1rem; +} + +.site-map-utility { + position: relative; +} + +.site-map-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: var(--z-gnb-pulldown); + min-width: 14rem; + padding: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-control); + box-shadow: var(--shadow); +} + +.site-map-menu .workspace-gnb { + flex-direction: column; + align-items: stretch; + height: auto; + gap: 0.25rem; + margin: 0; + padding: 0; + border: 0; +} + +.site-map-menu .workspace-gnb-item { + width: 100%; + min-height: var(--size-control-min); + height: auto; + justify-content: flex-start; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-control); +} + +.site-map-menu .workspace-gnb-item[aria-current="page"]::after { + display: none; +} + .app-user-profile { font-size: 0.85rem; font-weight: 600; @@ -107,9 +232,120 @@ border: 1px solid var(--border); } +.app-account-scope { + position: relative; + max-width: min(30rem, 34vw); + color: var(--text-muted); + font-size: 0.75rem; +} + +.app-account-scope > summary { + display: flex; + min-width: 0; + min-height: var(--size-control-min); + align-items: center; + gap: 0.35rem; + overflow: hidden; + padding: 0.2rem 0.45rem; + border: 1px solid transparent; + border-radius: var(--radius-control); + cursor: pointer; + list-style: none; +} + +.app-account-scope > summary::-webkit-details-marker { + display: none; +} + +.app-account-scope > summary::after { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + margin-left: 0.15rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + content: ""; + transform: translateY(-0.12rem) rotate(45deg); +} + +.app-account-scope[open] > summary::after { + transform: translateY(0.12rem) rotate(225deg); +} + +.app-account-scope > summary:hover, +.app-account-scope > summary:focus-visible { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + color: var(--text-h); +} + +.app-account-scope-summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-account-scope-more { + flex: 0 0 auto; + color: var(--color-primary); + font-weight: 700; + white-space: nowrap; +} + +.app-account-scope-panel { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: var(--z-gnb-pulldown); + width: min(24rem, calc(100vw - 2rem)); + max-height: min(24rem, 60vh); + overflow: auto; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: 0 10px 28px rgba(16, 24, 40, 0.14); + color: var(--text-h); +} + +.app-account-scope-heading { + margin: 0 0 0.55rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.app-account-scope-panel ul { + display: grid; + gap: 0.35rem; + max-height: 18rem; + overflow: auto; + list-style: none; + padding: 0; + margin: 0; +} + +.app-account-scope-panel li { + overflow-wrap: anywhere; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface-muted); + font-family: var(--mono); + font-size: 0.76rem; +} + /* Drawer Menu Trigger (Mobile) */ .mobile-drawer-trigger { display: none; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + align-items: center; + justify-content: center; background: transparent; border: none; font-size: 1.5rem; @@ -117,6 +353,38 @@ color: var(--color-text-heading); } +.mobile-drawer-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-drawer-backdrop); + background: rgba(0, 0, 0, 0.5); +} + +.mobile-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(20rem, 88vw); + padding: 1rem; + background: var(--surface); + box-shadow: var(--shadow); + overflow-y: auto; + z-index: var(--z-drawer); +} + +.mobile-drawer-close { + display: block; + margin-left: auto; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + border: 0; + background: transparent; + color: var(--text-h); + font-size: 1.5rem; + cursor: pointer; +} + /* App Footer (§2.2.3 & §2.2.4) */ .app-footer { margin-top: auto; @@ -142,7 +410,7 @@ } /* GNB Navigation (§2.3.1) */ -.buyer-gnb { +.workspace-gnb { display: flex; align-items: center; height: var(--gnb-height); @@ -155,134 +423,1241 @@ z-index: var(--z-gnb-pulldown); } -.buyer-gnb-item { +.workspace-gnb-item { + display: flex; + align-items: center; + height: 100%; + border: 0; + background: transparent; + color: var(--color-text); + font: inherit; + font-weight: 700; + cursor: pointer; + position: relative; + padding: 0 0.5rem; + transition: color 0.15s ease; +} + +.workspace-gnb-item:hover { + color: var(--color-text-heading); +} + +.workspace-gnb-item[aria-current="page"] { + color: var(--color-primary); +} + +.workspace-gnb-item[aria-current="page"]::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: var(--gnb-active-indicator-height); + background-color: var(--gnb-active-indicator-color); +} + +/* Button Standards (§4.3) */ +button:focus-visible, +select:focus-visible, +input:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: 2px; +} + +.btn-primary { + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid transparent; + border-radius: var(--radius-control); + padding: 0.5rem 1.15rem; + min-height: var(--size-control-min); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.btn-primary:hover { + background: var(--color-btn-primary-hover); +} + +.btn-secondary { + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + padding: 0.45rem 1rem; + min-height: var(--size-control-min); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.btn-secondary:hover { + background: var(--color-btn-secondary-hover); +} + +/* Language Switcher */ +.language-switcher { + display: inline-flex; + align-items: center; +} + +.language-switcher select { + min-height: var(--size-control-min); + padding: 0.35rem 1.8rem 0.35rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + font: inherit; + font-size: max(16px, 0.82rem); + cursor: pointer; +} + +.error { + color: var(--color-status-alert); +} + +.status-alert { + color: var(--color-status-alert); +} + +/* Board: the primary find-and-open workflow */ +.board-surface, +.workspace-destination { + width: min(100%, var(--layout-content-width)); + margin: 0 auto; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.board-header h2, +.workspace-destination h2 { + margin: 0.15rem 0 0.35rem; + font-size: clamp(1.5rem, 2.2vw, 2rem); + letter-spacing: -0.04em; +} + +.board-header > div > p:last-child, +.workspace-destination-intro { + color: var(--text-muted); +} + +.ask-agent-workspace { + width: min(100%, 1480px); + max-width: 1480px; + min-height: min(760px, calc(100vh - 10rem)); + margin: 0 auto; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); +} + +.ask-agent-workspace-empty { + min-height: 0; +} + +.ask-agent-layout { + display: grid; + grid-template-columns: 16rem minmax(0, 1fr); + min-height: inherit; +} + +.ask-agent-workspace-empty .ask-agent-layout { + min-height: 0; +} + +.ask-agent-main { + min-width: 0; + min-height: inherit; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ask-agent-history { + min-width: 0; + padding: 1rem 0.8rem; + border-right: 1px solid var(--border); + background: var(--surface-muted); +} + +.ask-agent-history-context { + min-width: 0; + display: grid; + gap: 0.25rem; + padding: 0.2rem 0.35rem 0.85rem; + border-bottom: 1px solid var(--border); +} + +.ask-agent-history-context .section-eyebrow { + margin: 0; +} + +.ask-agent-history-context strong { + color: var(--text-h); + font-size: 0.88rem; +} + +.ask-agent-history-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.6rem; + margin: 1rem 0 0.75rem; + padding: 0 0.35rem; +} + +.ask-agent-history-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.45; +} + +.ask-agent-new { + width: auto; + min-height: var(--size-control-min); + padding: 0.55rem 0.65rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-control); + background: var(--color-accent-background); + color: var(--color-primary); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 700; +} + +.ask-agent-new:disabled, +.ask-agent-history-item:disabled { + cursor: wait; + opacity: 0.6; +} + +.ask-agent-history-list { + display: grid; + gap: 0.35rem; + max-height: calc(100vh - 20rem); + overflow-y: auto; + list-style: none; + padding: 0; + margin: 0; +} + +.ask-agent-history-item { + display: grid; + width: 100%; + gap: 0.25rem; + min-height: 3.65rem; + padding: 0.65rem; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-h); + cursor: pointer; + font: inherit; + text-align: left; +} + +.ask-agent-history-item:hover, +.ask-agent-history-item:focus-visible, +.ask-agent-history-item[aria-pressed="true"] { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.ask-agent-history-item strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.78rem; +} + +.ask-agent-history-item span, +.ask-agent-history-empty, +.ask-agent-history-loading { + color: var(--text-muted); + font-size: 0.72rem; +} + +.ask-agent-history-empty, +.ask-agent-history-loading { + margin: 0; + line-height: 1.5; +} + +.ask-agent-history-empty { + display: grid; + gap: 0.25rem; + padding: 0.35rem; +} + +.ask-agent-history-empty strong { + color: var(--text-h); + font-size: 0.76rem; +} + +.ask-agent-history-error { + display: grid; + gap: 0.65rem; + padding: 0.3rem 0.35rem; + color: var(--color-danger, #b42318); + font-size: 0.72rem; + line-height: 1.5; +} + +.ask-agent-history-error p { + margin: 0; +} + +.ask-agent-retry { + min-height: 2.35rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-control); + background: var(--color-accent-background); + color: var(--color-primary); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + font-weight: 700; +} + +.ask-agent-retry:disabled { + cursor: wait; + opacity: 0.6; +} + +.ask-agent-history-load-status { + padding: 0.65rem 0.35rem 0.1rem; + color: var(--text-muted); + font-size: 0.68rem; + text-align: center; +} + +.ask-agent-history-load-status p { + margin: 0; +} + +.ask-agent-header { + flex: 0 0 auto; + margin: 0; + padding: 1.15rem 1.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.ask-agent-header-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.ask-agent-header .section-eyebrow { + margin: 0; +} + +.ask-agent-header h2 { + margin: 0.15rem 0 0; + font-size: 1.25rem; + letter-spacing: -0.03em; +} + +.ask-agent-scope { + padding: 0.3rem 0.55rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-chip); + color: var(--color-primary); + font: 0.7rem var(--mono); + white-space: nowrap; +} + +.ask-agent-header .workspace-destination-intro { + margin: 0.55rem 0 0; + font-size: 0.78rem; +} + +.ask-agent-thread { + flex: 1; + width: 100%; + box-sizing: border-box; + min-height: 0; + overflow-y: auto; + padding: 1.25rem 1.5rem 8.5rem; + scroll-behavior: smooth; +} + +.ask-agent-main-empty .ask-agent-thread { + flex: 0 0 auto; + overflow: visible; + padding: 1rem 1.5rem 0; +} + +.ask-agent-empty { + width: min(100%, 54rem); + margin: 0 auto; + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface-muted); + color: var(--text-muted); +} + +.ask-agent-empty-kicker, +.ask-agent-starter-label { + margin: 0; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.ask-agent-empty h3 { + margin: 0.35rem 0 0.35rem; + color: var(--text-h); + font-size: 1.2rem; +} + +.ask-agent-empty p { + margin-bottom: 0; +} + +.ask-agent-starter-group { + margin-top: 1.25rem; +} + +.ask-agent-starters { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.45rem; +} + +.ask-agent-starter { + min-height: var(--size-control-min); + flex: 1 1 12rem; + padding: 0.65rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.35; + text-align: left; +} + +.ask-agent-starter:hover, +.ask-agent-starter:focus-visible { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.ask-agent-turn { + display: grid; + gap: 0.75rem; + width: min(100%, 52rem); + margin: 0 auto 1.5rem; + content-visibility: auto; + contain-intrinsic-size: 0 180px; +} + +.ask-agent-message-row { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.ask-agent-user-row { + display: flex; + justify-content: flex-end; +} + +.ask-agent-avatar { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 0.7rem; + font-size: 0.68rem; + font-weight: 800; +} + +.ask-agent-user-avatar { + display: none; + border: 1px solid var(--border); + background: var(--color-table-row-hover); + color: var(--text-h); +} + +.ask-agent-assistant-avatar { + background: var(--color-primary); + color: var(--color-btn-primary-text); +} + +.ask-agent-message { + min-width: 0; + overflow-wrap: anywhere; + padding: 0.8rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + line-height: 1.65; +} + +.ask-agent-user-message { + max-width: min(75%, 42rem); + border: 0; + border-radius: 1.25rem; + background: var(--surface-muted); +} + +.ask-agent-user-message .ask-agent-message-label { + display: none; +} + +.ask-agent-assistant-message { + padding: 0; + border: 0; + background: var(--surface); +} + +.ask-agent-message-label { + margin: 0 0 0.3rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.ask-agent-message > p { + margin: 0 0 0.65rem; +} + +.ask-agent-message > p:last-child { + margin-bottom: 0; +} + +.ask-agent-pending { + color: var(--text-muted); +} + +.ask-agent-error { + color: var(--color-danger, #b42318); +} + +.ask-agent-thread-retry { + display: block; + margin: 0 auto 1rem; +} + +.ask-agent-citations { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} + +.ask-agent-citations h4 { + margin: 0 0 0.5rem; + color: var(--text-muted); + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ask-agent-citation-list { + display: grid; + gap: 0.5rem; + list-style: none; + padding: 0; + margin: 0; +} + +.ask-agent-citation { + display: flex; + width: 100%; + min-height: 2.75rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-control); + background: var(--color-accent-background); + color: var(--text-h); + cursor: pointer; + font: inherit; + text-align: left; +} + +.ask-agent-citation span { + color: var(--color-primary); + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; +} + +.ask-agent-composer { + box-sizing: border-box; + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + width: 100%; + max-width: none; + margin: 0; + padding: 1rem max(1.5rem, calc((100% - 52rem) / 2)) 1.15rem; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.ask-agent-main-empty .ask-agent-composer { + position: static; + width: min(100%, 54rem); + margin: 0 auto; + padding: 0.9rem 1.5rem 1.5rem; + border-top: 0; + background: transparent; +} + +.ask-agent-composer:focus-within { + border-top-color: var(--color-focus-border); +} + +.ask-agent-composer-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: 1.25rem; + background: var(--surface); + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.1); +} + +.ask-agent-composer-field:focus-within { + border-color: var(--color-focus-border); + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.ask-agent-composer-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin: 0 0 0.4rem 0.35rem; +} + +.ask-agent-composer-label { + color: var(--text-h); + font-size: 0.78rem; + font-weight: 700; +} + +.ask-agent-composer-label-row span { + color: var(--text-muted); + font-size: 0.7rem; + text-align: right; +} + +.ask-agent-composer textarea { + width: 100%; + min-height: 3rem; + max-height: 10rem; + resize: vertical; + padding: 0.7rem 0.8rem; + border: 0; + outline: none; + background: transparent; + color: var(--text-h); + font: inherit; + line-height: 1.45; +} + +.ask-agent-send { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 0; + border-radius: var(--radius-control); + background: var(--color-primary); + color: var(--color-btn-primary-text); + cursor: pointer; +} + +.ask-agent-send:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.ask-agent-composer-help { + margin: 0.35rem 0.35rem 0; + color: var(--text-muted); + font-size: 0.72rem; +} + +.customer-master-list { + display: grid; + gap: 0.55rem; + list-style: none; + padding: 0; + margin: 1.25rem 0 0; +} + +.customer-master-hint-search { + display: grid; + gap: 0.45rem; + margin-top: 1.25rem; +} + +.customer-master-hint-search label { + color: var(--text-h); + font-size: 0.85rem; + font-weight: 700; +} + +.customer-master-hint-search-row { + display: flex; + gap: 0.55rem; +} + +.customer-master-hint-search-row input { + min-width: 0; + flex: 1; + min-height: 2.75rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + +.customer-master-scope-filter { + display: grid; + grid-template-columns: auto minmax(12rem, 1fr); + align-items: center; + gap: 0.65rem; + margin-top: 1.25rem; +} + +.customer-master-scope-filter label { + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 700; +} + +.customer-master-scope-filter select { + min-height: 2.75rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + +.customer-master-tree-children { + margin-top: 0.55rem; + padding-left: 1rem; + border-left: 2px solid var(--color-accent-border); +} + +.customer-entity-button { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + text-align: left; +} + +.customer-entity-button:hover, +.customer-entity-button[aria-expanded="true"] { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.customer-entity-button span { + color: var(--text-muted); + font-size: 0.8rem; +} + +.customer-entity-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.3rem 0.5rem; +} + +.customer-scope-chip { + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--color-accent-background); + color: var(--text-h) !important; + font-size: 0.72rem !important; + font-weight: 700; +} + +.customer-related-posts { + margin: 0.45rem 0 0.75rem; + padding: 0.75rem; + border-left: 2px solid var(--border); +} + +.customer-keymen { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.customer-keymen h3 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.customer-keymen > .customer-master-list > li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + padding: 0.8rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); +} + +.customer-keymen details, +.customer-keymen details > ul { + flex-basis: 100%; +} + +.customer-keymen details > ul { + margin: 0.65rem 0 0; +} + +.related-post-card { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + text-align: left; +} + +.related-post-card:hover { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.related-post-content { + min-width: 0; + display: grid; + gap: 0.25rem; +} + +.related-post-content .post-body-excerpt { + color: var(--text-muted); + font-size: 0.82rem; +} + +.post-evidence-list { + display: grid; + gap: 0.35rem; + list-style: none; + margin: 0.5rem 0 0; + padding: 0.65rem 0.8rem; + border-left: 2px solid var(--color-accent-border); + background: var(--bg); +} + +.post-evidence-list li { + display: grid; + gap: 0.15rem; +} + +.post-evidence-list li span:first-child { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.board-result-count { + flex: 0 0 auto; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-chip); + background: var(--color-accent-background); + color: var(--color-primary); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.board-controls { + margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: 0 6px 18px rgba(19, 37, 63, 0.05); +} + +.board-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: end; +} + +.board-controls label, +.board-voc-type-filter legend { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.board-controls label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.board-controls input[type="search"], +.board-controls select { + width: 100%; + min-height: var(--size-control-min); + padding: 0.65rem 0.8rem; + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + +.board-controls input[type="search"]::placeholder { + color: var(--text-muted); + opacity: 0.8; +} + +.board-search-help { + margin: 0.45rem 0 1rem; + font-size: 0.8rem; +} + +.board-filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(10rem, 0.28fr) minmax(10rem, 0.28fr) auto; + gap: 0.75rem; + align-items: end; +} + +.board-voc-type-filter { + min-width: 0; + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.85rem; + margin: 0; + padding: 0.7rem 0.8rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg); +} + +.board-voc-type-filter legend { + padding: 0 0.25rem; +} + +.board-voc-type-filter label { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + color: var(--text); + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.board-voc-type-option, +.board-choice-option { + min-height: var(--size-control-min); + padding: 0.3rem 0.45rem; + border: 1px solid transparent; + border-radius: var(--radius-chip); +} + +.board-voc-type-option:hover, +.board-choice-option:hover { + background: var(--color-accent-background); +} + +.board-voc-type-option:focus-within, +.board-choice-option:focus-within { + border-color: var(--color-accent-border); + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.board-source-detail-state-help { + flex: 0 0 100%; + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; +} + +.board-voc-type-filter input { + width: 1rem; + height: 1rem; + accent-color: var(--color-primary); +} + +.board-voc-type-code { + color: var(--text-h); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.board-voc-type-description { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; +} + +.board-reset { + white-space: nowrap; +} + +/* Post List */ +.post-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.75rem; +} + +.post-card { + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.post-card:hover { + border-color: var(--color-accent-border); + box-shadow: 0 8px 20px rgba(19, 37, 63, 0.08); + transform: translateY(-1px); +} + +.post-list-item { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 1rem; + padding: 1rem 1.1rem; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + font: inherit; +} + +.post-card-main { + min-width: 0; + display: grid; + gap: 0.3rem; +} + +.post-title, +.post-body-excerpt, +.post-card-main > .post-meta { + display: block; +} + +.post-title { + color: var(--text-h); + font-size: 1rem; + font-weight: 700; + line-height: 1.45; +} + +.post-body-excerpt { + display: -webkit-box; + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.post-card-main > .post-meta { + font-size: 0.77rem; + line-height: 1.4; +} + +.source-lineage-presence { display: flex; + flex-wrap: wrap; align-items: center; - height: 100%; - border: 0; - background: transparent; - color: var(--color-text); - font: inherit; - font-weight: 700; - cursor: pointer; - position: relative; - padding: 0 0.5rem; - transition: color 0.15s ease; + gap: 0.35rem; } -.buyer-gnb-item:hover { - color: var(--color-text-heading); +.source-lineage-presence-item { + color: var(--text); } -.buyer-gnb-item[aria-current="page"] { - color: var(--color-primary); +.source-lineage-presence-item.is-missing { + color: var(--text-muted); } -.buyer-gnb-item[aria-current="page"]::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: var(--gnb-active-indicator-height); - background-color: var(--gnb-active-indicator-color); +.post-card-badges { + max-width: 12rem; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; } -.buyer-gnb-tools { - margin-left: auto; - display: flex; +.post-badge { + display: inline-flex; align-items: center; + min-height: 1.5rem; + padding: 0.2rem 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-chip); + background: var(--bg); + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.2; + text-align: right; + text-transform: uppercase; } -/* Button Standards (§4.3) */ -.btn-primary { - background: var(--color-btn-primary-bg); - color: var(--color-btn-primary-text); - border: 1px solid transparent; - border-radius: var(--radius-control); - padding: 0.5rem 1.15rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.15s ease-in-out; +.source-lineage-combination { + border-color: var(--color-accent-info); + background: var(--color-accent-info-background); + color: var(--text-h); + gap: 0.3rem; } -.btn-primary:hover { - background: var(--color-btn-primary-hover); +.source-lineage-combination-code { + color: var(--color-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; + letter-spacing: 0.08em; } -.btn-secondary { - background: var(--color-btn-secondary-bg); - color: var(--color-btn-secondary-text); - border: 1px solid var(--color-btn-secondary-border); - border-radius: var(--radius-control); - padding: 0.45rem 1rem; +.source-lineage-combination-label { + color: var(--text-muted); font-weight: 600; - cursor: pointer; - transition: background-color 0.15s ease-in-out; + text-transform: none; } -.btn-secondary:hover { - background: var(--color-btn-secondary-hover); +.post-card .post-list-item:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: -3px; } -/* Language Switcher */ -.language-switcher { - display: inline-flex; +.post-list-item[aria-current="true"], +.ticket-list-item[aria-current="true"] { + border-color: var(--color-accent-info); + box-shadow: inset 0 0 0 1px var(--color-accent-info); +} + +.board-empty { + padding: 2rem 1rem; + border: 1px dashed var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + color: var(--text-muted); + text-align: center; +} + +.board-pagination { + display: flex; + justify-content: center; align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 1.5rem; } -.language-switcher select { +.board-pagination button { + min-width: var(--size-control-min); min-height: var(--size-control-min); - padding: 0.35rem 1.8rem 0.35rem 0.65rem; + padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--text-h); - font: inherit; - font-size: 0.82rem; cursor: pointer; } -.error { - color: var(--color-status-alert); -} - -.status-alert { - color: var(--color-status-alert); +.board-pagination button[aria-current="page"] { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-btn-primary-text); + font-weight: 700; } -/* Post List */ -.post-list { - list-style: none; - padding: 0; - margin: 0; +.board-pagination button:disabled { + cursor: not-allowed; + opacity: 0.45; } -.post-list-item { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - margin-bottom: 0.5rem; +.advanced-review-tools { + margin-top: 2rem; + padding: 1rem; border: 1px solid var(--border); - border-radius: var(--radius-control); - background: none; - cursor: pointer; - text-align: left; - font-size: 1rem; -} - -.post-list-item[aria-current="true"], -.ticket-list-item[aria-current="true"] { - border-color: var(--color-accent-info); - box-shadow: inset 0 0 0 1px var(--color-accent-info); + border-radius: var(--radius-panel); + background: var(--surface); } -.post-badge { - font-size: 0.75rem; - opacity: 0.7; - text-transform: uppercase; +.advanced-review-tools summary { + color: var(--text-h); + cursor: pointer; + font-weight: 700; } /* Popup / Modals (§3.6.1 모달 레이어 투명도 50%) */ @@ -300,20 +1675,68 @@ position: relative; background: var(--surface); color: var(--text); - max-width: 720px; - width: 90%; + width: min(90%, 1180px); max-height: 85vh; overflow-y: auto; - padding: 2rem; - border-radius: 12px; + padding: clamp(1.25rem, 3vw, 2rem); + border: 1px solid var(--border); + border-radius: var(--radius-panel); z-index: var(--z-modal); box-shadow: var(--shadow); } +.popup-panel > h2 { + max-width: calc(100% - 3rem); + margin: 0 0 0.35rem; + font-size: clamp(1.2rem, 2.3vw, 1.6rem); + line-height: 1.35; +} + +.post-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.8rem; +} + +.post-actions button { + min-height: var(--size-control-min); + padding: 0.45rem 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + font-weight: 600; +} + +.post-actions button:hover, +.post-actions button[aria-pressed="true"] { + border-color: var(--color-primary); + background: var(--color-accent-background); + color: var(--color-primary); +} + +.post-actions button:disabled { + cursor: wait; + opacity: 0.55; +} + +.post-action-status { + margin-top: 0.6rem; + color: var(--color-primary); + font-size: 0.85rem; +} + .popup-close { position: absolute; top: var(--space-close-inset); right: var(--space-close-inset); + min-width: var(--size-control-min); + min-height: var(--size-control-min); + display: flex; + align-items: center; + justify-content: center; background: none; border: none; font-size: var(--font-size-close); @@ -379,6 +1802,35 @@ opacity: 0.7; } +.summary-status { + display: grid; + gap: 0.45rem; + opacity: 1; +} + +.summary-status strong { + color: var(--text-h); +} + +.summary-status span, +.summary-status small { + color: var(--text-muted); +} + +.summary-status button { + justify-self: start; + margin-top: 0.25rem; +} + +.summary-status-processing { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.summary-status-unavailable { + border-color: var(--color-accent-orange); +} + .popup-live-body-warning { margin: 0.75rem 0 1rem; padding: 0.65rem 0.75rem; @@ -405,6 +1857,44 @@ border-top: 1px solid var(--border); } +/* Korean summary next to 5W1H so both are scannable at a glance instead + of stacked below the raw post body (UI/UX Standard Guide Ver.3.0 SS3.1 + item order: summary/key events/R&R read first). */ +.popup-analysis-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0 1.5rem; +} + +@media (min-width: 768px) { + .popup-analysis-grid { + grid-template-columns: 1fr 1fr; + align-items: start; + } +} + +/* Projects/semantic evidence and Original source state are both short, + standalone reference blocks (no "next action" reading order between + them) — let them sit side by side on wide viewports instead of each + claiming the full popup width. Falls back to one column when only one + of the two is rendered. */ +.popup-secondary-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0 1.5rem; +} + +@media (min-width: 768px) { + .popup-secondary-grid { + grid-template-columns: 1fr 1fr; + align-items: start; + } + + .popup-secondary-grid > .popup-section:only-child { + grid-column: 1 / -1; + } +} + .popup-section h3 { margin: 0 0 0.5rem; font-size: 1rem; @@ -487,6 +1977,65 @@ margin: 0 0 1.25rem; } +.lineage-dag-header { + margin-bottom: 1rem; +} + +.lineage-dag-header h4 { + margin: 0.15rem 0 0.35rem; +} + +.lineage-dag-description { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.lineage-dag-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin-bottom: 1rem; + color: var(--text-muted); + font-size: 0.8rem; +} + +.lineage-dag-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.lineage-dag-legend-mark { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + border: 2px solid var(--border); + border-radius: 50%; + background: var(--surface-muted); +} + +.lineage-dag-legend-root { + border-color: var(--color-accent-info); +} + +.lineage-dag-legend-branch { + border-color: var(--color-accent-orange); + background: var(--badge-actor-organization-bg); +} + +.lineage-dag-legend-current { + border-color: var(--text-h); + border-width: 3px; +} + +.lineage-dag-viewport { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + .lineage-dag-group figcaption { font-size: 0.85rem; opacity: 0.8; @@ -494,9 +2043,8 @@ } .lineage-dag svg { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); + display: block; + min-width: 100%; } .lineage-dag-edge { @@ -524,23 +2072,55 @@ stroke: var(--color-accent-info); } -.lineage-dag-node text { - font-size: 11px; - fill: var(--text-h); +.lineage-dag-node text { + font-size: 11px; + fill: var(--text-h); +} + +.lineage-dag-node:focus { + outline: none; +} + +.lineage-dag-node:focus circle, +.lineage-dag-node:hover circle { + stroke-width: 2.5; +} + +.lineage-dag-node[aria-current="true"] circle { + stroke-width: 3; + stroke: var(--text-h); +} + +.lineage-dag-inference-note { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 3px solid var(--color-primary); + background: var(--color-accent-background); + color: var(--text); + font-size: 0.85rem; +} + +.lineage-dag-inference-note strong { + display: block; + margin-bottom: 0.2rem; + color: var(--text-h); +} + +.lineage-dag-evidence { + margin-top: 1rem; } -.lineage-dag-node:focus { - outline: none; +.lineage-dag-evidence h4 { + margin-bottom: 0.5rem; } -.lineage-dag-node:focus circle, -.lineage-dag-node:hover circle { - stroke-width: 2.5; +.lineage-dag-evidence table { + width: 100%; } -.lineage-dag-node[aria-current="true"] circle { - stroke-width: 3; - stroke: var(--text-h); +.lineage-dag-evidence td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; } .keyman-list { @@ -598,6 +2178,164 @@ font-size: 0.9rem; } +.ontology-role { + padding: 0.3rem 0; +} + +.ontology-role-unresolved { + border-inline-start: 2px solid var(--accent-warning, #b7791f); + padding-inline-start: 0.5rem; +} + +.ontology-role-resolution, +.ontology-role-note, +.source-context-hint { + color: var(--text-muted); + font-size: 0.8rem; +} + +.source-lineage-hint { + margin-top: 0.9rem; + padding: 0.75rem 0.9rem; + border-inline-start: 3px solid var(--color-primary); + background: var(--color-accent-background); +} + +.source-lineage-hint p { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + margin: 0.35rem 0 0; +} + +.source-lineage-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; + padding: 0; + margin: 0.45rem 0 0; + list-style: none; +} + +.source-lineage-fields li { + display: grid; + gap: 0.15rem; + min-width: 0; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg); +} + +.source-lineage-fields li span { + color: var(--text-muted); + font-size: 0.72rem; +} + +.source-lineage-fields li strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8rem; +} + +.source-lineage-fields .is-missing strong { + color: var(--text-muted); + font-weight: 500; +} + +@media (max-width: 720px) { + .source-lineage-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.ontology-affiliation-link { + font-weight: 600; +} + +/* 5W1H had zero custom styling -- the browser's default
/
+ layout gave every value's evidence-source disclosure its own full-width + line, so 9 "누가" entries meant 18 stacked lines where R&R gets the same + information onto one compact line per entry. .semantic-provenance is + shared with the Projects/semantic evidence section below R&R. */ +.five-w1h dl { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.five-w1h dt { + margin: 0 0 0.3rem; + font-size: 0.85rem; + font-weight: 700; + color: var(--text-h); +} + +.five-w1h dd { + margin: 0; +} + +.five-w1h ul { + display: flex; + flex-direction: column; + gap: 0.3rem; + list-style: none; + padding: 0; + margin: 0; +} + +.five-w1h li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.semantic-provenance { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.semantic-provenance summary { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-chip); + background: var(--bg); + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; + cursor: pointer; +} + +.semantic-provenance[open] summary { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + color: var(--color-primary); +} + +.semantic-provenance .post-badge { + margin: 0; +} + +.semantic-relationship-list li { + display: grid; + gap: 0.35rem; +} + +.semantic-relationship-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +} + .verification-verify_pending { background: var(--badge-status-pending-bg); color: var(--badge-status-pending-text); @@ -613,6 +2351,37 @@ color: var(--badge-status-danger-text); } +.evidence-status-mark { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.1rem 0.6rem; + border-radius: var(--radius-chip); + font-size: var(--font-size-badge); + font-weight: 600; + white-space: nowrap; +} + +.evidence-status-glyph { + font-size: 0.7em; + line-height: 1; +} + +.evidence-status-evidence { + background: var(--badge-status-evidence-bg); + color: var(--badge-status-evidence-text); +} + +.evidence-status-inference { + background: var(--badge-status-inference-bg); + color: var(--badge-status-inference-text); +} + +.evidence-status-prediction { + background: var(--badge-status-prediction-bg); + color: var(--badge-status-prediction-text); +} + .keyman-select { background: none; border: none; @@ -820,29 +2589,330 @@ .app-header { padding: 0 1rem; } - .buyer-gnb { + + .app-account-scope { + max-width: min(24rem, 42vw); + } + .workspace-gnb { padding: 0 1rem; } } @media (max-width: 768px) { /* Phone Breakpoint (<768px) */ - - .buyer-gnb { + + .app-shell > main { + padding: 1.25rem 0.9rem 2rem; + } + + .board-header { + align-items: flex-start; + flex-direction: column; + margin-bottom: 1.15rem; + } + + .board-result-count { + align-self: stretch; + text-align: center; + } + + .board-controls { + padding: 0.75rem; + } + + .board-search-row, + .board-filter-row { + grid-template-columns: 1fr; + } + + .board-search-row .btn-primary { + width: 100%; + } + + .board-voc-type-filter { + grid-column: auto; + } + + .customer-master-scope-filter { + grid-template-columns: 1fr; + } + + .customer-entity-button { + align-items: flex-start; + flex-direction: column; + } + + .customer-entity-meta { + justify-content: flex-start; + } + + .post-list-item { + grid-template-columns: 1fr; + gap: 0.8rem; + padding: 0.9rem; + } + + .post-card-badges { + max-width: none; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + + .post-badge { + text-align: left; + } + + .post-card:hover { + transform: none; + } + + .ask-agent-workspace { + min-height: calc(100vh - 11rem); + border-radius: var(--radius-control); + } + + .ask-agent-workspace-empty { + min-height: 0; + } + + .ask-agent-layout { + display: flex; + flex-direction: column; + gap: 0; + } + + .ask-agent-history { + order: 1; + padding: 0.7rem; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .ask-agent-main { + order: 2; + min-height: calc(100vh - 17rem); + } + + .ask-agent-workspace-empty .ask-agent-main { + min-height: 0; + } + + .ask-agent-history-context { + padding-bottom: 0.7rem; + } + + .ask-agent-history-header { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + margin-bottom: 0.55rem; + } + + .ask-agent-new { + width: auto; + min-height: 2.4rem; + } + + .ask-agent-history-list { + max-height: 9rem; + } + + .ask-agent-header { + padding: 1rem; + } + + .ask-agent-header .workspace-destination-intro { + margin-top: 0.5rem; + } + + .ask-agent-thread { + padding: 1.25rem 0.9rem 8.5rem; + } + + .ask-agent-message-row { + grid-template-columns: 1.75rem minmax(0, 1fr); + gap: 0.55rem; + } + + .ask-agent-avatar { + width: 1.75rem; + height: 1.75rem; + } + + .ask-agent-message { + padding: 0.7rem 0.8rem; + } + + .ask-agent-user-message { + max-width: 88%; + } + + .ask-agent-starters { + display: grid; + grid-template-columns: 1fr; + } + + .ask-agent-citation { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } + + .ask-agent-composer { + padding: 0.75rem 0.9rem 0.9rem; + } + + .ask-agent-main-empty .ask-agent-composer { + padding: 0.75rem 0.9rem 1rem; + } + + .ask-agent-composer-label-row { + align-items: flex-start; + flex-direction: column; + gap: 0.15rem; + margin-left: 0.2rem; + } + + .ask-agent-composer-label-row span { + text-align: left; + } + + /* Fixed height + no-wrap pushed the search/language/logout controls + off the right edge of narrow viewports (unreachable, not just + visually cramped -- logout must always stay reachable per SS3.2). */ + .app-header { + height: auto; + min-height: var(--header-height); + flex-wrap: wrap; + row-gap: 0.5rem; + padding: 0.6rem 1rem; + } + + .app-header-top-menu { + width: 100%; + min-width: 0; + flex: 1 1 100%; + flex-wrap: wrap; + justify-content: flex-end; + row-gap: 0.5rem; + } + + .app-account-scope { + flex: 1 0 100%; + max-width: 100%; + min-width: 0; + order: -1; + } + + .app-account-scope-panel { + position: fixed; + top: calc(var(--header-height) + 0.5rem); + right: 0.9rem; + left: 0.9rem; + width: auto; + max-height: 60vh; + } + + .global-search-panel { + position: fixed; + top: calc(var(--header-height) + 0.5rem); + right: 0.9rem; + left: 0.9rem; + width: auto; + } + + .site-map-utility { + display: none; + } + + .workspace-gnb { display: none; /* Replaced by drawer on mobile */ } .mobile-drawer-trigger { + display: flex; + } + + .mobile-drawer .workspace-gnb { + display: flex; + flex-direction: column; + align-items: stretch; + height: auto; + margin: 0.5rem 0 0; + padding: 0; + gap: 0.25rem; + border-bottom: 0; + } + + .mobile-drawer .workspace-gnb-item { + justify-content: flex-start; + width: 100%; + min-height: 3rem; + height: auto; + padding: 0.75rem; + border-radius: var(--radius-control); + } + + .mobile-drawer .workspace-gnb-item[aria-current="page"]::after { + display: none; + } + + .mobile-drawer .workspace-gnb-item[aria-current="page"] { + background: var(--color-accent-background); + } + + .lineage-dag-viewport { + padding-bottom: 0.25rem; + } + + .lineage-dag svg { + min-width: 42rem; + } + + .lineage-dag-evidence td { display: block; + text-align: left !important; } - .app-header { - padding: 0 1rem; + .lineage-dag-evidence tr { + display: block; + margin-bottom: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + } + + .lineage-dag-evidence th { + display: none; + } + + .lineage-dag-evidence td { + border: 0; + } + + .lineage-dag-evidence td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.15rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; } - + .app-footer { flex-direction: column; align-items: flex-start; gap: 0.5rem; } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..da6a838bd 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -24,6 +24,10 @@ beforeEach(() => { signinRedirect, signoutRedirect, }; + // Post navigation now pushes real history entries (browser back should + // close the popup); reset between tests so one test's opened post doesn't + // leak into the next test's initial render via a stale `?post=` query. + window.history.replaceState({}, "", "/"); }); afterEach(() => { @@ -75,6 +79,9 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; evidenceUnavailable?: boolean; searchUnavailable?: boolean; + askUnavailable?: boolean; + askHistory?: boolean; + askHistoryPages?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; runningLineageRun?: boolean; @@ -83,14 +90,25 @@ describe("App, authenticated", () => { succeededTeppRun?: boolean; pendingTeppRun?: boolean; pluralAffiliations?: boolean; + manyAffiliations?: boolean; + noAffiliations?: boolean; deferMe?: boolean; + deferPosts?: boolean; meFailed?: boolean; postBody?: string; + vocTypeOptions?: { code: string; label: string }[]; + sourceDetailStateCode?: string; + sourceDetailStateOptions?: { code: string; label: string }[]; manyCustomerHints?: number; customerEntityHierarchy?: boolean; + customerScopeFacets?: boolean; + rrOrgWithMembers?: boolean; + groupedKeyEvents?: boolean; staleSummary?: boolean; contentAfterSummary?: boolean; - }): ReturnType & { releaseMe: () => void } { + summaryPending?: boolean; + summaryUnavailable?: boolean; + }): ReturnType & { releaseMe: () => void; releasePosts: () => void } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -115,13 +133,74 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + const authorizedAffiliations = options?.noAffiliations + ? [] + : options?.manyAffiliations + ? [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP", + entity_name: "Demo Corp", + process_unit_id: "pu-demo", + process_unit_code: "DEMO-PU", + process_unit_name: "Demo PU", + }, + { + corporate_entity_id: "corp-north", + corporate_entity_code: "NORTH-CORP", + entity_name: "North Corp", + process_unit_id: "pu-north", + process_unit_code: "NORTH-PU", + process_unit_name: "North PU", + }, + { + corporate_entity_id: "corp-south", + corporate_entity_code: "SOUTH-CORP", + entity_name: "South Corp", + process_unit_id: "pu-south", + process_unit_code: "SOUTH-PU", + process_unit_name: "South PU", + }, + { + corporate_entity_id: "corp-west", + corporate_entity_code: "WEST-CORP", + entity_name: "West Corp", + process_unit_id: "pu-west", + process_unit_code: "WEST-PU", + process_unit_name: "West PU", + }, + { + corporate_entity_id: "corp-hq", + corporate_entity_code: "HQ-CORP", + entity_name: "HQ Corp", + process_unit_id: null, + process_unit_code: null, + process_unit_name: null, + }, + ] + : [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP", + entity_name: "Demo Corp", + process_unit_id: "pu-demo", + process_unit_code: "DEMO-PU", + process_unit_name: "Demo PU", + }, + ]; let releaseMe = () => {}; + let releasePosts = () => {}; const meReady = options?.deferMe ? new Promise((resolve) => { releaseMe = resolve; }) : Promise.resolve(); + const postsReady = options?.deferPosts + ? new Promise((resolve) => { + releasePosts = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -152,6 +231,7 @@ describe("App, authenticated", () => { { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, ] : [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], + account_affiliations: authorizedAffiliations, }); }); } @@ -1035,7 +1115,7 @@ describe("App, authenticated", () => { } const postsUrl = new URL(url, "https://backend.test"); if (postsUrl.pathname === "/api/posts") { - return Promise.resolve( + return postsReady.then(() => jsonResponse( postsUrl.searchParams.get("search") ? [] @@ -1046,8 +1126,18 @@ describe("App, authenticated", () => { post_title: "Public post", voc_type_code: "voc", voc_type_label: "Voice of Customer", + source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", + source_lineage_hints: { + combination_code: "1000", + commercial_context_code: "customer_only_candidate", + inference_status_code: "inferred_from_field_presence", + present_fields: ["customer"], + missing_fields: ["order_pool", "sales_order", "sales_order_item"], + lifecycle_vector: "Z-A-I-ALIVE", + deleted_marker_present: false, + }, created_at: "2026-01-01T00:00:00Z", }, ], @@ -1055,9 +1145,12 @@ describe("App, authenticated", () => { limit: 50, offset: 0, voc_type_options: [ - { code: "voc", label: "Voice of Customer" }, - { code: "vop", label: "Voice of Partner" }, + ...(options?.vocTypeOptions ?? [ + { code: "voc", label: "Voice of Customer" }, + { code: "vop", label: "Voice of Partner" }, + ]), ], + source_detail_state_options: options?.sourceDetailStateOptions ?? [], visibility_options: [{ code: "public", label: "Public" }], }, ), @@ -1073,8 +1166,18 @@ describe("App, authenticated", () => { post_body: options?.postBody ?? "The full body text.", voc_type_code: "voc", voc_type_label: "Voice of Customer", + source_detail_state_code: options?.sourceDetailStateCode, visibility_code: "public", visibility_label: "Public", + source_lineage_hints: { + combination_code: "1000", + commercial_context_code: "customer_only_candidate", + inference_status_code: "inferred_from_field_presence", + present_fields: ["customer"], + missing_fields: ["order_pool", "sales_order", "sales_order_item"], + lifecycle_vector: "Z-A-I-ALIVE", + deleted_marker_present: false, + }, project_evidence: [ { project_key: "source-project", @@ -1164,6 +1267,15 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/summary")) { + if (options?.summaryPending) return new Promise(() => undefined); + if (options?.summaryUnavailable) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "summary unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + } return Promise.resolve( jsonResponse({ post_id: "post-1", @@ -1172,12 +1284,55 @@ describe("App, authenticated", () => { ? { summary_status: "stale", summary_contract_version: 4 } : {}), key_events: ["첫 번째 이벤트"], - roles_and_responsibilities: [ + ...(options?.groupedKeyEvents + ? { + key_event_details: [ + { + event_text: "1st milestone discussed", + project_name: "Case Facility Plan", + evidence_text: null, + }, + { + event_text: "2nd milestone discussed", + project_name: "Case Facility Plan", + evidence_text: null, + }, + { + event_text: "Unrelated standalone event", + project_name: null, + evidence_text: null, + }, + ], + } + : {}), + roles_and_responsibilities: options?.rrOrgWithMembers + ? [ + { + actor_name: "Case Institute", + responsibility: "연구 수행 기관", + actor_type_code: "prov_organization", + affiliated_organization_name: null, + }, + { + actor_name: "Case Researcher One", + responsibility: "상담 고객 연구원", + actor_type_code: "prov_person", + affiliated_organization_name: "Case Institute", + }, + { + actor_name: "Case Researcher Two", + responsibility: "상담 고객 연구원", + actor_type_code: "prov_person", + affiliated_organization_name: "Case Institute", + }, + ] + : [ { actor_name: "Ada West", responsibility: "우리 측 후속", actor_type_code: "prov_person", affiliated_organization_name: "Demo Corp", + affiliated_organization_catalog_id: "corp-1", }, { actor_name: "Priya Nair", @@ -1187,6 +1342,12 @@ describe("App, authenticated", () => { catalog_node_id: "person-priya", catalog_node_type_code: "node_person", }, + { + actor_name: "Northridge Grid Devices", + responsibility: "부품 납품", + actor_type_code: "prov_organization", + affiliated_organization_name: "Northridge Grid", + }, { actor_name: "당사", responsibility: "출하 일정 확정", @@ -1545,7 +1706,95 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/ask/conversations") && method === "GET") { + if (options?.askHistory) { + return Promise.resolve( + jsonResponse({ + conversations: [ + { + conversation_id: "conversation-1", + title: "Saved project question", + updated_at: "2026-08-21T00:00:00Z", + turn_count: 1, + }, + ], + ...(options?.askHistoryPages + ? { + next_cursor: { + updated_at: "2026-08-21T00:00:00Z", + conversation_id: "conversation-1", + }, + } + : {}), + }), + ); + } + return Promise.resolve(jsonResponse({ conversations: [] })); + } + if (options?.askHistoryPages && url.includes("/api/ask/conversations?") && method === "GET") { + return Promise.resolve( + jsonResponse({ + conversations: [ + { + conversation_id: "conversation-2", + title: "Older saved question", + updated_at: "2026-08-20T00:00:00Z", + turn_count: 2, + }, + ], + next_cursor: null, + }), + ); + } + if (options?.askHistoryPages && url.includes("/api/ask/conversations/conversation-1?") && method === "GET") { + return Promise.resolve( + jsonResponse({ + conversation_id: "conversation-1", + title: "Saved project question", + older_cursor: null, + exchanges: [ + { + turn_id: "turn-0", + question_text: "Older saved turn", + answer_text: "The older saved answer is still grounded in evidence.", + cited_post_ids: [], + cited_posts: [], + cited_post_evidence: [], + source_post_ids: [], + }, + ], + }), + ); + } + if (url.endsWith("/api/ask/conversations/conversation-1") && method === "GET") { + return Promise.resolve( + jsonResponse({ + conversation_id: "conversation-1", + title: "Saved project question", + ...(options?.askHistoryPages ? { older_cursor: "2" } : {}), + exchanges: [ + { + turn_id: "turn-1", + question_text: "Which project was saved?", + answer_text: "The saved answer is grounded in the linked source.", + cited_post_ids: ["post-2"], + cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], + cited_post_evidence: [], + source_post_ids: ["post-1", "post-2"], + }, + ], + }), + ); + } if (url.endsWith("/api/ask") && method === "POST") { + if (options?.askUnavailable) { + return Promise.resolve( + new Response( + JSON.stringify({ detail: "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY" }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ), + ); + } return Promise.resolve( jsonResponse({ answer_text: "The cited project is supported by the stored semantic evidence.", @@ -1561,41 +1810,86 @@ describe("App, authenticated", () => { }, ], source_post_ids: ["post-1", "post-2"], + next_action: "Open the cited source posts to verify this answer.", }), ); } - if (url.endsWith("/api/customer-master") && method === "GET") { + const customerMasterUrl = new URL(url, "https://backend.test"); + if (customerMasterUrl.pathname === "/api/customer-master" && method === "GET") { + const requestedCustomerHint = customerMasterUrl.searchParams.get("hint_code"); return Promise.resolve( jsonResponse({ - corporate_entities: options?.customerEntityHierarchy + corporate_entities: options?.customerScopeFacets ? [ { - corporate_entity_id: "corp-group", - corporate_entity_code: "DEMO-GROUP-01", - entity_name: "Demo Group", - entity_level_code: "group", - entity_level_label: "Group", + corporate_entity_id: "corp-own", + corporate_entity_code: "OWN-CORP-01", + entity_name: "Own Scope Corp", + entity_level_code: "company", + entity_level_label: "Company", parent_entity_id: null, + scope_facets: ["authorized_own"], }, { - corporate_entity_id: "corp-demo", - corporate_entity_code: "DEMO-CORP-01", - entity_name: "Demo Corp", + corporate_entity_id: "corp-granted", + corporate_entity_code: "GRANTED-CORP-01", + entity_name: "Granted Scope Corp", entity_level_code: "company", entity_level_label: "Company", - parent_entity_id: "corp-group", + parent_entity_id: null, + scope_facets: ["authorized_granted"], }, - ] - : [ { - corporate_entity_id: "corp-demo", - corporate_entity_code: "DEMO-CORP-01", - entity_name: "Demo Corp", + corporate_entity_id: "corp-observed", + corporate_entity_code: "OBSERVED-CORP-01", + entity_name: "Observed Scope Corp", entity_level_code: "company", entity_level_label: "Company", parent_entity_id: null, + scope_facets: ["observed_organization"], }, - ], + { + corporate_entity_id: "corp-unclassified", + corporate_entity_code: "UNCLASSIFIED-CORP-01", + entity_name: "Unclassified Scope Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + scope_facets: [], + }, + ] + : options?.customerEntityHierarchy + ? [ + { + corporate_entity_id: "corp-group", + corporate_entity_code: "DEMO-GROUP-01", + entity_name: "Demo Group", + entity_level_code: "group", + entity_level_label: "Group", + parent_entity_id: null, + scope_facets: ["authorized_own"], + }, + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP-01", + entity_name: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: "corp-group", + scope_facets: ["authorized_own"], + }, + ] + : [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP-01", + entity_name: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + scope_facets: ["authorized_own"], + }, + ], keymen: [ { person_id: "person-1", @@ -1615,7 +1909,7 @@ describe("App, authenticated", () => { resolution_status: resolvedHintCode === `CUST-${index}` ? "resolved" : "hint_only", hint_trust: "normal", provenance: "source_post.source_customer_code", - })) + })).filter((hint) => !requestedCustomerHint || hint.customer_code === requestedCustomerHint) : [], source_author_hints: [], relationship_network: [ @@ -1657,7 +1951,7 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return Object.assign(fetchMock, { releaseMe }); + return Object.assign(fetchMock, { releaseMe, releasePosts }); } it("renders safe Ask Agent evidence under each cited post", async () => { @@ -1668,12 +1962,91 @@ describe("App, authenticated", () => { await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); await userEvent.click(screen.getByRole("button", { name: "Ask" })); + expect(screen.getByRole("log", { name: "Conversation" })).toBeInTheDocument(); + expect(screen.getByText("Which project?", { exact: true })).toBeInTheDocument(); expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); expect(screen.getByText("Semantic project", { exact: true })).toBeInTheDocument(); expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument(); + expect(screen.getByText("Open the cited source posts to verify this answer.")).toBeInTheDocument(); expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("renders the conversation empty state and submits an Ask Agent question with Enter", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + + expect(screen.getByRole("log", { name: "Conversation" })).toHaveAttribute("aria-busy", "false"); + expect(screen.getByText("Start with a question about the evidence")).toBeInTheDocument(); + expect(screen.getByText("Evidence workspace")).toBeInTheDocument(); + expect(screen.getByText("Authorized evidence")).toBeInTheDocument(); + expect(screen.getByText("Switch between saved questions and source links.")).toBeInTheDocument(); + const input = screen.getByRole("textbox", { name: "Ask a question" }); + const send = screen.getByRole("button", { name: "Ask" }); + expect(send).toBeDisabled(); + await userEvent.type(input, "Which project?{Enter}"); + + expect(await screen.findByText("Which project?", { selector: ".ask-agent-user-message p:last-child" })).toBeInTheDocument(); + expect(input).toHaveValue(""); + }); + + it("restores saved Ask Agent history and can start a new conversation", async () => { + stubBackend({ askHistory: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + + expect(await screen.findByText("Which project was saved?", { exact: true })).toBeInTheDocument(); + const savedConversation = screen.getByRole("button", { name: /Saved project question/ }); + expect(savedConversation).toHaveAttribute("aria-pressed", "true"); + + await userEvent.click(screen.getByRole("button", { name: "New conversation" })); + expect(screen.getByText("Start with a question about the evidence")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Ask a question" })).toHaveFocus(); + await userEvent.click(savedConversation); + expect(await screen.findByText("Which project was saved?", { exact: true })).toBeInTheDocument(); + }); + + it("keeps the Ask Agent conversation visible when the orchestrator is unavailable", async () => { + stubBackend({ askUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const input = screen.getByRole("textbox", { name: "Ask a question" }); + await userEvent.type(input, "Which project?{Enter}"); + + expect( + await screen.findByText("Ask Agent is temporarily unavailable. Saved evidence is still available."), + ).toBeInTheDocument(); + expect(screen.getByText("Which project?", { exact: true })).toBeInTheDocument(); + expect(screen.getByRole("log", { name: "Conversation" })).toHaveAttribute("aria-busy", "false"); + }); + + it("loads older conversations and turns from their scroll boundaries", async () => { + const fetchMock = stubBackend({ askHistory: true, askHistoryPages: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + expect(await screen.findByText("Which project was saved?", { exact: true })).toBeInTheDocument(); + + const historyList = document.querySelector(".ask-agent-history-list") as HTMLUListElement; + Object.defineProperties(historyList, { + scrollHeight: { configurable: true, value: 1000 }, + clientHeight: { configurable: true, value: 300 }, + scrollTop: { configurable: true, value: 760 }, + }); + fireEvent.scroll(historyList); + expect(await screen.findByText("Older saved question", { exact: true })).toBeInTheDocument(); + + const thread = document.querySelector(".ask-agent-thread") as HTMLDivElement; + Object.defineProperties(thread, { + scrollTop: { configurable: true, value: 0, writable: true }, + scrollHeight: { configurable: true, value: 1000, writable: true }, + clientHeight: { configurable: true, value: 500 }, + }); + fireEvent.scroll(thread); + expect(await screen.findByText("Older saved turn", { exact: true })).toBeInTheDocument(); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("before_turn=2"))).toBe(true); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("before_updated_at"))).toBe(true); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, @@ -1715,6 +2088,72 @@ describe("App, authenticated", () => { expect(parentRow?.contains(subsidiaryRow)).toBe(true); }); + it("filters the customer master tree by scope facet", async () => { + // ADR 0125: 자사 속성은 필터로 접근해야 한다 -- an entity's own-company, + // granted-customer, observed, or unclassified facet must be a real + // filter, not just a label. All four buckets are on by default. + stubBackend({ customerScopeFacets: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + + expect(await screen.findByText("Own Scope Corp")).toBeInTheDocument(); + expect(screen.getByText("Granted Scope Corp")).toBeInTheDocument(); + expect(screen.getByText("Observed Scope Corp")).toBeInTheDocument(); + expect(screen.getByText("Unclassified Scope Corp")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("checkbox", { name: "Own company" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Granted customer" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Observed in posts" })); + + expect(screen.queryByText("Own Scope Corp")).not.toBeInTheDocument(); + expect(screen.queryByText("Granted Scope Corp")).not.toBeInTheDocument(); + expect(screen.queryByText("Observed Scope Corp")).not.toBeInTheDocument(); + expect(screen.getByText("Unclassified Scope Corp")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("checkbox", { name: "Unclassified" })); + expect(screen.getByText("No entities match the current scope filter.")).toBeInTheDocument(); + }); + + it("nests R&R rows under their affiliated organization instead of repeating the affiliation as flat text", async () => { + // UI/UX feedback: two researchers at the same institute should read + // as a tree (institute -> its researchers), not three unrelated + // bullets that each separately say "· 소속: Case Institute". + stubBackend({ rrOrgWithMembers: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const instituteRow = await screen.findByText("Case Institute", { selector: "li *" }); + const instituteItem = instituteRow.closest("li") as HTMLLIElement; + const researcherOne = screen.getByText("Case Researcher One").closest("li") as HTMLLIElement; + const researcherTwo = screen.getByText("Case Researcher Two").closest("li") as HTMLLIElement; + expect(instituteItem.contains(researcherOne)).toBe(true); + expect(instituteItem.contains(researcherTwo)).toBe(true); + // Nesting itself conveys the affiliation -- repeating "· 소속: Case + // Institute" text on every nested row would be redundant. + expect(researcherOne.textContent).not.toContain("소속"); + expect(researcherTwo.textContent).not.toContain("소속"); + }); + + it("nests key events sharing a project name instead of repeating the project name as a flat prefix", async () => { + // UI/UX feedback: four key events that all began with the same + // "{project}: " prefix read as flat, disconnected bullets even + // though they clearly belong to one shared plan. + stubBackend({ groupedKeyEvents: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const projectHeading = await screen.findByText("Case Facility Plan", { selector: "li > strong" }); + const projectItem = projectHeading.closest("li") as HTMLLIElement; + const firstMilestone = screen.getByText("1st milestone discussed").closest("li") as HTMLLIElement; + const secondMilestone = screen.getByText("2nd milestone discussed").closest("li") as HTMLLIElement; + expect(projectItem.contains(firstMilestone)).toBe(true); + expect(projectItem.contains(secondMilestone)).toBe(true); + // An event with no shared project stays a flat, ungrouped bullet. + const standalone = screen.getByText("Unrelated standalone event", { exact: false }).closest("li") as HTMLLIElement; + expect(projectItem.contains(standalone)).toBe(false); + }); + it("shows every observed relationship role for a counterparty, flagging multi-role names", async () => { // Feature request (2026-08-19): a real counterparty is not limited // to one role -- a customer in one post can be a competitor, @@ -1782,6 +2221,20 @@ describe("App, authenticated", () => { expect(screen.queryByText("CUST-44")).not.toBeInTheDocument(); }); + it("finds an observed customer code outside the ranked first page", async () => { + const fetchMock = stubBackend({ manyCustomerHints: 45 }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + + await userEvent.type(screen.getByRole("searchbox", { name: "Find source customer code" }), "CUST-44"); + await userEvent.click(screen.getByRole("button", { name: "Find" })); + + expect(await screen.findByText("CUST-44")).toBeInTheDocument(); + expect(screen.queryByText("CUST-0")).not.toBeInTheDocument(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("hint_code=CUST-44"))).toBe(true); + }); + it("searches the board from a semantic project mention", async () => { stubBackend(); render(); @@ -1806,12 +2259,12 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "View post: Public post" })); expect(await screen.findByLabelText("A-100 lineage")).toBeInTheDocument(); - expect(screen.getByLabelText("Open post: Pricing renegotiation follow-up")).toHaveClass( - "lineage-dag-branch", - ); - expect(screen.getByLabelText("Open post: Unrelated: annual account review")).toHaveClass( - "lineage-dag-root", - ); + expect( + screen.getByLabelText("Open post: Pricing renegotiation follow-up (Branch point)"), + ).toHaveClass("lineage-dag-branch"); + expect( + screen.getByLabelText("Open post: Unrelated: annual account review (Root record)"), + ).toHaveClass("lineage-dag-root"); }); it("renders the board landmark and functional post controls", async () => { @@ -1823,7 +2276,7 @@ describe("App, authenticated", () => { expect(within(board).getByLabelText("Search semantic evidence")).toHaveAttribute("type", "search"); expect(within(board).getByRole("list", { name: "Board posts" })).toBeInTheDocument(); expect(within(board).getByText(/Posts shown:/)).toBeInTheDocument(); - expect(within(board).getByLabelText("Voice of Partner")).toBeInTheDocument(); + expect(within(board).getByRole("checkbox", { name: /VOP.*Voice of Partner/ })).toBeInTheDocument(); await userEvent.selectOptions(within(board).getByLabelText("Sort posts"), "title"); await waitFor(() => @@ -1837,6 +2290,125 @@ describe("App, authenticated", () => { expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); }); + it("uses canonical VOC acronyms with explanatory accessible names", async () => { + stubBackend({ + vocTypeOptions: [ + { code: "voc", label: "legacy customer label" }, + { code: "vocc", label: "legacy customer-customer label" }, + { code: "voco", label: "legacy competitor label" }, + { code: "vom", label: "legacy market label" }, + { code: "vop", label: "legacy partner label" }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + for (const code of ["VOC", "VOCC", "VOCO", "VOM", "VOP"]) { + expect(within(board).getByText(code, { selector: ".board-voc-type-code" })).toBeInTheDocument(); + } + expect(within(board).getByRole("checkbox", { name: "VOC — Voice of Customer" })).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "VOCC — Voice of Customer's Customer" }), + ).toBeInTheDocument(); + + setLocale("ko"); + await waitFor(() => + expect( + within(board).getByRole("checkbox", { name: "VOC — 고객의 소리 (Voice of Customer)" }), + ).toBeInTheDocument(), + ); + }); + + it("explains and filters source detail state codes", async () => { + const fetchMock = stubBackend({ + sourceDetailStateCode: "D", + sourceDetailStateOptions: [ + { code: "W", label: "W" }, + { code: "D", label: "D" }, + { code: "A", label: "A" }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect( + within(board).getByRole("group", { name: "Filter by source detail state" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "W — Writing in progress" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "D — Pending approval" }), + ).toBeInTheDocument(); + expect( + within(board).getByRole("checkbox", { name: "A — Approved" }), + ).toBeInTheDocument(); + expect(within(board).getByText("D", { selector: ".board-source-detail-state-code" })).toBeInTheDocument(); + expect(within(board).getByText("Pending approval", { selector: ".board-source-detail-state-description" })).toBeInTheDocument(); + + await userEvent.click(within(board).getByRole("checkbox", { name: "D — Pending approval" })); + await waitFor(() => + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("source_detail_state=D"))).toBe(true), + ); + + setLocale("ko"); + await waitFor(() => + expect( + within(board).getByRole("checkbox", { name: "D — 결재 중 (Pending approval)" }), + ).toBeInTheDocument(), + ); + }); + + it("does not request derived analysis for a writing-state post", async () => { + const fetchMock = stubBackend({ sourceDetailStateCode: " w " }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByText("Summary is not created for writing posts.")).toBeInTheDocument(); + + const requestedPaths = fetchMock.mock.calls.map(([url]) => + new URL(String(url), "https://backend.test").pathname, + ); + expect(requestedPaths).not.toContain("/api/posts/post-1/summary"); + expect(requestedPaths).not.toContain("/api/posts/post-1/evaluation"); + expect(requestedPaths).not.toContain("/api/posts/post-1/five-w1h"); + expect(requestedPaths).not.toContain("/api/posts/post-1/keymen"); + expect(requestedPaths).not.toContain("/api/posts/post-1/counterparties"); + expect(requestedPaths).not.toContain("/api/posts/post-1/lineage"); + expect(requestedPaths).not.toContain("/api/posts/post-1/knowledge-graph"); + expect(requestedPaths).not.toContain("/api/posts/post-1/affiliate-tree"); + expect(requestedPaths).not.toContain("/api/posts/post-1/voc-evidence"); + expect(requestedPaths).not.toContain("/api/posts/post-1/content"); + }); + + it("does not show an empty source detail state filter", async () => { + const fetchMock = stubBackend({ sourceDetailStateOptions: [] }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect( + within(board).queryByRole("group", { name: "Filter by source detail state" }), + ).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("does not request derived panels for writing posts", async () => { + const fetchMock = stubBackend({ + sourceDetailStateCode: " w ", + sourceDetailStateOptions: [{ code: "W", label: "W" }], + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await screen.findByText("The full body text."); + + const urls = fetchMock.mock.calls.map(([url]) => String(url)); + expect( + urls.some((url) => + /\/api\/posts\/[^/]+\/(five-w1h|keymen|counterparties|lineage|knowledge-graph|affiliate-tree|voc-evidence|evaluation)(?:\?|$)/.test(url), + ), + ).toBe(false); + }); + it("opens a post from a DAG node click", async () => { stubBackend(); render(); @@ -1876,6 +2448,9 @@ describe("App, authenticated", () => { ); expect(listButton).toHaveTextContent("Voice of Customer"); expect(listButton).toHaveTextContent("Public"); + expect(listButton).toHaveTextContent("Combination code"); + expect(listButton).toHaveTextContent("1000"); + expect(within(listButton).getByLabelText("Field combination: 1000, Customer only candidate")).toBeInTheDocument(); await userEvent.click(listButton); @@ -1968,9 +2543,29 @@ describe("App, authenticated", () => { expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "R&R affiliation: Demo Corp" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R person: Priya Nair" })).toBeInTheDocument(); expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization"); expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument(); + // R&R groups by affiliated organization, then orders each group + // organization-first, then team, then person (ADR 0004's PROV-O + // broader/narrower direction) -- not raw extraction order. "Northridge + // Grid Devices" is itself an organization row, but it is affiliated + // with "Northridge Grid" and must cluster with Priya Nair under that + // parent, not stand as its own separate group. + const rrList = screen.getByText("당사").closest("ul"); + const rrOrder = within(rrList as HTMLElement) + .getAllByRole("listitem") + .map((item) => item.textContent); + const demoCorpGroup = rrOrder.slice( + rrOrder.findIndex((text) => text?.includes("설계팀")), + rrOrder.findIndex((text) => text?.includes("Ada West")) + 1, + ); + expect(demoCorpGroup[0]).toContain("설계팀"); + expect(demoCorpGroup[1]).toContain("Ada West"); + const northridgeGroupStart = rrOrder.findIndex((text) => text?.includes("Northridge Grid Devices")); + expect(rrOrder[northridgeGroupStart]).toContain("Northridge Grid Devices"); + expect(rrOrder[northridgeGroupStart + 1]).toContain("Priya Nair"); const relatedPosts = screen.getByRole("heading", { name: "Related posts", level: 3 }).closest( ".related-posts-section", ); @@ -1979,7 +2574,9 @@ describe("App, authenticated", () => { expect(relatedPosts).toHaveTextContent("Linked post"); // The Event Lineage DAG belongs to the opened post, not the list surface. expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); - expect(screen.getAllByLabelText("Open post: Pricing renegotiation follow-up")).toHaveLength(1); + expect( + screen.getAllByLabelText("Open post: Pricing renegotiation follow-up (Branch point)"), + ).toHaveLength(1); expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); expect(document.getElementById("post-ask")).not.toHaveFocus(); expect( @@ -1996,12 +2593,18 @@ describe("App, authenticated", () => { expect(screen.queryByText("Related to Priya Nair")).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); + expect(screen.getByRole("dialog", { name: "Public post" })).toBe(popup); + expect(popup).toHaveAttribute("aria-modal", "true"); + expect(popup).toHaveAttribute("aria-labelledby", "post-detail-title"); const evaluation = within(popup as HTMLElement).getByRole("heading", { name: "Post quality (IRT)", }); const eventLineage = within(popup as HTMLElement).getByRole("heading", { name: "Event Lineage" }); const affiliate = within(popup as HTMLElement).getByRole("heading", { name: "Affiliate tree" }); const keyman = within(popup as HTMLElement).getByRole("heading", { name: "Keymen" }); + expect(within(popup as HTMLElement).getByText("Lineage evidence")).toBeInTheDocument(); + expect(within(popup as HTMLElement).getByText("Inference boundary")).toBeInTheDocument(); + expect(within(popup as HTMLElement).getByRole("table", { name: /Evidence trail/ })).toBeInTheDocument(); expect(evaluation.compareDocumentPosition(eventLineage) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); @@ -2010,6 +2613,45 @@ describe("App, authenticated", () => { expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); + it("keeps the post popup keyboard-contained and restores the opener after Escape", async () => { + const user = userEvent.setup(); + stubBackend(); + render(); + + const opener = await screen.findByRole("button", { name: "View post: Public post" }); + await user.click(opener); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + await waitFor(() => expect(dialog).toHaveFocus()); + + await user.keyboard("{Tab}"); + expect(screen.getByRole("button", { name: "Close" })).toHaveFocus(); + const evidenceSummary = screen.getByText("Evidence provenance"); + for (let step = 0; step < 40 && document.activeElement !== evidenceSummary; step += 1) { + await user.keyboard("{Tab}"); + } + expect(evidenceSummary).toHaveFocus(); + const ariaHiddenTabStop = document.createElement("button"); + ariaHiddenTabStop.setAttribute("aria-hidden", "true"); + dialog.insertBefore(ariaHiddenTabStop, screen.getByRole("button", { name: "Close" }).nextSibling); + await user.keyboard("{Tab}"); + expect(ariaHiddenTabStop).not.toHaveFocus(); + const ariaHiddenGroup = document.createElement("div"); + ariaHiddenGroup.setAttribute("aria-hidden", "true"); + const nestedAriaHiddenTabStop = document.createElement("button"); + ariaHiddenGroup.append(nestedAriaHiddenTabStop); + dialog.insertBefore(ariaHiddenGroup, screen.getByRole("button", { name: "Close" }).nextSibling); + await user.keyboard("{Tab}"); + expect(nestedAriaHiddenTabStop).not.toHaveFocus(); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + expect((document.activeElement as HTMLElement).closest("details:not([open])")).toBeNull(); + await user.keyboard("{Escape}"); + + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(opener).toHaveFocus(); + }); + it("labels a stale summary and retries the semantic refresh on request", async () => { const fetchMock = stubBackend({ staleSummary: true }); render(); @@ -2032,6 +2674,26 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "Retry summary refresh" })).toBeInTheDocument(); }); + it("shows processing instead of an empty summary while the request is pending", async () => { + stubBackend({ summaryPending: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByRole("status")).toHaveTextContent("Summary is being prepared."); + expect(screen.queryByText("No summary is available for this record yet.")).not.toBeInTheDocument(); + }); + + it("separates an unavailable summary from a missing saved summary", async () => { + stubBackend({ summaryUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Summary could not be generated."); + expect(screen.getByRole("button", { name: "Retry summary refresh" })).toBeInTheDocument(); + expect(screen.queryByText("No saved summary exists for this record.")).not.toBeInTheDocument(); + }); + it("refreshes newly processed source content after summary generation", async () => { stubBackend({ contentAfterSummary: true }); render(); @@ -2100,7 +2762,7 @@ describe("App, authenticated", () => { ); }); - it("stops loading and gives the buyer a next action when cited evidence is unavailable", async () => { + it("stops loading and gives the reader a next action when cited evidence is unavailable", async () => { stubBackend({ evidenceUnavailable: true }); render(); @@ -2549,7 +3211,7 @@ describe("App, authenticated", () => { expect(screen.getByText("due 2026-01-09")).toBeInTheDocument(); }); - it("tells the buyer how to populate an empty calendar", async () => { + it("tells the reader how to populate an empty calendar", async () => { stubBackend({ calendarCommitments: [] }); render(); @@ -2953,7 +3615,9 @@ describe("App, authenticated", () => { ).toHaveLength(1); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); - const currentNode = within(popup as HTMLElement).getByLabelText("Open post: Public post"); + const currentNode = within(popup as HTMLElement).getByLabelText( + "Open post: Public post (Current record, Root record)", + ); expect(currentNode).toHaveAttribute("aria-current", "true"); const lineageNext = screen.getByRole("status", { name: "Event Lineage next action" }); expect(lineageNext).toHaveTextContent( @@ -3501,12 +4165,198 @@ describe("App, authenticated", () => { ); }); - it("keeps advanced review tools out of the buyer board", async () => { + it("keeps advanced review tools out of the workspace board", async () => { stubBackend(); render(); - expect(await screen.findByRole("navigation", { name: "Buyer navigation" })).toBeInTheDocument(); + expect(await screen.findByRole("navigation", { name: "Workspace navigation" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByLabelText("Authorized scope")).toHaveTextContent("DEMO-CORP / DEMO-PU"); expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument(); + const mobileMenu = screen.getByRole("button", { name: "Open navigation" }); + expect(mobileMenu).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(mobileMenu); + expect(screen.getByRole("button", { name: "Close Workspace navigation" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1); + expect(document.getElementById("mobile-workspace-navigation")).toBeInTheDocument(); + const drawerClose = document.querySelector(".mobile-drawer-close"); + expect(drawerClose).not.toBeNull(); + await userEvent.click(drawerClose as HTMLButtonElement); + expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("aria-expanded", "false"); + const appHeader = document.querySelector("header.app-header"); + expect(appHeader).not.toBeNull(); + expect(within(appHeader as HTMLElement).getByLabelText("Language")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Workspace navigation" })).not.toHaveTextContent("Language"); + await userEvent.click(screen.getByRole("button", { name: "Open navigation" })); + const mobileNavigation = document.getElementById("mobile-workspace-navigation"); + expect(mobileNavigation).not.toBeNull(); + await userEvent.click(within(mobileNavigation as HTMLElement).getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute( + "aria-expanded", + "false", + ); + await userEvent.click(screen.getByRole("button", { name: "Open navigation" })); + expect(screen.getByRole("button", { name: "Close Workspace navigation" })).toBeInTheDocument(); + await userEvent.click( + within(appHeader as HTMLElement).getByRole("button", { name: "Search" }), + ); + expect(document.getElementById("mobile-workspace-navigation")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("searchbox", { name: "Search semantic evidence" })).toHaveFocus(), + ); + }); + + it("discloses every authorized corporation and business unit code", async () => { + stubBackend({ manyAffiliations: true }); + render(); + + const scope = await screen.findByLabelText("Authorized scope"); + const summary = scope.querySelector("summary"); + expect(summary).not.toBeNull(); + expect(summary).toHaveTextContent("DEMO-CORP / DEMO-PU"); + expect(summary).toHaveTextContent("+2"); + expect(scope).not.toHaveAttribute("open"); + + await userEvent.click(summary as HTMLElement); + + expect(scope).toHaveAttribute("open", ""); + expect(within(scope).getByText("NORTH-CORP / NORTH-PU")).toBeVisible(); + expect(within(scope).getByText("HQ-CORP")).toBeVisible(); + }); + + it("does not derive GNB scope from an unrelated entity list", async () => { + stubBackend({ noAffiliations: true, pluralAffiliations: true }); + render(); + + await screen.findByRole("region", { name: "Board" }); + expect(screen.queryByLabelText("Authorized scope")).not.toBeInTheDocument(); + }); + + it("opens the site map utility and closes it after navigation or Escape", async () => { + stubBackend(); + render(); + + const siteMapButton = await screen.findByRole("button", { name: "Site map" }); + expect(siteMapButton).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(siteMapButton); + expect(screen.getByRole("region", { name: "Site map" })).toBeInTheDocument(); + expect(siteMapButton).toHaveAttribute("aria-expanded", "true"); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("region", { name: "Site map" })).not.toBeInTheDocument(); + + await userEvent.click(siteMapButton); + const siteMap = screen.getByRole("region", { name: "Site map" }); + await userEvent.click(within(siteMap).getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Site map" })).not.toBeInTheDocument(); + }); + + it("lets a keyboard user skip the header and GNB to reach main content", async () => { + stubBackend(); + render(); + + await screen.findByRole("navigation", { name: "Workspace navigation" }); + const skipLink = screen.getByRole("link", { name: "Skip to main content" }); + expect(skipLink).toHaveAttribute("href", "#main-content"); + const main = document.getElementById("main-content"); + expect(main).not.toBeNull(); + await userEvent.click(skipLink); + expect(main).toHaveFocus(); + }); + + it("keeps the current workspace while global search is open", async () => { + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + const searchButton = screen.getByRole("button", { name: "Search" }); + await userEvent.click(searchButton); + + const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(searchButton).toHaveAttribute("aria-expanded", "true"); + expect(searchInput).toHaveFocus(); + + await userEvent.keyboard("{Escape}"); + expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(searchButton).toHaveFocus(); + expect(screen.queryByRole("searchbox", { name: "Search semantic evidence" })).not.toBeInTheDocument(); + }); + + it("restores the workspace from the URL and responds to browser navigation", async () => { + stubBackend(); + window.history.replaceState({}, "", "/?workspace=calendar"); + render(); + + expect(await screen.findByRole("heading", { name: "Calendar" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Calendar" })).toHaveAttribute("aria-current", "page"); + + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + expect(new URL(window.location.href).searchParams.get("workspace")).toBe("ask"); + + window.history.replaceState({}, "", "/?workspace=calendar"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(await screen.findByRole("heading", { name: "Calendar" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Calendar" })).toHaveAttribute("aria-current", "page"); + }); + + it("does not expose the admin workspace from an unauthorized deep link", async () => { + stubBackend(); + window.history.replaceState({}, "", "/?workspace=admin"); + render(); + + expect(await screen.findByRole("region", { name: "Board" })).toBeInTheDocument(); + await waitFor(() => expect(new URL(window.location.href).searchParams.has("workspace")).toBe(false)); + expect(screen.queryByText("Admin endpoint catalog")).not.toBeInTheDocument(); + }); + + it("submits global search only after an explicit query", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + await screen.findByRole("heading", { name: "Customer master" }); + await userEvent.click(screen.getByRole("button", { name: "Search" })); + const globalSearchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + + await userEvent.type(globalSearchInput, "not found{Enter}"); + + const board = await screen.findByRole("region", { name: "Board" }); + expect(within(board).getByLabelText("Search semantic evidence")).toHaveValue("not found"); + expect(await screen.findByText("No posts match the current filters.")).toBeInTheDocument(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("search=not+found"))).toBe(true); + }); + + it("does not navigate when the global search is opened before posts load", async () => { + const fetchMock = stubBackend({ deferPosts: true }); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Search" })); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + + fetchMock.releasePosts(); + await userEvent.click(screen.getByRole("button", { name: "Board" })); + const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + expect(searchInput).not.toHaveFocus(); + }); + + it("closes a post popup when browser history moves back", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByRole("heading", { name: "Public post" })).toBeInTheDocument(); + expect(new URL(window.location.href).searchParams.get("post")).toBe("post-1"); + + window.history.replaceState({}, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + + await waitFor(() => expect(screen.queryByRole("heading", { name: "Public post" })).not.toBeInTheDocument()); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..aee702d61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ -import { AdminPanel } from "./components/AdminPanel"; +import { AdminPanel, type AdminBoardTool } from "./components/AdminPanel"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, askAgent, + fetchAskConversation, + fetchAskConversations, BackendError, createAnalysisRun, startAnalysisRun, @@ -28,6 +30,7 @@ import { fetchPostCounterparties, fetchPostEvaluation, fetchPostKeymen, + fetchPostKnowledgeGraph, fetchPostLineage, fetchPostFiveW1H, fetchPostSummary, @@ -48,7 +51,11 @@ import { updateTicketStatus, verifyPostRelations, type ActivityEvent, + type AccountAffiliation, type AskAgentResponse, + type AskConversationCursor, + type AskConversationSummary, + type CurrentUser, type AffiliateNode, type AnalysisRun, type CalendarResponse, @@ -57,14 +64,17 @@ import { type CorporateEntityRef, type CustomerMasterEntity, type CustomerMasterResponse, + type CustomerMasterScopeFacet, type Counterparty, type EvaluationResponse, type IssueTicket, type LineageGraph, + type KnowledgeGraph, type Keyman, type SourceAuthorContext, type PostAiSummary, type PostFiveW1H, + type PostKeyEvent, type PostDetail, type PostContentUnit, type PostImageContent, @@ -77,6 +87,8 @@ import { type PostSortOrder, type RankingList, type PersonRoleHistoryEntry, + type PostRoleResponsibility, + type PostSemanticRelationship, type RelatedNode, type RelatedNodeType, type VocEvidence, @@ -84,14 +96,26 @@ import { } from "./api"; import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; +import { GlobalSearch } from "./components/GlobalSearch"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; -import { BuyerNav, type BuyerDestination } from "./components/BuyerNav"; +import { RoleEvidence } from "./components/RoleEvidence"; +import { SummaryStatus } from "./components/SummaryStatus"; +import { isGenericTeamActor } from "./components/roleEvidenceUtils"; +import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; +import { MenuIcon, CloseIcon, SendIcon } from "./components/icons"; import { LineageDag } from "./LineageDag"; +import { KnowledgeGraphView } from "./KnowledgeGraph"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; +import { + SOURCE_LINEAGE_FIELDS, + sourceLineageContextLabel, + sourceLineageFieldIsPresent, + sourceLineageFieldLabel, +} from "./sourceLineageHints"; import { isSupportedLocale, LOCALE_LABELS, @@ -101,7 +125,6 @@ import { tf, useLocale, } from "./i18n"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -136,6 +159,83 @@ function LanguageSwitcher({ accessToken }: { accessToken?: string }) { ); } +function AuthorizedScope({ affiliations }: { affiliations?: AccountAffiliation[] }) { + const scopeValues = Array.from( + new Set( + (affiliations ?? []) + .map((affiliation) => { + const corporateCode = affiliation.corporate_entity_code.trim(); + if (!corporateCode) return null; + return affiliation.process_unit_code?.trim() + ? `${corporateCode} / ${affiliation.process_unit_code.trim()}` + : corporateCode; + }) + .filter((value): value is string => Boolean(value)), + ), + ); + if (scopeValues.length === 0) return null; + + const visibleScopeValues = scopeValues.slice(0, 3); + const hiddenScopeCount = scopeValues.length - visibleScopeValues.length; + const fullScopeLabel = scopeValues.join(", "); + + return ( +
+ + {t("Authorized scope")}: + + {visibleScopeValues.join(", ")} + + {hiddenScopeCount > 0 ? ( + +{hiddenScopeCount} + ) : null} + +
+

{t("Authorized scope")}

+
    + {scopeValues.map((scopeValue) => ( +
  • {scopeValue}
  • + ))} +
+
+
+ ); +} + +function SiteMapUtility({ + destination, + onChange, + showAdmin, + open, + onToggle, +}: { + destination: WorkspaceDestination; + onChange: (destination: WorkspaceDestination) => void; + showAdmin: boolean; + open: boolean; + onToggle: () => void; +}) { + return ( +
+ + {open ? ( +
+ +
+ ) : null} +
+ ); +} + function searchUnavailableMessage(err: unknown): string { if (err instanceof BackendError && err.status === 503) { return t("Verification unavailable (search is not configured)."); @@ -352,8 +452,10 @@ function ChatPanel({ onChange={(event) => setQuestion(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleAsk()} placeholder={t("What happened between these events?")} + aria-label={t("What happened between these events?")} /> @@ -1591,6 +1693,7 @@ function IssueTicketPanel({ onChange={(event) => setNewTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleCreate()} placeholder={t("New ticket title")} + aria-label={t("New ticket title")} /> = { + org_member_of: "Organization member of", + org_unit_of: "Organization unit of", + org_suborganization_of: "Sub-organization of", + lw_responsible_for: "Responsible for", + lw_supports: "Supports", +}; + +function semanticRelationLabel(relation: PostSemanticRelationship): string { + return t( + SEMANTIC_RELATION_LABELS[relation.predicate_code] ?? + relation.ontology_label ?? + relation.predicate_code, + ); +} + +const ROLE_ACTOR_TYPE_RANK: Record = { + prov_organization: 0, + prov_team: 1, + prov_software_agent: 2, + prov_person: 3, +}; + +// R&R read order follows the PROV-O broader/narrower direction (ADR 0004): +// an organization, then the teams affiliated with it, then the people +// affiliated with it -- not raw LLM extraction order. Grouping is keyed by +// `affiliated_organization_name` for every actor type, including +// organization rows themselves (a subsidiary org's row is affiliated with +// its parent org and must cluster under it, not stand as its own group) +// -- a row only anchors its own group when it has no +// affiliated_organization_name at all. A person's specific team +// membership isn't part of PostRoleResponsibility, so people group by +// their affiliated organization alongside that organization's teams, not +// nested under one specific team. +function sortRolesByOntologyOrder( + roles: PostRoleResponsibility[], +): PostRoleResponsibility[] { + const groupKey = (role: PostRoleResponsibility) => + role.affiliated_organization_name || role.actor_name; + const isGroupAnchor = (role: PostRoleResponsibility) => + role.actor_type_code === "prov_organization" && !role.affiliated_organization_name; + return roles + .map((role, index) => ({ role, index })) + .sort((a, b) => { + const groupCompare = groupKey(a.role).localeCompare(groupKey(b.role)); + if (groupCompare !== 0) return groupCompare; + const anchorCompare = Number(isGroupAnchor(b.role)) - Number(isGroupAnchor(a.role)); + if (anchorCompare !== 0) return anchorCompare; + const rankCompare = + (ROLE_ACTOR_TYPE_RANK[a.role.actor_type_code] ?? 3) - + (ROLE_ACTOR_TYPE_RANK[b.role.actor_type_code] ?? 3); + if (rankCompare !== 0) return rankCompare; + return a.index - b.index; + }) + .map(({ role }) => role); +} + +interface RoleTreeNode { + role: PostRoleResponsibility; + children: RoleTreeNode[]; +} + +// Turns the sorted, grouped list into a real tree: a person or team whose +// affiliated_organization_name matches another row's own actor_name nests +// under that row instead of repeating "· 소속: X" as a flat, disconnected +// bullet next to it -- two researchers at the same institute now share a +// visual parent instead of just sorting adjacent to each other. +function buildRoleTree(roles: PostRoleResponsibility[]): RoleTreeNode[] { + const sorted = sortRolesByOntologyOrder(roles); + const organizationsByName = new Map(); + for (const role of sorted) { + if (role.actor_type_code === "prov_organization" && !organizationsByName.has(role.actor_name)) { + organizationsByName.set(role.actor_name, role); + } + } + const nodesByRole = new Map(); + for (const role of sorted) nodesByRole.set(role, { role, children: [] }); + const roots: RoleTreeNode[] = []; + for (const role of sorted) { + const parent = role.affiliated_organization_name + ? organizationsByName.get(role.affiliated_organization_name) + : undefined; + const node = nodesByRole.get(role) as RoleTreeNode; + if (parent && parent !== role) { + (nodesByRole.get(parent) as RoleTreeNode).children.push(node); + } else { + roots.push(node); + } + } + return roots; +} + +interface KeyEventGroup { + projectName: string | null; + items: { event: PostKeyEvent; originalIndex: number }[]; +} + +// Consecutive key events sharing the same project_name (the LLM's own +// grouping signal) nest under one heading instead of repeating "{project +// name}: " as a flat text prefix on every line -- only adjacent events are +// merged so this never reorders the events' original narrative sequence. +function groupKeyEventsByProject(events: PostKeyEvent[]): KeyEventGroup[] { + const groups: KeyEventGroup[] = []; + events.forEach((event, originalIndex) => { + const projectName = event.project_name ?? null; + const last = groups[groups.length - 1]; + if (projectName !== null && last?.projectName === projectName) { + last.items.push({ event, originalIndex }); + } else { + groups.push({ projectName, items: [{ event, originalIndex }] }); + } + }); + return groups; +} + +function isWritingSourceDetailState(code: string | null | undefined): boolean { + return (code ?? "").trim().toUpperCase() === "W"; +} + function PostDetailPopup({ postId, accessToken, @@ -1695,12 +1917,16 @@ function PostDetailPopup({ const [error, setError] = useState(null); const [summary, setSummary] = useState(null); const [summaryError, setSummaryError] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(true); const [summaryRetry, setSummaryRetry] = useState(0); + const contentStatusRef = useRef<"ready" | "processing" | "unavailable" | undefined>(undefined); + const [contentStatus, setContentStatus] = useState<"ready" | "processing" | "unavailable" | undefined>(undefined); const [fiveW1H, setFiveW1H] = useState(null); const [keymen, setKeymen] = useState(null); const [sourceAuthorContext, setSourceAuthorContext] = useState(null); const [counterparties, setCounterparties] = useState(null); const [lineage, setLineage] = useState(null); + const [knowledgeGraph, setKnowledgeGraph] = useState(null); const [affiliateTrees, setAffiliateTrees] = useState(null); const [vocEvidence, setVocEvidence] = useState(null); const [evaluation, setEvaluation] = useState(null); @@ -1708,8 +1934,60 @@ function PostDetailPopup({ const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); const contentReloadRef = useRef<() => void>(() => undefined); + const popupPanelRef = useRef(null); + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + const panel = popupPanelRef.current; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + if (!panel) return; + panel.focus({ preventScroll: true }); + + const focusableSelector = + 'a[href], area[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab") return; + const focusable = Array.from(panel.querySelectorAll(focusableSelector)).filter( + (element) => + !element.hidden && + !element.closest('[aria-hidden="true"]') && + (!element.closest("details:not([open])") || element.matches("summary")), + ); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + const currentIndex = focusable.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? currentIndex <= 0 + ? focusable.length - 1 + : currentIndex - 1 + : currentIndex < 0 || currentIndex === focusable.length - 1 + ? 0 + : currentIndex + 1; + event.preventDefault(); + focusable[nextIndex].focus(); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus(); + }; + }, []); function reloadKeymen() { + if (isWritingSourceDetailState(post?.source_detail_state_code)) return; fetchPostKeymen(accessToken, postId) .then((r) => { setKeymen(r.keymen); @@ -1727,6 +2005,7 @@ function PostDetailPopup({ } function reloadCounterparties() { + if (isWritingSourceDetailState(post?.source_detail_state_code)) return; fetchPostCounterparties(accessToken, postId) .then((r) => setCounterparties(r.counterparties)) .catch(() => setCounterparties([])); @@ -1741,11 +2020,15 @@ function PostDetailPopup({ setError(null); setSummary(null); setSummaryError(null); + setSummaryLoading(true); + contentStatusRef.current = undefined; + setContentStatus(undefined); setFiveW1H(null); setKeymen(null); setSourceAuthorContext(null); setCounterparties(null); setLineage(null); + setKnowledgeGraph(null); setAffiliateTrees(null); setVocEvidence(null); setEvaluation(null); @@ -1755,13 +2038,57 @@ function PostDetailPopup({ let disposed = false; let contentPollTimer: number | undefined; const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined; - fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); + const loadDerivedPostData = (loadedPost: PostDetail) => { + if (isWritingSourceDetailState(loadedPost.source_detail_state_code)) return; + fetchPostEvaluation(accessToken, postId) + .then((r) => setEvaluation(r.responses)) + .catch(() => setEvaluation([])); + fetchPostFiveW1H(accessToken, postId) + .then(setFiveW1H) + .catch(() => setFiveW1H(null)); + fetchPostKeymen(accessToken, postId) + .then((r) => { + setKeymen(r.keymen); + setSourceAuthorContext(r.source_author_context ?? null); + }) + .catch(() => { + setKeymen([]); + setSourceAuthorContext(null); + }); + fetchPostCounterparties(accessToken, postId) + .then((r) => setCounterparties(r.counterparties)) + .catch(() => setCounterparties([])); + fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); + fetchPostKnowledgeGraph(accessToken, postId) + .then(setKnowledgeGraph) + .catch(() => setKnowledgeGraph(null)); + fetchPostAffiliateTree(accessToken, postId) + .then((r) => setAffiliateTrees(r.trees)) + .catch(() => setAffiliateTrees([])); + fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + }; + fetchPost(accessToken, postId, asOf) + .then((loadedPost) => { + if (disposed) return; + setPost(loadedPost); + loadDerivedPostData(loadedPost); + if (!isWritingSourceDetailState(loadedPost.source_detail_state_code)) { + reloadContent(); + } + }) + .catch((err) => setError(String(err))); const reloadContent = () => fetchPostContent(accessToken, postId) .then((content) => { if (disposed) return; + const previousStatus = contentStatusRef.current; + contentStatusRef.current = content.status; + setContentStatus(content.status); setImageContent(content.images); setStructureUnits(content.units); + if (previousStatus === "processing" && content.status === "ready") { + setSummaryRetry((value) => value + 1); + } if (content.status === "processing" && contentPollTimer === undefined) { contentPollTimer = window.setTimeout(() => { contentPollTimer = undefined; @@ -1775,35 +2102,11 @@ function PostDetailPopup({ setStructureUnits([]); }); contentReloadRef.current = reloadContent; - reloadContent(); fetchPostBookmark(accessToken, postId) .then((r) => setBookmarked(r.bookmarked)) .catch(() => { setBookmarked(null); }); - fetchPostEvaluation(accessToken, postId) - .then((r) => setEvaluation(r.responses)) - .catch(() => setEvaluation([])); - fetchPostFiveW1H(accessToken, postId) - .then(setFiveW1H) - .catch(() => setFiveW1H(null)); - fetchPostKeymen(accessToken, postId) - .then((r) => { - setKeymen(r.keymen); - setSourceAuthorContext(r.source_author_context ?? null); - }) - .catch(() => { - setKeymen([]); - setSourceAuthorContext(null); - }); - fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); - fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); - fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); return () => { disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); @@ -1817,6 +2120,18 @@ function PostDetailPopup({ let disposed = false; setSummary(null); setSummaryError(null); + setSummaryLoading(true); + if (!post) { + return () => { + disposed = true; + }; + } + if (isWritingSourceDetailState(post.source_detail_state_code)) { + setSummaryLoading(false); + return () => { + disposed = true; + }; + } fetchPostSummary(accessToken, postId) .then((value) => { if (!disposed) { @@ -1828,11 +2143,14 @@ function PostDetailPopup({ if (disposed) return; setSummary(null); setSummaryError(summaryFetchError(err)); + }) + .finally(() => { + if (!disposed) setSummaryLoading(false); }); return () => { disposed = true; }; - }, [postId, accessToken, summaryRetry]); + }, [postId, accessToken, summaryRetry, post]); const permanentLink = (() => { const url = new URL(window.location.href); @@ -1883,13 +2201,22 @@ function PostDetailPopup({ return (
-
event.stopPropagation()}> +
event.stopPropagation()} + > {error &&

{error}

} {!post && !error &&

{t("Loading...")}

} {post && ( <> -

{post.post_title}

+

{post.post_title}

{post.voc_type_label ?? post.voc_type_code} ·{" "} {post.visibility_label ?? post.visibility_code} ·{" "} @@ -1929,63 +2256,481 @@ function PostDetailPopup({ {liveBodyWarning}

) : null} -
-

{t("Post body")}

- {post.post_body.trim() ? ( - - ) : ( -

- {t("Source body was not imported; summary and semantic extraction are unavailable.")} -

- )} -
- {(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) && ( -
-

{t("Original source state")}

-
- {post.source_stage_code ? ( - <> -
{t("Source stage")}
-
{post.source_stage_code}
- + +
+
+

{t("Summary")}

+ {isWritingSourceDetailState(post.source_detail_state_code) ? ( + + ) : !summary && (summaryLoading || contentStatus === "processing") ? ( + + ) : summary ? ( + <> + {summary.summary_status === "stale" ? ( +

+ {t("Last saved summary shown. Retry semantic refresh.")} {" "} + +

) : null} - {post.source_detail_state_code ? ( +

{summary.korean_summary}

+ {(summary.key_event_details?.length ?? summary.key_events.length) > 0 && ( <> -
{t("Source detail state")}
-
{post.source_detail_state_code}
+

{t("Key events")}

+
    + {(() => { + const summarySnapshot = summary; + function renderKeyEventBody(event: PostKeyEvent, index: number): ReactNode { + return ( + <> + {event.evidence_text ? ( + + {t("Evidence")}: {event.evidence_text} + + ) : null} + {summarySnapshot.event_clues?.filter((clue) => clue.event_index === index).length ? ( +
    + {t("Connected clues")} + {summarySnapshot.event_clues + .filter((clue) => clue.event_index === index) + .map((clue, clueIndex) => ( + + {clue.clue_type_code.replace(/^clue_/, "")}: {clue.clue_text} + {clue.target_text ? ` · ${t("Target")}: ${clue.target_text}` : ""} + {clue.assertion_code === "assertion_negated" ? ` · ${t("Negated clue")}` : ""} + + ))} +
    + ) : null} + + ); + } + const events: PostKeyEvent[] = + summary.key_event_details ?? + summary.key_events.map((event) => ({ + event_text: event, + project_name: null, + evidence_text: null, + })); + return groupKeyEventsByProject(events).map((group, groupIndex) => { + if (group.projectName && group.items.length > 1) { + return ( +
  • + {group.projectName} +
      + {group.items.map(({ event, originalIndex }) => ( +
    • + {event.event_text} + {renderKeyEventBody(event, originalIndex)} +
    • + ))} +
    +
  • + ); + } + const { event, originalIndex } = group.items[0]; + return ( +
  • + {event.project_name ? {event.project_name}: : null} + {event.event_text} + {renderKeyEventBody(event, originalIndex)} +
  • + ); + }); + })()} +
- ) : null} - {post.source_draft_code ? ( + )} + {summary.roles_and_responsibilities.length > 0 && ( <> -
{t("Source draft marker")}
-
{post.source_draft_code}
+

{t("R&R")}

+
    + {(() => { + function renderRoleNode(node: RoleTreeNode, isChild: boolean): ReactNode { + const rr = node.role; + const isPerson = rr.actor_type_code === "prov_person"; + const actorTypeLabel = t( + rr.actor_type_code === "prov_team" + ? "Team" + : rr.actor_type_code === "prov_software_agent" + ? "Software agent" + : isPerson + ? "Person" + : "Organization", + ); + const person = isPerson + ? keymen?.find((row) => row.person_name === rr.actor_name) + : undefined; + const catalogId = rr.catalog_node_id; + const catalogType = rr.catalog_node_type_code; + const genericTeam = isGenericTeamActor(rr.actor_type_code, rr.actor_name); + let actorName: ReactNode = {rr.actor_name}; + if (catalogType === NODE_PERSON && catalogId) { + actorName = ( + + ); + } else if (person) { + actorName = ( + + ); + } else if (catalogType === NODE_TEAM && catalogId && !genericTeam) { + actorName = ( + + ); + } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) { + actorName = ( + + ); + } + return ( + + // already shows that relationship structurally + // -- repeating "· 소속: X" next to it would be + // redundant, so only un-nested (root) rows show it. + affiliationName={isChild ? null : rr.affiliated_organization_name} + affiliationCatalogId={rr.affiliated_organization_catalog_id} + affiliationLabel={t("Affiliation")} + affiliationAriaLabel={tf("R&R affiliation: {name}", { + name: rr.affiliated_organization_name ?? "", + })} + unresolvedLabel={t("Not linked to catalog")} + genericUnitNote={t("Specific business unit not stated in source")} + onSelectAffiliation={(entityId, entityName) => { + setFocusPerson(null); + setFocusTeam(null); + setFocusEntity({ entityId, entityName }); + }} + > + {node.children.length > 0 ? ( +
      + {node.children.map((child) => renderRoleNode(child, true))} +
    + ) : null} +
    + ); + } + return buildRoleTree(summary.roles_and_responsibilities).map((node) => + renderRoleNode(node, false), + ); + })()} +
- ) : null} - {post.source_deleted_flag ? ( + )} + {summary.semantic_relationships && summary.semantic_relationships.length > 0 && ( <> -
{t("Source deletion marker")}
-
{post.source_deleted_flag}
+

{t("Explicit semantic relationships")}

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

{t("Major event actions")}

+
    + {summary.major_event_actions.map((action, i) => ( +
  • + + {action.project_name ? `${action.project_name}: ` : ""} + {action.action_text} + +
    + {t("Requester")}: {action.requester_actor_name ?? t("Not stated in source")} +
    +
    + {t("Processor")}: {action.processor_actor_name ?? t("Not stated in source")} +
    + + {t("Evidence")}: {action.evidence_text} + +
  • + ))} +
+ + )} + {summary.quantitative_observations && summary.quantitative_observations.length > 0 && ( + <> +

{t("Quantitative evidence")}

+
    + {summary.quantitative_observations.map((observation, i) => ( +
  • + + {observation.label_text}: {observation.raw_value_text} + + {observation.quantity_numeric !== null ? ( +
    + {t("Quantity")}: {observation.quantity_numeric} {observation.quantity_unit_code} +
    + ) : null} + {observation.qualifier_text ?
    {observation.qualifier_text}
    : null} + + {t("Evidence")}: {observation.evidence_text} + +
    + {t("Evidence provenance")} + + {t("Ontology class")}: {t(observation.ontology_label ?? "Quantitative observation")} + + + {t("Extraction source")}: {observation.extraction_method} + +
    +
  • + ))} +
+ + )} + {summary.source_grounded_facts && summary.source_grounded_facts.length > 0 && ( + <> +

{t("Source-grounded facts")}

+
    + {summary.source_grounded_facts.map((fact, i) => ( +
  • + + {fact.label_text}: {fact.value_text} + + {fact.assertion_code === "assertion_negated" ? ( +
    {t("Negated condition")}
    + ) : null} + {fact.normalized_date ? ( +
    + {t("Normalized date")}: {fact.normalized_date} +
    + ) : null} + {fact.normalization_evidence_text ? ( + + {t("Normalization evidence")}: {fact.normalization_evidence_text} + + ) : null} + + {t("Evidence")}: {fact.evidence_text} + +
    + {t("Evidence provenance")} + + {t("Ontology class")}: {t(fact.ontology_label ?? "Source-grounded fact")} + + + {t("Extraction source")}: {fact.extraction_method} + +
    +
  • + ))} +
+ + )} + + ) : summaryError ? ( + setSummaryRetry((value) => value + 1)} + /> + ) : ( + setSummaryRetry((value) => value + 1)} + /> + )} +
+
+ +
+
+ +
+ {post.project_evidence && post.project_evidence.length > 0 ? ( +
+

{t("Projects / semantic evidence")}

+
    + {post.project_evidence.map((project) => ( +
  • + {" "} + {project.confidence === null + ? `(${t("Hint only")})` + : `(${Math.round(project.confidence * 100)}%)`} + : {project.evidence} +
    + {t("Evidence provenance")} + + {t("Ontology class")}: {t(project.ontology_label ?? "Project")} + + + {t("Extraction source")}: {projectExtractionLabel(project.extraction_method)} + + + {t("Evidence field")}: {projectProvenanceLabel(project.provenance)} + +
    +
  • + ))} +
+
+ ) : null} + + {(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_process_unit_catalog_name || + post.source_sales_pool_code || + post.source_sales_pool_name || + post.source_order_pool_code || + post.source_sales_order_code || + (post.source_sales_order_item_number !== null && post.source_sales_order_item_number !== undefined) || + post.source_inspection_point_code || + post.source_customer_code || + post.source_customer_name || + post.source_project_code || + post.source_project_name || + post.source_system_code || + post.source_record_key) && ( +
+

{t("Original source state")}

+
+ {post.source_stage_code ? ( + <> +
{t("Source stage")}
+
{post.source_stage_code}
+ + ) : null} + {post.source_detail_state_code ? ( + <> +
{t("Source detail state")}
+ {(() => { + const presentation = presentSourceDetailState(post.source_detail_state_code); + return ( +
+ {presentation.code} · {presentation.description} +
+ ); + })()} + + ) : null} + {post.source_draft_code ? ( + <> +
{t("Source draft marker")}
+
{post.source_draft_code}
+ + ) : null} + {post.source_deleted_flag ? ( + <> +
{t("Source deletion marker")}
+
{post.source_deleted_flag}
+ + ) : null} + {post.source_author_code ? ( + <>
{t("Source author code")}
{post.source_author_code}
@@ -2020,6 +2765,14 @@ function PostDetailPopup({
{post.source_process_unit_code}
) : null} + {post.source_process_unit_catalog_name ? ( + <> +
{t("Source process unit catalog hint")}
+
+ {t("Catalog hint")}: {post.source_process_unit_catalog_name} +
+ + ) : null} {post.source_sales_pool_code ? ( <>
{t("Source sales pool")}
@@ -2032,6 +2785,30 @@ function PostDetailPopup({
{post.source_sales_pool_name}
) : null} + {post.source_order_pool_code ? ( + <> +
{t("Source order pool")}
+
{post.source_order_pool_code}
+ + ) : null} + {post.source_sales_order_code ? ( + <> +
{t("Source sales order")}
+
{post.source_sales_order_code}
+ + ) : null} + {post.source_sales_order_item_number !== null && post.source_sales_order_item_number !== undefined ? ( + <> +
{t("Source sales order item")}
+
{post.source_sales_order_item_number}
+ + ) : null} + {post.source_inspection_point_code ? ( + <> +
{t("Source inspection point")}
+
{post.source_inspection_point_code}
+ + ) : null} {post.source_customer_code ? ( <>
{t("Source customer code")}
@@ -2070,202 +2847,52 @@ function PostDetailPopup({ ) : null}

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

+ {post.source_lineage_hints ? ( +
+

{t("Source lineage combination")}

+

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

+

{t("Field combination")}

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

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

+
+ ) : null}
)} +
- - - {post.project_evidence && post.project_evidence.length > 0 ? ( -
-

{t("Projects / semantic evidence")}

-
    - {post.project_evidence.map((project) => ( -
  • - {" "} - {project.confidence === null - ? `(${t("Hint only")})` - : `(${Math.round(project.confidence * 100)}%)`} - : {project.evidence} -
    - {t("Evidence provenance")} - - {t("Ontology class")}: {t(project.ontology_label ?? "Project")} - - - {t("Extraction source")}: {projectExtractionLabel(project.extraction_method)} - - - {t("Evidence field")}: {projectProvenanceLabel(project.provenance)} - -
    -
  • - ))} -
-
- ) : null} - -
-

{t("Summary")}

- {summary ? ( - <> - {summary.summary_status === "stale" ? ( -

- {t("Last saved summary shown. Retry semantic refresh.")} {" "} - -

- ) : null} -

{summary.korean_summary}

- {(summary.key_event_details?.length ?? summary.key_events.length) > 0 && ( - <> -

{t("Key events")}

-
    - {(summary.key_event_details ?? summary.key_events.map((event) => ({ event_text: event, project_name: null }))).map((event, i) => ( -
  • - {event.project_name ? {event.project_name}: : null} - {event.event_text} -
  • - ))} -
- - )} - {summary.roles_and_responsibilities.length > 0 && ( - <> -

{t("R&R")}

-
    - {summary.roles_and_responsibilities.map((rr, i) => { - const isPerson = rr.actor_type_code === "prov_person"; - const actorTypeLabel = t( - rr.actor_type_code === "prov_team" - ? "Team" - : isPerson - ? "Person" - : "Organization", - ); - const person = isPerson - ? keymen?.find((row) => row.person_name === rr.actor_name) - : undefined; - const catalogId = rr.catalog_node_id; - const catalogType = rr.catalog_node_type_code; - let actorName: ReactNode = {rr.actor_name}; - if (catalogType === NODE_PERSON && catalogId) { - actorName = ( - - ); - } else if (person) { - actorName = ( - - ); - } else if (catalogType === NODE_TEAM && catalogId) { - actorName = ( - - ); - } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) { - actorName = ( - - ); - } - return ( -
  • - - {actorTypeLabel} - {" "} - {actorName} - {rr.affiliated_organization_name && ( - ({rr.affiliated_organization_name}) - )} - : {rr.responsibility} -
  • - ); - })} -
- - )} - {summary.major_event_actions && summary.major_event_actions.length > 0 && ( - <> -

{t("Major event actions")}

-
    - {summary.major_event_actions.map((action, i) => ( -
  • - - {action.project_name ? `${action.project_name}: ` : ""} - {action.action_text} - -
    - {t("Requester")}: {action.requester_actor_name ?? t("Not stated in source")} -
    -
    - {t("Processor")}: {action.processor_actor_name ?? t("Not stated in source")} -
    - - {t("Evidence")}: {action.evidence_text} - -
  • - ))} -
- - )} - - ) : summaryError ? ( -

{summaryError}

+
+

{t("Post body")}

+ {post.post_body.trim() ? ( + ) : ( -

{t("No summary is available for this record yet.")}

+

+ {t("Source body was not imported; summary and semantic extraction are unavailable.")} +

)}
@@ -2306,6 +2933,12 @@ function PostDetailPopup({ />
+ {knowledgeGraph ? ( +
+ +
+ ) : null} + {focusEventLineage && ( )} {analysisRunCanRequestTeppRetry(selected) && (

- Connect a TEPP transport from this Failed row. Request a lineage - reconstruction does not invent a measurement. + {selected.run_kind_code === "analysis_run_topic_lineage" + ? "Connect a topic-lineage transport from this Failed row. Request a " + + "lineage reconstruction does not invent a topic model." + : "Connect a TEPP transport from this Failed row. Request a lineage " + + "reconstruction does not invent a measurement."}

)} {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( @@ -3566,19 +4231,81 @@ function ReportsPanel({ ); } -const POST_PAGE_SIZE = 50; -type BoardSortOrder = PostSortOrder; +const POST_PAGE_SIZE = 50; +type BoardSortOrder = PostSortOrder; + +const VOC_TYPE_PRESENTATIONS: Record = { + voc: { code: "VOC", englishLabel: "Voice of Customer" }, + vocc: { code: "VOCC", englishLabel: "Voice of Customer's Customer" }, + voco: { code: "VOCO", englishLabel: "Voice of Competitor" }, + vom: { code: "VOM", englishLabel: "Voice of Market" }, + vop: { code: "VOP", englishLabel: "Voice of Partner" }, +}; + +function presentVocType(option: PostFilterOption): { + code: string; + description: string; + accessibleName: string; +} { + const presentation = VOC_TYPE_PRESENTATIONS[option.code.trim().toLowerCase()]; + const englishLabel = presentation?.englishLabel ?? option.label; + const description = t(englishLabel); + return { + code: presentation?.code ?? option.code.toUpperCase(), + description, + accessibleName: + description === englishLabel + ? `${presentation?.code ?? option.code.toUpperCase()} — ${englishLabel}` + : `${presentation?.code ?? option.code.toUpperCase()} — ${description} (${englishLabel})`, + }; +} + +const SOURCE_DETAIL_STATE_PRESENTATIONS: Record = { + W: "Writing in progress", + D: "Pending approval", + A: "Approved", +}; + +function presentSourceDetailState(code: string): { + code: string; + description: string; + accessibleName: string; +} { + const normalizedCode = code.trim().toUpperCase(); + const englishLabel = SOURCE_DETAIL_STATE_PRESENTATIONS[normalizedCode] ?? "Unmapped source detail state"; + const description = t(englishLabel); + return { + code: normalizedCode || code, + description, + accessibleName: + description === englishLabel + ? `${normalizedCode || code} — ${englishLabel}` + : `${normalizedCode || code} — ${description} (${englishLabel})`, + }; +} function PostList({ accessToken, showLabPanels = false, postIdToOpen = null, onPostOpened, + focusSearchRequest = 0, + onSearchFocusHandled, + globalSearchRequest = null, + onGlobalSearchHandled, + adminTool = null, + onAdminToolHandled, }: { accessToken: string; showLabPanels?: boolean; postIdToOpen?: string | null; onPostOpened?: () => void; + focusSearchRequest?: number; + onSearchFocusHandled?: () => void; + globalSearchRequest?: { id: number; query: string } | null; + onGlobalSearchHandled?: () => void; + adminTool?: AdminBoardTool | null; + onAdminToolHandled?: () => void; }) { const [posts, setPosts] = useState(null); const [graph, setGraph] = useState(null); @@ -3605,10 +4332,54 @@ function PostList({ const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState([]); const [vocTypeFilterOptions, setVocTypeFilterOptions] = useState([]); + const [sourceDetailStateFilter, setSourceDetailStateFilter] = useState([]); + const [sourceDetailStateFilterOptions, setSourceDetailStateFilterOptions] = useState([]); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); const postsRequest = useRef(0); + const searchInputRef = useRef(null); + const lastFocusedSearchRequest = useRef(0); + const lastGlobalSearchRequest = useRef(0); + const advancedReviewRef = useRef(null); + + useEffect(() => { + if (focusSearchRequest <= 0) { + // The parent intentionally reuses 1 after each handled request resets + // its counter to 0. Reset the local guard with it so the next global + // Search action can focus the input again. + lastFocusedSearchRequest.current = 0; + return; + } + if (focusSearchRequest <= lastFocusedSearchRequest.current) return; + const input = searchInputRef.current; + if (!input) return; + lastFocusedSearchRequest.current = focusSearchRequest; + input.focus(); + onSearchFocusHandled?.(); + }, [focusSearchRequest, onSearchFocusHandled, posts]); + + useEffect(() => { + if (!globalSearchRequest) { + lastGlobalSearchRequest.current = 0; + return; + } + if (globalSearchRequest.id <= lastGlobalSearchRequest.current) return; + lastGlobalSearchRequest.current = globalSearchRequest.id; + searchBoard(globalSearchRequest.query); + onGlobalSearchHandled?.(); + }, [globalSearchRequest, onGlobalSearchHandled]); + + useEffect(() => { + if (!adminTool || !posts || !advancedReviewRef.current) return; + const details = advancedReviewRef.current; + details.open = true; + const target = adminTool === "advanced" || adminTool === "lineage" + ? details + : details.querySelector(`[data-admin-surface="${adminTool}"]`) ?? details; + window.requestAnimationFrame(() => target.scrollIntoView({ behavior: "smooth", block: "start" })); + onAdminToolHandled?.(); + }, [adminTool, onAdminToolHandled, posts]); function openReportFromAnalysisRun( periodCode: string, @@ -3648,6 +4419,13 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + if (!options?.fromPopState) { + const url = new URL(window.location.href); + if (url.searchParams.get("post") !== postId) { + url.searchParams.set("post", postId); + window.history.pushState({}, "", `${url.pathname}${url.search}${url.hash}`); + } + } } useEffect(() => { @@ -3656,6 +4434,22 @@ function PostList({ onPostOpened?.(); }, [onPostOpened, postIdToOpen]); + useEffect(() => { + function handlePopState() { + const postId = new URLSearchParams(window.location.search).get("post"); + if (postId) { + selectPost(postId, { fromPopState: true }); + } else { + setSelectedPostId(null); + setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedFromReportMember(false); + } + } + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + function closeSelectedPost() { setSelectedPostId(null); setOpenedAfterCutoff(false); @@ -3688,6 +4482,7 @@ function PostList({ (page - 1) * POST_PAGE_SIZE, query, typeFilter.length > 0 ? typeFilter : undefined, + sourceDetailStateFilter.length > 0 ? sourceDetailStateFilter : undefined, visibilityFilter === "all" ? undefined : visibilityFilter, sort, ); @@ -3695,6 +4490,7 @@ function PostList({ setPosts(response.posts); setTotalPosts(response.total_count); setVocTypeFilterOptions(response.voc_type_options ?? []); + setSourceDetailStateFilterOptions(response.source_detail_state_options ?? []); setVisibilityFilterOptions(response.visibility_options ?? []); setCurrentPage(page); } catch (err) { @@ -3703,7 +4499,7 @@ function PostList({ } finally { if (requestId === postsRequest.current) setLoadingPage(false); } - }, [accessToken, searchQuery, sortOrder, typeFilter, visibilityFilter]); + }, [accessToken, searchQuery, sortOrder, typeFilter, sourceDetailStateFilter, visibilityFilter]); useEffect(() => { void loadPostPage(1); @@ -3772,11 +4568,27 @@ function PostList({ code, label: loadedPosts.find((post) => post.voc_type_code === code)?.voc_type_label ?? code, })); + const sourceDetailStateOptions = sourceDetailStateFilterOptions.length + ? sourceDetailStateFilterOptions + : Array.from( + new Set( + loadedPosts + .map((post) => post.source_detail_state_code) + .filter((code): code is string => Boolean(code?.trim())), + ), + ) + .sort() + .map((code) => ({ code, label: code })); const filteredPosts = loadedPosts .filter((post) => { const matchesType = typeFilter.length === 0 || typeFilter.includes(post.voc_type_code); + const matchesSourceDetailState = + sourceDetailStateFilter.length === 0 || + (post.source_detail_state_code !== null && + post.source_detail_state_code !== undefined && + sourceDetailStateFilter.includes(post.source_detail_state_code)); const matchesVisibility = visibilityFilter === "all" || post.visibility_code === visibilityFilter; - return matchesType && matchesVisibility; + return matchesType && matchesSourceDetailState && matchesVisibility; }) .sort((left, right) => { if (sortOrder === "title") { @@ -3785,7 +4597,12 @@ function PostList({ const direction = sortOrder === "newest" ? -1 : 1; return direction * left.created_at.localeCompare(right.created_at); }); - const hasBoardFilters = Boolean(searchInput.trim()) || Boolean(searchQuery) || typeFilter.length > 0 || visibilityFilter !== "all"; + const hasBoardFilters = + Boolean(searchInput.trim()) || + Boolean(searchQuery) || + typeFilter.length > 0 || + sourceDetailStateFilter.length > 0 || + visibilityFilter !== "all"; const totalPages = Math.max(1, Math.ceil(totalPosts / POST_PAGE_SIZE)); const pageItems: Array = totalPages <= 7 @@ -3835,73 +4652,113 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter([]); + setSourceDetailStateFilter([]); setVisibilityFilter("all"); setSortOrder("newest"); }} > - - +
+ + +

{t("Search includes post text and semantic evidence.")}

-
- {t("Filter by VOC type")} - {vocTypeOptions.map((option) => ( - - ))} -
- - - {hasBoardFilters && ( - - )} +
+
+ {t("Filter by VOC type")} + {vocTypeOptions.map((option) => { + const presentation = presentVocType(option); + return ( + + ); + })} +
+ {sourceDetailStateOptions.length > 0 ? ( +
+ {t("Filter by source detail state")} +

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

+ {sourceDetailStateOptions.map((option) => { + const presentation = presentSourceDetailState(option.code); + return ( + + ); + })} +
+ ) : null} + + + {hasBoardFilters && ( + + )} +
{posts.length === 0 ? (

@@ -3915,7 +4772,11 @@ function PostList({

) : (
    - {filteredPosts.map((post) => ( + {filteredPosts.map((post) => { + const sourceDetailState = post.source_detail_state_code + ? presentSourceDetailState(post.source_detail_state_code) + : null; + return (
  • - ))} + ); + })}
)} {totalPages > 1 && ( @@ -4015,7 +4905,7 @@ function PostList({ )} {(showLabPanels || canRebuild) && ( -
+
{t("Advanced review tools")} {canRebuild && (
@@ -4029,29 +4919,33 @@ function PostList({
)} - - - +
+
+ +
+
+ +
)} {selectedPostId && ( @@ -4109,6 +5003,21 @@ function buildCustomerEntityTree(entities: CustomerMasterEntity[]): CustomerEnti return roots.map(toNode); } +function customerScopeFacetLabel(facet: CustomerMasterScopeFacet): string { + switch (facet) { + case "authorized_own": + return t("Own company"); + case "authorized_granted": + return t("Granted company"); + case "scope_unclassified": + return t("Scope not classified"); + case "observed_organization": + return t("Observed organization"); + case "observed_hierarchy": + return t("Observed hierarchy"); + } +} + function CustomerEntityTreeRow({ node, depth, @@ -4139,7 +5048,12 @@ function CustomerEntityTreeRow({ onClick={() => onToggle(entity.corporate_entity_id)} > {entity.entity_name} - {entity.corporate_entity_code} · {entity.entity_level_label} + + {entity.corporate_entity_code} · {entity.entity_level_label} + {(entity.scope_facets ?? []).map((facet) => ( + {customerScopeFacetLabel(facet)} + ))} + {expandedEntityId === entity.corporate_entity_id ? (
@@ -4216,6 +5130,30 @@ function CustomerRelatedPostCard({ ); } +const CUSTOMER_MASTER_SCOPE_FILTERS = ["own", "granted", "observed", "unclassified"] as const; +type CustomerMasterScopeFilter = (typeof CUSTOMER_MASTER_SCOPE_FILTERS)[number]; +const CUSTOMER_MASTER_SCOPE_FILTER_LABELS: Record = { + own: "Own company", + granted: "Granted customer", + observed: "Observed in posts", + unclassified: "Unclassified", +}; + +// An entity can carry more than one facet (e.g. it is both this account's +// own company and an organization observed in a post); it belongs to +// every bucket that applies. No facet at all means an authorized but +// undifferentiated (scope_unclassified) affiliation -- ADR 0125's +// deliberate honest third state, not a guessed own/customer label. +function customerMasterScopeBuckets(entity: CustomerMasterEntity): CustomerMasterScopeFilter[] { + const facets = entity.scope_facets ?? []; + const buckets: CustomerMasterScopeFilter[] = []; + if (facets.includes("authorized_own")) buckets.push("own"); + if (facets.includes("authorized_granted")) buckets.push("granted"); + if (facets.includes("observed_organization")) buckets.push("observed"); + if (buckets.length === 0) buckets.push("unclassified"); + return buckets; +} + function CustomerMasterPanel({ accessToken, onOpenPost, @@ -4224,12 +5162,17 @@ function CustomerMasterPanel({ onOpenPost: (postId: string) => void; }) { const [master, setMaster] = useState(null); + const [scopeFilter, setScopeFilter] = useState>( + () => new Set(CUSTOMER_MASTER_SCOPE_FILTERS), + ); const [error, setError] = useState(null); const [expandedEntityId, setExpandedEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); const [relatedLoading, setRelatedLoading] = useState(null); const [resolvingHint, setResolvingHint] = useState(null); const [resolveError, setResolveError] = useState(null); + const [hintCodeInput, setHintCodeInput] = useState(""); + const [searchedHintCode, setSearchedHintCode] = useState(""); // Fetched independently, same pattern as PostList's own canRebuild -- // CustomerMasterPanel is a sibling of PostList under App, not a child, // so it cannot read PostList's local post_admin check. @@ -4251,10 +5194,10 @@ function CustomerMasterPanel({ const loadMaster = useCallback(() => { setError(null); - return fetchCustomerMaster(accessToken) + return fetchCustomerMaster(accessToken, searchedHintCode) .then(setMaster) .catch(() => setError(t("Customer master could not be loaded."))); - }, [accessToken]); + }, [accessToken, searchedHintCode]); useEffect(() => { setMaster(null); @@ -4274,6 +5217,11 @@ function CustomerMasterPanel({ } } + function handleHintSearch(event: FormEvent) { + event.preventDefault(); + setSearchedHintCode(hintCodeInput.trim()); + } + async function toggleEntity(entityId: string) { if (expandedEntityId === entityId) { setExpandedEntityId(null); @@ -4292,19 +5240,69 @@ function CustomerMasterPanel({ } } + const filteredEntities = (master?.corporate_entities ?? []).filter((entity) => + customerMasterScopeBuckets(entity).some((bucket) => scopeFilter.has(bucket)), + ); + return ( -
-

{t("Authorized customer scope")}

+
+

{t("Customer scope")}

{t("Customer master")}

-

{t("Customer entities available to this account.")}

+

{t("Customer entities available to this account.")}

{error ?

{error}

: null} {master === null && !error ?

{t("Loading customer master...")}

: null} {master?.corporate_entities.length === 0 ? (

{t("No customer entities are connected to this account.")}

) : null} +
+ +
+ setHintCodeInput(event.target.value)} + placeholder={t("Paste an observed customer code")} + /> + +
+

{t("Searches all authorized source hints, not only the ranked first page.")}

+
+ {searchedHintCode && master && master.source_customer_hints.length === 0 ? ( +

+ {tf("No source customer evidence matches {code}.", { code: searchedHintCode })} +

+ ) : null} {master && master.corporate_entities.length > 0 ? ( +
+ {t("Filter by scope")} + {CUSTOMER_MASTER_SCOPE_FILTERS.map((bucket) => ( + + ))} +
+ ) : null} + {master && master.corporate_entities.length > 0 && filteredEntities.length === 0 ? ( +

+ {t("No entities match the current scope filter.")} +

+ ) : null} + {filteredEntities.length > 0 ? (
    - {buildCustomerEntityTree(master.corporate_entities).map((node) => ( + {buildCustomerEntityTree(filteredEntities).map((node) => ( 0 ? (

    {t("Relationship network")}

    -

    +

    {t("A counterparty can hold more than one role over time -- a customer in one post can be a competitor, supplier, or partner in another. Every role observed for a name is listed, not just the most frequent.")}

      @@ -4344,7 +5342,7 @@ function CustomerMasterPanel({ {master && master.source_customer_hints.length > 0 ? (

      {t("Observed customer evidence")}

      -

      +

      {t("Source identifiers are hints only; ontology and semantic evidence must resolve them before binding a customer.")}

      {master.source_customer_hints.length > HINT_RENDER_LIMIT && ( @@ -4466,7 +5464,30 @@ function CustomerMasterPanel({ ); } -function AskAgentPanel({ +type AskAgentExchange = { + id: string; + question: string; + status: "pending" | "complete" | "error"; + response?: AskAgentResponse; + error?: string; +}; + +const ASK_AGENT_STARTERS = [ + "What happened between these events?", + "Who is involved?", + "What is the next commitment?", +] as const; + +function toAskAgentExchanges(conversation: Awaited>): AskAgentExchange[] { + return conversation.exchanges.map((exchange) => ({ + id: exchange.turn_id, + question: exchange.question_text, + status: "complete", + response: exchange, + })); +} + +export function AskAgentPanel({ accessToken, onOpenPost, }: { @@ -4474,85 +5495,450 @@ function AskAgentPanel({ onOpenPost: (postId: string) => void; }) { const [question, setQuestion] = useState(""); - const [answer, setAnswer] = useState(null); - const [error, setError] = useState(null); + const [exchanges, setExchanges] = useState([]); + const [conversations, setConversations] = useState([]); + const [conversationId, setConversationId] = useState(null); + const [historyLoading, setHistoryLoading] = useState(true); + const [historyError, setHistoryError] = useState(null); + const [historyCursor, setHistoryCursor] = useState(null); + const [historyLoadingMore, setHistoryLoadingMore] = useState(false); + const [historyMoreError, setHistoryMoreError] = useState(false); const [asking, setAsking] = useState(false); + const [olderTurnCursor, setOlderTurnCursor] = useState(null); + const [olderTurnsLoading, setOlderTurnsLoading] = useState(false); + const [olderTurnsError, setOlderTurnsError] = useState(false); + const exchangeIdRef = useRef(0); + const inputRef = useRef(null); + const historyRequestIdRef = useRef(0); + const historyListRef = useRef(null); + const threadRef = useRef(null); + const scrollToLatestRef = useRef(false); + + const loadInitialHistory = useCallback(async () => { + const requestId = ++historyRequestIdRef.current; + setHistoryLoading(true); + setHistoryError(null); + setHistoryMoreError(false); + setHistoryCursor(null); + setOlderTurnCursor(null); + setOlderTurnsError(false); + try { + const result = await fetchAskConversations(accessToken); + if (requestId !== historyRequestIdRef.current) return; + setConversations(result.conversations); + setHistoryCursor(result.next_cursor ?? null); + const latest = result.conversations[0]; + if (!latest) { + setConversationId(null); + setExchanges([]); + return; + } + const conversation = await fetchAskConversation(accessToken, latest.conversation_id); + if (requestId !== historyRequestIdRef.current) return; + setConversationId(conversation.conversation_id); + setExchanges(toAskAgentExchanges(conversation)); + setOlderTurnCursor(conversation.older_cursor ? Number(conversation.older_cursor) : null); + setOlderTurnsError(false); + scrollToLatestRef.current = true; + } catch { + if (requestId !== historyRequestIdRef.current) return; + setHistoryError(t("Conversation history could not be loaded.")); + } finally { + if (requestId === historyRequestIdRef.current) setHistoryLoading(false); + } + }, [accessToken]); + + useEffect(() => { + void loadInitialHistory(); + }, [loadInitialHistory]); + + useEffect(() => { + if (!scrollToLatestRef.current || exchanges.length === 0) return; + const thread = threadRef.current; + if (!thread) return; + thread.scrollTop = thread.scrollHeight; + scrollToLatestRef.current = false; + }, [conversationId, exchanges.length]); + + async function loadMoreConversations() { + if (!historyCursor || historyLoadingMore || asking) return; + setHistoryLoadingMore(true); + setHistoryMoreError(false); + try { + const result = await fetchAskConversations(accessToken, historyCursor); + setConversations((current) => { + const existingIds = new Set(current.map((item) => item.conversation_id)); + return [ + ...current, + ...result.conversations.filter((item) => !existingIds.has(item.conversation_id)), + ]; + }); + setHistoryCursor(result.next_cursor ?? null); + } catch { + setHistoryMoreError(true); + } finally { + setHistoryLoadingMore(false); + } + } + + async function loadOlderExchanges() { + if (!conversationId || olderTurnCursor === null || olderTurnsLoading || asking) return; + const thread = threadRef.current; + const previousHeight = thread?.scrollHeight ?? 0; + setOlderTurnsLoading(true); + setOlderTurnsError(false); + try { + const conversation = await fetchAskConversation(accessToken, conversationId, olderTurnCursor); + const olderExchanges = toAskAgentExchanges(conversation); + setExchanges((current) => { + const existingIds = new Set(current.map((item) => item.id)); + return [ + ...olderExchanges.filter((item) => !existingIds.has(item.id)), + ...current, + ]; + }); + setOlderTurnCursor(conversation.older_cursor ? Number(conversation.older_cursor) : null); + window.requestAnimationFrame(() => { + if (thread) thread.scrollTop += thread.scrollHeight - previousHeight; + }); + } catch { + setOlderTurnsError(true); + } finally { + setOlderTurnsLoading(false); + } + } + + async function selectConversation(nextConversationId: string) { + if (asking) return; + setHistoryLoading(true); + setHistoryError(null); + setOlderTurnsError(false); + try { + const conversation = await fetchAskConversation(accessToken, nextConversationId); + setConversationId(conversation.conversation_id); + setExchanges(toAskAgentExchanges(conversation)); + setOlderTurnCursor(conversation.older_cursor ? Number(conversation.older_cursor) : null); + scrollToLatestRef.current = true; + } catch { + setHistoryError(t("Conversation history could not be loaded.")); + } finally { + setHistoryLoading(false); + } + } + + function startNewConversation() { + if (asking) return; + setConversationId(null); + setExchanges([]); + setQuestion(""); + setHistoryError(null); + setOlderTurnCursor(null); + setOlderTurnsError(false); + inputRef.current?.focus(); + } + + function chooseStarter(prompt: string) { + setQuestion(t(prompt)); + inputRef.current?.focus(); + } async function handleAsk() { const normalized = question.trim(); - if (!normalized) return; + if (!normalized || asking) return; + const exchangeId = String(++exchangeIdRef.current); + setExchanges((current) => [...current, { id: exchangeId, question: normalized, status: "pending" }]); + setQuestion(""); setAsking(true); - setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + const response = await askAgent(accessToken, normalized, conversationId); + if (response.conversation_id) { + setConversationId(response.conversation_id); + setConversations((current) => [ + { + conversation_id: response.conversation_id!, + title: current.find((item) => item.conversation_id === response.conversation_id)?.title ?? normalized.slice(0, 80), + updated_at: new Date().toISOString(), + turn_count: (current.find((item) => item.conversation_id === response.conversation_id)?.turn_count ?? 0) + 1, + }, + ...current.filter((item) => item.conversation_id !== response.conversation_id), + ]); + } + setExchanges((current) => current.map((exchange) => ( + exchange.id === exchangeId ? { ...exchange, status: "complete", response } : exchange + ))); } catch (err) { - setAnswer(null); - setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + setExchanges((current) => current.map((exchange) => ( + exchange.id === exchangeId + ? { ...exchange, status: "error", error: orchestratorUnavailableMessage(err, t("Ask Agent")) } + : exchange + ))); } finally { setAsking(false); } } + const showEmptyState = !historyLoading && !historyError && exchanges.length === 0; + return ( -
      -

      {t("Evidence-grounded questions")}

      -

      {t("Ask Agent")}

      -

      {t("Questions use authorized posts and their evidence.")}

      - {error ?

      {error}

      : null} -