diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..05e03d82c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.codegraph +.venv +node_modules +frontend/node_modules +frontend/dist +frontend/coverage +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/.mypy_cache 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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cad1f17c..1c2cc6714 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,15 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + valkey: + image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec + ports: + - 16379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres steps: @@ -36,6 +45,25 @@ jobs: with: persist-credentials: false + - name: Start synthetic Keycloak realm + run: | + docker build --tag lineageweave-keycloak-test docker/keycloak + docker run --detach --name lineageweave-keycloak-test \ + --publish 18080:8080 \ + --env KEYCLOAK_ADMIN=admin \ + --env KEYCLOAK_ADMIN_PASSWORD=admin_dev_only \ + lineageweave-keycloak-test start-dev --import-realm + for _ in {1..60}; do + if curl --fail --silent \ + http://localhost:18080/realms/lineageweave-demo/.well-known/openid-configuration \ + >/dev/null; then + exit 0 + fi + sleep 2 + done + docker logs lineageweave-keycloak-test + exit 1 + - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 with: diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..393aed77d 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 @@ -208,6 +208,11 @@ A run-bearing analysis-run registry empties only after an unrevoked (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not expose purge on a public HTTP route. +Unauthenticated **Log in** must call `returnUrlFromLocation()` then +`rememberOidcReturnUrl` before `signinRedirect` (ADR 0109) so a +shared `/?post=` link still opens that post. Do not mount tenant admin +settings on the signed-out login shell. + `POST /api/analysis-runs` records Pending lineage only (ADR 0017 / v2.7.1). TEPP and period-report kinds 422 before any snapshot write. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..4cb521e8a 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 | @@ -301,7 +301,9 @@ config baked in at build time from the same `.env` ports every other service uses (Vite embeds `import.meta.env.VITE_*` at build time, not runtime, so these are Docker build args, not container env vars). `src/App.test.tsx` mocks `react-oidc-context`'s `useAuth` to test the -component's own render logic (login button -> `signinRedirect()`; the +component's own render logic (login button stores a safe return path +then `signinRedirect()`; the signed-out shell never mounts tenant +admin settings; the A-100 fork DAG shows a branch point and rec-006 as its own root; `post_admin` can rebuild; fetch posts with the token -> render list -> click -> popup shows the fetched body and every panel; ask a chat @@ -326,7 +328,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 +469,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 +477,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 +927,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.19-oidc-login-return-remember.md b/CHANGELOG.d/2.12.19-oidc-login-return-remember.md new file mode 100644 index 000000000..1d590260f --- /dev/null +++ b/CHANGELOG.d/2.12.19-oidc-login-return-remember.md @@ -0,0 +1,6 @@ +## 2.12.19 — Remember the login return path + +- Log in now stores a validated same-origin return path (ADR 0109) + before the OIDC redirect, so a shared `/?post=` link still opens that + post after callback. Tenant admin settings stay off the signed-out + login shell so the production frontend build type-checks. 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-related-posts-unavailable.md b/CHANGELOG.d/2.12.6-related-posts-unavailable.md new file mode 100644 index 000000000..69e5d9310 --- /dev/null +++ b/CHANGELOG.d/2.12.6-related-posts-unavailable.md @@ -0,0 +1 @@ +Fixed post details to replace an endless related-post loading state with a fail-closed availability message when lineage evidence cannot be loaded. 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.6-temporal-semantic-order.md b/CHANGELOG.d/2.12.6-temporal-semantic-order.md new file mode 100644 index 000000000..0f9ab7450 --- /dev/null +++ b/CHANGELOG.d/2.12.6-temporal-semantic-order.md @@ -0,0 +1,32 @@ +### Fixed + +- Preserve every explicit earlier-to-later product-introduction relation as + OWL-Time milestone evidence, including a named base product followed by its + multi-stage variant. +- Return persisted older summaries immediately as stale while operator + regeneration performs the dedicated relation extraction. +- Show Knowledge Graph direction and source evidence without relying on hover. +- Add a reproducible full-production-source frontend coverage command using + Vitest's matching V8 provider; the measured gap remains visible instead of + excluding uncovered application files. +- Enforce repository-wide public Python docstrings with an AST contract and + document all 51 public definitions missing on this exact branch head. +- Cover the frontend bootstrap and OIDC return callback at 100%, removing the + only production entrypoint with zero measured coverage. +- Fail closed on unknown analysis-evidence diagnoses and keep untrusted 5xx + payloads behind reader-safe next-action copy. +- Cover Global Search focus/keyboard behavior, Source Research retry and + reader-safe failure states, R&R affiliation navigation, and malformed + Lineage DAG edges without changing production behavior. +- Cover list-filter serialization, bookmark writes, and explicit source + research at the shared frontend API boundary. +- Route empty embedded-image payloads through the existing reader-facing + decode failure instead of rendering an empty body. +- Fail closed on unknown analysis-run kinds/statuses and cover its lifecycle, + reporting, TEPP, FiveW1H, and cyclic-layout guidance boundaries. +- Cover Knowledge Graph zoom limits, keyboard/pointer panning, post selection, + drag-click suppression, and dangling-edge evidence fallback. +- Keep an unknown analysis-run kind or status reader-safe and non-interactive + without crashing the workspace or exposing the backend code. +- Remove unreachable admin handoff screens while preserving direct Board + routing, and cover account scope, shortcuts, and tenant-save outcomes. 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/2.16.1-planned-facility-relation.md b/CHANGELOG.d/2.16.1-planned-facility-relation.md new file mode 100644 index 000000000..a6388030e --- /dev/null +++ b/CHANGELOG.d/2.16.1-planned-facility-relation.md @@ -0,0 +1,7 @@ +## 2.16.1 + +- Add the fail-closed `lw_plans_to_operate` semantic relationship for planned + facilities backed by the same literal actor/facility evidence, a matching + R&R actor, and an independently extracted project mention (ADR 0142). +- Migration 0138 admits the predicate at the database write boundary on new + and existing Compose volumes. diff --git a/CHANGELOG.d/dashboard-home-route.md b/CHANGELOG.d/dashboard-home-route.md new file mode 100644 index 000000000..8db7368d0 --- /dev/null +++ b/CHANGELOG.d/dashboard-home-route.md @@ -0,0 +1,7 @@ +# Dashboard replaces the Board as the `/` landing route + +`/` now opens a news-portal Dashboard ranking important posts and important +projects by real `fast-mlsirm` FIPC-calibrated theta (falling back to +RankWeave's fused ranking before any project period report exists), instead +of the Board's find-and-filter list. The Board stays one click away as the +first Workspace navigation item after Dashboard (ADR 0145). diff --git a/CHANGELOG.d/fivew1h-empty-evidence-guidance.md b/CHANGELOG.d/fivew1h-empty-evidence-guidance.md new file mode 100644 index 000000000..2d8cf1e25 --- /dev/null +++ b/CHANGELOG.d/fivew1h-empty-evidence-guidance.md @@ -0,0 +1,6 @@ +## Buyer 5W1H evidence guidance + +- Explain what to inspect when a 5W1H dimension has no persisted source-grounded + evidence. +- Keep the backend action code internal and provide the guidance in all supported + buyer locales. 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.d/image-evidence-queue.md b/CHANGELOG.d/image-evidence-queue.md new file mode 100644 index 000000000..b0333adaf --- /dev/null +++ b/CHANGELOG.d/image-evidence-queue.md @@ -0,0 +1,4 @@ +## Image evidence queue + +- Requeue posts whose parent image or visual region VISION evidence is still + unavailable instead of marking them complete from text embeddings alone. diff --git a/CHANGELOG.d/post-content-explicit-retry.md b/CHANGELOG.d/post-content-explicit-retry.md new file mode 100644 index 000000000..314b640cd --- /dev/null +++ b/CHANGELOG.d/post-content-explicit-retry.md @@ -0,0 +1,5 @@ +## Post-content explicit retry + +- Added a bounded operator command path for retrying selected terminal-failed + post-content jobs without reopening synthetic, draft, deleted, or writing + posts and without touching analysis-run registries. diff --git a/CHANGELOG.d/source-identity-eligibility.md b/CHANGELOG.d/source-identity-eligibility.md new file mode 100644 index 000000000..e979b09c5 --- /dev/null +++ b/CHANGELOG.d/source-identity-eligibility.md @@ -0,0 +1,7 @@ +## Source identity eligibility + +- Preserve source system, record identity, stage, and detail-state metadata + when distinguishing imported posts from synthetic Demo seed rows and when + selecting content, summary, Keyman, and Valkey backfill work. +- Reject writing-in-progress posts in direct content, Keyman, and explicit + retry operator paths before they can create derived evidence. diff --git a/CHANGELOG.d/synthetic-seed-source-context.md b/CHANGELOG.d/synthetic-seed-source-context.md new file mode 100644 index 000000000..9f3fc090c --- /dev/null +++ b/CHANGELOG.d/synthetic-seed-source-context.md @@ -0,0 +1,6 @@ +# Synthetic seed cleanup source context + +- Require every `source_*` field, including lifecycle markers, system and record + identity, order metadata, and inspection metadata, to be empty before a + Demo-scoped post can be considered synthetic cleanup material. +- Preserve imported rows that carry metadata without author or customer names. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..bdd54486f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,247 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added + +- R&R catalog links now record why they are unresolved (ADR 0141): tied + candidates, no live enrichment client, checked-but-not-corroborated, or no + matching catalog entry. The reader sees the specific reason next to an + unlinked person, organization, or affiliation instead of one flat "Not + linked to catalog" label; a historical row written before this change + keeps its prior behavior. +- `frontend/index.html` now shows a visible, styled message when JavaScript + is disabled, instead of a silent blank page. +- Planned-facility evidence now reuses the semantic-relationship channel with + `lw_plans_to_operate` (ADR 0142). The relationship is retained only when the + same source span names a matching R&R actor and project-backed facility; it + never represents an already-operating facility. Migration 0138 keeps the + database write constraint aligned with the closed predicate vocabulary. +- Event Lineage now reports why a post has no DAG (ADR 0143): + "no_relation_found" when reconstruct compared it against real + candidates and found no relation, or "no_comparison_group" when it was + the only visible post in its group. The reader sees the specific reason + instead of one flat "No linked posts yet." +- `POST /api/lineage/rebuild` now returns a corpus-wide `coverage` + breakdown (`total_posts`, `posts_with_edges`, `posts_no_relation_found`, + `posts_no_comparison_group`) alongside `edge_count`, giving an operator + an honest coverage summary instead of a bare edge count. +- Posts can now run an evidence-bearing source-reference research agent + against cited URLs and patents (ADR 0133). Search hits stay leads until + a Judge outcome of supported, refuted, or not_enough_information; a + sharing actor is bound only from cited retrieved text. +- Each post's Ask-about-this-lineage surface now keeps account-owned + conversation history (list, select, new) without replacing the seeded + `post_chat_result` cache (ADR 0136). Ask Agent history remains ADR 0126. + TEPP topic modeling of how many posts can connect under temporal + precedence stays deferred. +- Repeated `(source_system_code, source_customer_code)` observations can now + enter Customer Master only after two authorized posts, contextual-orchestrator + resolution, a persisted fast-mlsirm Judge/IRT decision, external + corroboration, and unique catalog resolution (ADR 0137). The importer + reconciles changed keys automatically; promoted posts receive a distinct + Knowledge Graph customer-observation edge, while preferred, former, and + alternate organization names remain visible and auditable. + +### Fixed + +- Admin Panel labels (control center, endpoint catalog, tenant settings, + authorized-entity/board/calendar navigation, and related copy -- 20 + strings) were only translated into Korean; zh/ja/vi silently fell back + to the raw English key since no test enforced locale parity for these + strings (the existing i18n test only checks curated key lists, and + this component's keys were never added to one). Added zh/ja/vi + translations matching this file's established terminology (Tenant, + Workspace, Board, Admin) and a new curated `adminPanelLabels` parity + test following the same pattern as the existing workspace/Event + Lineage label tests. Also translated 33 more AdminPanel labels this + session's second pass found: its endpoint-catalog and navigation + metadata is defined as data (rendered via `t(item.label)`), which a + literal-`t("...")` grep had missed the first time. `adminPanelLabels` + now covers all 53 of AdminPanel's live keys. ~64 other pre-existing + ko-only keys (mostly in App.tsx) remain untranslated repo-wide and + are tracked separately, not claimed fixed here. +- `GET /api/posts`'s `total_count` no longer silently reports `0` when the + requested page is past the last page of results. `total_count` came + from `count(*) over()`, a window function that only rides along on rows + that survive the query's own `OFFSET`/`LIMIT` -- once the offset skipped + past every matching row, the query returned zero rows and `total_count` + fell back to `0` even though matches existed. A paginator relying on + `total_count` to detect it overshot the last page (or that a filter + change shrank the result set) would instead see "0 results" and could + wrongly conclude nothing matched. Extracted the query's predicate into + a shared variable so a small fallback `count(*)` query (used only when + the main page comes back empty) can reuse it without duplicating ~170 + lines of SQL; regression test confirmed RED (reported 0 instead of the + real count) before GREEN. +- Global Ask (`POST /api/ask`) no longer persists or returns a citation + whose authorization changes between source selection and commit. This + branch had lost the atomic reauthorize-and-rollback fix from #399/#374 + during a divergent history merge; a cited post that turned private or + changed corporate entity mid-request would have its facts served in the + answer, and its citation row would persist even on the 503 path, + poisoning the session (issue #362). Restored `_ensure_citations_visible` + inside `persist_turn`'s transaction, wired `GlobalAskEvidenceChanged` + into `ask_agent`'s error handling, and added a regression test proving + the fix RED-to-GREEN. +- Per-post Ask (`POST /api/posts/{post_id}/chat`) had the identical + citation-authorization race as Global Ask above, but never received + the #362 fix in the first place: `post_ask_history.persist_turn` had + no reauthorization step at all. Added the same + `_ensure_citations_visible` / `PostAskEvidenceChanged` -> 503 pattern; + regression test confirmed RED (a revoked citation's facts served with + a 200) before GREEN. +- The Event Lineage / 5W1H "indirect" relationship walk (`find_linked_post_ids`, + ADR 0018) no longer lets an ABAC-hidden sibling post seed the + Knowledge Graph traversal. Finding indirect neighbors first expands to + every post mentioning the same person as the focus post, then walks + their shared org/team/customer entities -- but that sibling expansion + was never ABAC-filtered, so a hidden sibling's own entity mention could + bridge to an unrelated *visible* post, fabricating an "indirect" + relationship whose only real basis was content the account cannot see + (the hidden sibling itself was already correctly excluded from output). + Fixed at all three call sites (`read_post_lineage`, `gather_chat_sources`, + `load_five_w1h_slots`); regression test confirmed RED before GREEN. +- `GET /api/lineage`'s focused view no longer lets an edge to an + ABAC-hidden sibling post mask a post's `isolation_reason` (ADR 0143). + The connected-component check that decides whether a focused post has + any visible neighbor built its graph from every `post_lineage_edge` row + regardless of visibility, so a hidden post's edge could make an + otherwise-isolated post look connected -- leaking the existence of a + hidden relationship through an absent isolation reason. Both edge + endpoints must now be ABAC-visible before the edge counts. Found on a + divergent history line (PR #493) and ported here via cross-session + coordination, with a regression test proving RED-to-GREEN. +- Unexpected exceptions inside Global Ask, per-post chat, keymen + extraction, entity-relationship verification, evaluation, summary + regeneration, and commitment derivation now reach server-side logs + (`logger.exception`, stdlib `logging`, no new dependency) before the + same stable customer-facing 503. Previously the broad fail-closed + `except Exception` boundary silently swallowed unclassified defects -- + correct for the customer, but turned a real regression into an opaque + availability incident for operators (issue #361, partial: this covers + server-side traceback capture, not yet OpenTelemetry metrics/ + correlation IDs). +- The Source Detail popup's "Source lineage combination" panel (badge, field + presence list, and commercial-context labels from `sourceLineageHints.ts`) + was only translated into Korean; zh/ja/vi silently fell back to the raw + English key for all 29 of its labels ("Catalog hint", "Combination code", + "Present"/"Not present", the nine `commercial_context_code` labels, the four + `SOURCE_LINEAGE_FIELDS` labels, and related `Source *` field-hint keys). + Added zh/ja/vi translations matching this file's established terminology + (来源/原典/nguồn for "Source", 谱系/系譜/dòng for "lineage") and a new curated + `sourceLineageHintLabels` parity test. ~39 other pre-existing ko-only keys + (Board/R&R/customer-identity-search area) remain untranslated and are + tracked separately, not claimed fixed here. +- `SourceResearchPanel` no longer lets a slow, stale `postId` fetch overwrite + the currently-displayed post's research. `load`'s `fetchPostSourceResearch` + call had no request-id guard, so switching posts quickly (or a race between + the initial load and a fast-following `runResearch` refresh) could let an + earlier post's response land after the panel had already moved to a newer + post, showing another post's persisted evidence in the wrong place. Added + the same `requestIdRef` counter/compare pattern already used elsewhere in + `App.tsx` (`historyRequestIdRef`, `relatedRequest`, `postsRequest`). + Regression test confirmed RED (a stale response rendered its lead after a + postId change) before GREEN. +- `GET /api/customer-master`'s `relationship_network` no longer scopes its + ABAC check with the endpoint's broader Customer Master entity listing, + which also includes merely-*observed* entities (organizations only ever + mentioned in a visible post, never actually affiliated with the + account). `fetch_relationship_network`'s `corporate_entity_ids` + parameter is an ABAC scope, not a display list -- the query treats it + exactly like `_can_see_post`'s `post.corporate_entity_id = any($1)` + clause, so a broader listing let a private post owned by one of those + observed-only entities leak its counterparty classification into the + response. Scoped it to `account.corporate_entity_ids` (the account's + own real affiliations), with the same synthetic-only/stale-demo-grant + exclusion the entity tree already applies once real source context + exists. Regression test confirmed RED (a private post's counterparty + leaked) before GREEN. + +### Changed + +- Global Ask and per-post Ask history (`GET /api/ask/conversations/{id}`) + now reauthorize a whole conversation's sources, citations, and evidence + in one bounded query per relation type, instead of one query per turn. + An N-turn conversation dropped from up to `3N+3` queries to a constant + 6; behavior and the fail-closed per-turn authorization boundary are + unchanged (issue #358). +- Leftover closest/farthest pairs now name the post and the Post quality + criterion, and leftover clicks land on that criterion instead of a generic + post open (ADR 0049 / ADR 0135). Catalog-unbound, dropped/unavailable + channel, and confident-negative each have distinct next-action copy; + a glued R&R source phrase stays fail-closed and is not treated as an + “operates” relation. +- Reader-facing failures now share a token-backed exception surface (title, + next-action copy, optional retry) instead of a color-only red paragraph. + Raw exception types, stacks, OIDC diagnostics, and 5xx provider payloads + stay hidden (ADR 0123 / ADR 0134). +- Analysis-run next actions stay kind-and-status exact (ADR 0135): a running + lineage or TEPP row whose copy says the work is already queued now offers + Refresh, not Start reconstruction / Start TEPP; a failed report with a week + key opens the period-report rebuild surface. +- 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 +- Post-summary contract v19 now preserves an explicit predecessor-to-successor + statement in its source direction instead of replacing it with a guessed + base-to-variant relation. Knowledge Graph and ontology readers expose that + direction with evidence, confidence, and extraction provenance. +- Event Lineage and Knowledge Graph on-graph titles wrap instead of chopping + into `…`, so a long synthetic title stays readable on the graph. Topic + partitions, root/branch/current marks, and predecessor → successor + (선·후행) stay named on the graph without relying on hover or stroke color + alone. + +- Tenant settings now refresh their audit timestamp on every successful update, + keep the year field visibly blank while it is edited, and disable no-op + whitespace-only saves. + +- Tenant identity metadata migration now repairs blank legacy settings before + adding non-empty and copyright-year constraints, so existing Compose volumes + can replay the migration without losing valid tenant-provided values. + +- 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 an unshipped + Global Ask history migration; Global Ask remains stateless as documented by + ADR 0090, while the shipped scope-facet migration is applied directly. +- The static SQL review contract now counts the Customer Master evidence query + that uses closed schema fragments and bound entity ids. + +## [2.12.19] - 2026-08-24 + +### Fixed + +- Log in remembers a validated same-origin return path (ADR 0109) before + the OIDC redirect, so a shared `/?post=` link still opens that post + after callback. Tenant admin settings stay off the signed-out login + shell. The production frontend build type-checks again. + ## [2.12.6] - 2026-08-20 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/Dockerfile b/backend/Dockerfile index eb6b86288..b54385052 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -23,14 +23,19 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}" COPY pyproject.toml uv.lock README.md ./ + +# Compile the locked Rust/Python dependencies before application source is +# copied. A Python-only edit then reuses this expensive layer instead of +# rebuilding fast-mlsirm's PyO3 core. +RUN uv sync --frozen --no-dev --extra backend --no-editable --no-install-project + COPY lineageweave ./lineageweave COPY backend ./backend # lineageweave/ontology.py resolves this path relative to itself # (parents[1] = /app) -- ADR 0004. COPY docs/ontology ./docs/ontology -# Install exactly the committed universal lock. --no-editable prevents a -# runtime dependency on source-tree editability while retaining package data. +# Install exactly the committed project into the cached dependency environment. RUN uv sync --frozen --no-dev --extra backend --no-editable \ && chown -R appuser:appuser /app diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f7da2969b..fb76ad821 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -249,7 +249,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 +285,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 +346,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. diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..dce93fa55 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -13,6 +13,7 @@ import json from datetime import datetime, timezone from typing import Any +from urllib.parse import urlparse from uuid import UUID import asyncpg @@ -88,7 +89,11 @@ def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None: ) -def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppClient: +def configured_tepp_client( + transport_url: str = "", + api_key: str = "", + temporal_context_url: str = "", +) -> TeppClient: """Build a TEPP client from an optional HTTP transport URL. An empty URL keeps the default unavailable transport. A set URL @@ -96,17 +101,54 @@ def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppCl stay unavailable -- this is not a local psychometric substitute. """ url = transport_url.strip() - if not url: + temporal_url = temporal_context_url.strip() + if not url and not temporal_url: return TeppClient() + def unavailable_transport(_payload: dict[str, Any]) -> dict[str, Any]: + """Fail closed when only a temporal-context TEPP endpoint exists.""" + raise TeppNotAvailable("TEPP analysis-run transport unavailable") + def transport(payload: dict[str, Any]) -> dict[str, Any]: + """Submit an analysis-run request through the configured TEPP endpoint.""" + try: + headers = { + "tepp-consumer": "lineageweave", + "tepp-contract-version": "1", + "idempotency-key": str(payload["idempotency_key"]), + } + return post_json( + url, + payload, + headers=headers, + timeout=30.0, + include_context_metadata=False, + ) + except (HttpClientError, OSError, ValueError, TypeError) as exc: + raise TeppNotAvailable("TEPP transport unavailable") from exc + + def temporal_transport(payload: dict[str, Any]) -> dict[str, Any]: + """Submit a temporal-context request through the configured TEPP endpoint.""" + if not temporal_url: + raise TeppNotAvailable("TEPP temporal-context transport unavailable") try: - headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} - return post_json(url, payload, headers=headers, timeout=30.0) + headers = {"tepp-consumer": "lineageweave", "tepp-contract-version": "1"} + if urlparse(temporal_url).hostname == "host.docker.internal": + headers["host"] = "127.0.0.1" + return post_json( + temporal_url, + payload, + headers=headers, + timeout=10.0, + include_context_metadata=False, + ) except (HttpClientError, OSError, ValueError, TypeError) as exc: - raise TeppNotAvailable(str(exc)) from exc + raise TeppNotAvailable("TEPP temporal-context transport unavailable") from exc - return TeppClient(transport=transport) + return TeppClient( + transport=transport if url else unavailable_transport, + temporal_transport=temporal_transport, + ) def tepp_run_request( 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/config.py b/backend/app/config.py index 02dc8dc34..aa8d4bb78 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,6 +10,8 @@ @dataclass(frozen=True) class Settings: + """Immutable snapshot of environment-driven backend configuration.""" + database_url: str # Reachable *from this backend process* -- used only to fetch JWKS # signing keys. Inside docker-compose this is the internal service DNS @@ -46,6 +48,7 @@ class Settings: valkey_url: str searxng_base_url: str tepp_transport_url: str + tepp_temporal_context_url: str tepp_api_key: str caldav_base_url: str rankweave_disabled: bool @@ -133,6 +136,7 @@ def load_settings() -> Settings: valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), + tepp_temporal_context_url=os.environ.get("TEPP_TEMPORAL_CONTEXT_URL", ""), tepp_api_key=os.environ.get("TEPP_API_KEY", ""), caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(), rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "") diff --git a/backend/app/corporate_entity_ingestion.py b/backend/app/corporate_entity_ingestion.py index bfc0e740d..689175e9e 100644 --- a/backend/app/corporate_entity_ingestion.py +++ b/backend/app/corporate_entity_ingestion.py @@ -16,6 +16,7 @@ import asyncio import hashlib +from dataclasses import dataclass import asyncpg @@ -40,6 +41,17 @@ _CREATION_LOCK_KEY = "lineageweave:corporate_entity_creation" +@dataclass(frozen=True) +class PreparedCorporateEntityResolution: + """Provider-complete corporate resolution plan with no database writes.""" + + normalized_name: str + catalog_id: str | None + unresolved_reason: str | None + proposal: HierarchyProposal | None + parent: PreparedCorporateEntityResolution | None + + def _auto_entity_code(organization_name: str) -> str: """Return a deterministic, namespace-separated code.""" digest = hashlib.sha256(organization_name.encode("utf-8")).hexdigest()[:16] @@ -103,8 +115,7 @@ def _remember_candidate( ) -async def get_or_create_corporate_entity( - conn: asyncpg.Connection, +async def prepare_corporate_entity_resolution( organization_name: str, context_text: str, inference_client: CorporateHierarchyInferenceClient, @@ -113,31 +124,38 @@ async def get_or_create_corporate_entity( *, _depth: int = 0, _visited_names: frozenset[str] = frozenset(), -) -> str | None: - """Return a verified catalog id, otherwise ``None``. - - A unique similarity match is reused. A tied top score stays unbound - and does not create a third same-named row (ADR 0026). Only a genuine - miss -- no candidate at or above ``min_similarity`` -- may enter ADR - 0010 inference. A proposed parent must independently corroborate and - resolve before the child can be inserted. Repeated names in the - recursion path are cycles, including multi-node cycles such as - A -> B -> A. +) -> PreparedCorporateEntityResolution: + """Run scoring and provider checks without mutating the catalog. + + The frozen result can be applied only after a caller's own current-input + fence. A tie remains terminal and parent chains remain bounded exactly as + in ADR 0010/0026. """ normalized_name = organization_name.strip() if not normalized_name: - return None + return PreparedCorporateEntityResolution("", None, None, None, None) visit_key = normalized_name.casefold() if visit_key in _visited_names: - return None + return PreparedCorporateEntityResolution( + normalized_name, None, None, None, None + ) existing = score_corporate_entity(normalized_name, candidates) if existing.kind == RESOLUTION_UNIQUE and existing.catalog_id is not None: - return existing.catalog_id + return PreparedCorporateEntityResolution( + normalized_name, existing.catalog_id, None, None, None + ) if existing.kind == RESOLUTION_TIE: - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_tied_candidates", None, None + ) if _depth >= _MAX_HIERARCHY_DEPTH or not inference_client.available: - return None + # Excessive recursion depth is folded into the same "not attempted" + # bucket as an unconfigured client: from the reader's perspective + # both mean enrichment never ran for this specific mention. + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_no_live_client", None, None + ) try: proposal = await asyncio.to_thread( @@ -149,9 +167,17 @@ async def get_or_create_corporate_entity( # A provider timeout is an unavailable enrichment channel, not a # reason to discard the source-grounded summary. Keep the actor # unbound and let an explicit retry attempt catalog enrichment later. - return None - if proposal is None or not verification_client.available: - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_no_live_client", None, None + ) + if proposal is None: + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_not_corroborated", None, None + ) + if not verification_client.available: + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_no_live_client", None, None + ) try: placement_result = await asyncio.to_thread( @@ -165,16 +191,22 @@ async def get_or_create_corporate_entity( # ingestion) -- treat it the same as "not corroborated this run": # the entity simply isn't auto-created, same conservative outcome # as a real search that found nothing. - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_no_live_client", None, None + ) if placement_result.status_code != STATUS_CORROBORATED: - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_not_corroborated", None, None + ) visited_names = _visited_names | {visit_key} - parent_entity_id: str | None = None + parent_plan: PreparedCorporateEntityResolution | None = None if proposal.parent_name is not None: normalized_parent = proposal.parent_name.strip() if not normalized_parent or normalized_parent.casefold() in visited_names: - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_not_corroborated", None, None + ) try: parent_result = await asyncio.to_thread( verification_client.verify, @@ -184,11 +216,14 @@ async def get_or_create_corporate_entity( except (HttpClientError, OSError): # Same fail-closed-without-crashing behavior as the placement # verification above. - return None + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_no_live_client", None, None + ) if parent_result.status_code != STATUS_CORROBORATED: - return None - parent_entity_id = await get_or_create_corporate_entity( - conn, + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_not_corroborated", None, None + ) + parent_plan = await prepare_corporate_entity_resolution( normalized_parent, context_text, inference_client, @@ -197,8 +232,65 @@ async def get_or_create_corporate_entity( _depth=_depth + 1, _visited_names=visited_names, ) + if parent_plan.catalog_id is None and parent_plan.proposal is None: + # The child's own corroboration succeeded; it's the hierarchy + # placement (ADR 0010 requires the whole chain) that didn't. + return PreparedCorporateEntityResolution( + normalized_name, None, "reason_not_corroborated", None, None + ) + + return PreparedCorporateEntityResolution( + normalized_name, + None, + None, + proposal, + parent_plan, + ) + + +async def apply_prepared_corporate_entity_resolution( + conn: asyncpg.Connection, + prepared: PreparedCorporateEntityResolution, + candidates: list[CorporateEntityCandidate], + *, + _resolved_parent_ids: set[str] | None = None, +) -> tuple[str | None, str | None]: + """Apply a provider-complete plan using database work only.""" + resolved_parent_ids = ( + _resolved_parent_ids if _resolved_parent_ids is not None else set() + ) + if prepared.catalog_id is not None or prepared.proposal is None: + if prepared.catalog_id is not None: + resolved_parent_ids.add(prepared.catalog_id) + return prepared.catalog_id, prepared.unresolved_reason + + parent_entity_id: str | None = None + if prepared.parent is not None: + parent_entity_id, _parent_reason = ( + await apply_prepared_corporate_entity_resolution( + conn, + prepared.parent, + candidates, + _resolved_parent_ids=resolved_parent_ids, + ) + ) if parent_entity_id is None: - return None + return None, "reason_not_corroborated" + resolved_parent_ids.add(parent_entity_id) + + evolving = score_corporate_entity( + prepared.normalized_name, + [ + candidate + for candidate in candidates + if candidate.corporate_entity_id not in resolved_parent_ids + ], + ) + if evolving.kind == RESOLUTION_UNIQUE and evolving.catalog_id is not None: + resolved_parent_ids.add(evolving.catalog_id) + return evolving.catalog_id, None + if evolving.kind == RESOLUTION_TIE: + return None, "reason_tied_candidates" async with conn.transaction(): await conn.execute( @@ -210,20 +302,46 @@ async def get_or_create_corporate_entity( # initial lookup remains fuzzy, while this check only prevents a # concurrent insert of the same normalized name. fresh = score_corporate_entity( - normalized_name, + prepared.normalized_name, await _reload_candidates(conn), min_similarity=1.0, ) if fresh.kind == RESOLUTION_UNIQUE and fresh.catalog_id is not None: - _remember_candidate(candidates, fresh.catalog_id, normalized_name) - return fresh.catalog_id + _remember_candidate(candidates, fresh.catalog_id, prepared.normalized_name) + resolved_parent_ids.add(fresh.catalog_id) + return fresh.catalog_id, None if fresh.kind == RESOLUTION_TIE: - return None + return None, "reason_tied_candidates" new_id = await _create_entity( conn, - normalized_name, - proposal.level_code, + prepared.normalized_name, + prepared.proposal.level_code, parent_entity_id, ) - _remember_candidate(candidates, new_id, normalized_name) - return new_id + _remember_candidate(candidates, new_id, prepared.normalized_name) + resolved_parent_ids.add(new_id) + return new_id, None + + +async def get_or_create_corporate_entity( + conn: asyncpg.Connection, + organization_name: str, + context_text: str, + inference_client: CorporateHierarchyInferenceClient, + verification_client: RelationVerificationClient, + candidates: list[CorporateEntityCandidate], + *, + _depth: int = 0, + _visited_names: frozenset[str] = frozenset(), +) -> tuple[str | None, str | None]: + """Prepare provider evidence, then resolve or create the catalog row.""" + prepared = await prepare_corporate_entity_resolution( + organization_name, + context_text, + inference_client, + verification_client, + candidates, + _depth=_depth, + _visited_names=_visited_names, + ) + return await apply_prepared_corporate_entity_resolution(conn, prepared, candidates) diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py index 497c66728..87e5324f0 100644 --- a/backend/app/customer_hint_ingestion.py +++ b/backend/app/customer_hint_ingestion.py @@ -1,28 +1,456 @@ -"""Resolves one observed customer-hint code (`source_post.source_customer_code`) -to a real-world `corporate_entity`, using the text of posts that share the -code as evidence -- and, per the same corroboration discipline -`organization_name_resolution_ingestion.py` already applies to in-text -abbreviations (ADR 0008), never binding a new customer name that external -search did not corroborate. An uncorroborated or unresolved guess leaves -the hint exactly as unresolved as it started; it never invents a Customer -Master entity from a single ungrounded LLM answer. -""" +"""Promote repeated customer hints into governed cross-post identity (ADR 0137).""" from __future__ import annotations import asyncio +import hashlib +import json +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime from typing import Any import asyncpg +from fast_mlsirm import LLMJudgeResult +from backend.app.corporate_entity_ingestion import get_or_create_corporate_entity +from backend.app.knowledge_graph import persist_edges_for_post +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.corporate_hierarchy_inference import CorporateHierarchyInferenceClient +from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate from lineageweave.customer_hint_resolution import CustomerHintResolutionClient +from lineageweave.customer_identity_judgment import ( + RUBRIC_VERSION, + CustomerIdentityJudgeClient, + identity_is_promotable, + rename_is_supported, +) from lineageweave.image_content import NullImageContentClient -from lineageweave.organization_name_resolution import resolve_and_verify_organization_name +from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata +from lineageweave.organization_name_resolution import ( + resolve_and_verify_organization_name, +) from lineageweave.post_content_normalization import normalize_post_body -from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationClient - +from lineageweave.post_evaluation import irt_responses_from_result +from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + RelationVerificationClient, +) +from lineageweave.tepp_client import ( + TemporalContextEvent, + TemporalContextRequest, + TeppClient, + TeppNotAvailable, +) _EXCERPT_LENGTH = 1500 +_MAX_EVIDENCE_POSTS = 12 +_STATUS_ABSTAINED = "customer_identity_abstained" +_STATUS_PROMOTED = "customer_identity_promoted" + + +def _iso8601(value: object) -> str: + """Return an explicit UTC-aware ISO timestamp from a persisted source value.""" + if not isinstance(value, datetime): + raise TypeError("customer identity evidence requires datetime source clocks") + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +def _evidence_records(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Normalize bounded post excerpts once for fingerprinting and judging.""" + vision_client = NullImageContentClient() + records: list[dict[str, Any]] = [] + for row in rows: + excerpt = normalize_post_body( + row["post_body"], vision_client=vision_client + ).text[:_EXCERPT_LENGTH] + records.append( + { + "post_id": str(row["post_id"]), + "post_title": row["post_title"], + "excerpt": excerpt, + "excerpt_sha256": hashlib.sha256(excerpt.encode("utf-8")).hexdigest(), + "source_customer_name": row.get("source_customer_name"), + "created_at": _iso8601(row["created_at"]), + "updated_at": _iso8601(row["updated_at"]), + } + ) + return records + + +def _evidence_sha256(records: Sequence[Mapping[str, Any]]) -> str: + """Fingerprint evidence without persisting a second copy of source text.""" + fingerprint_rows = [ + { + "post_id": record["post_id"], + "source_customer_name": record["source_customer_name"], + "created_at": record["created_at"], + "updated_at": record["updated_at"], + "excerpt_sha256": record["excerpt_sha256"], + } + for record in sorted(records, key=lambda item: str(item["post_id"])) + ] + payload = json.dumps(fingerprint_rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _judge_context( + records: Sequence[Mapping[str, Any]], + source_system_code: str | None, + source_customer_code: str, +) -> str: + """Render explicit record boundaries so a judge cannot count one post twice.""" + system = source_system_code if source_system_code is not None else "(source system absent)" + return "\n\n---\n\n".join( + ( + f"record_ordinal={ordinal}\n" + f"post_id={record['post_id']}\n" + f"source_system_code={system}\n" + f"source_customer_code={source_customer_code}\n" + f"source_customer_name={record['source_customer_name'] or '(absent)'}\n" + f"created_at={record['created_at']}\n" + f"updated_at={record['updated_at']}\n" + f"title={record['post_title']}\n" + f"excerpt={record['excerpt']}" + ) + for ordinal, record in enumerate(records) + ) + + +async def _temporally_order_records( + records: list[dict[str, Any]], + tepp_client: TeppClient | None, + source_system_code: str | None, + source_customer_code: str, +) -> tuple[list[dict[str, Any]], str]: + """Use TEPP ordering when available; otherwise retain source-clock order.""" + source_order = sorted(records, key=lambda row: (row["created_at"], row["post_id"])) + if tepp_client is None: + return source_order, "source_timestamp" + actor_digest = hashlib.sha256( + f"{source_system_code or ''}\0{source_customer_code}".encode() + ).hexdigest() + events = tuple( + TemporalContextEvent( + event_id=f"customer-observation:{record['post_id']}", + source_post_id=str(record["post_id"]), + event_type_code="customer_identity_observation", + event_label="Customer identity observation", + event_time=str(record["created_at"]), + available_time=str(record["updated_at"]), + project_reference=None, + actor_references=(f"customer-key:{actor_digest}",), + ) + for record in source_order + ) + request = TemporalContextRequest( + knowledge_cutoff=max(str(record["updated_at"]) for record in source_order), + subject_post_id=str(source_order[-1]["post_id"]), + events=events, + ) + try: + response = await asyncio.to_thread(tepp_client.temporal_context, request) + except TeppNotAvailable: + return source_order, "source_timestamp" + by_post_id = {str(record["post_id"]): record for record in source_order} + return [by_post_id[post_id] for post_id in response["source_post_ids"]], "tepp" + + +async def _cached_promotion( + conn: asyncpg.Connection, + source_system_code: str | None, + source_customer_code: str, + evidence_sha256: str, +) -> dict[str, Any] | None: + """Reuse an unchanged promoted decision without paying for another judge call.""" + row = await conn.fetchrow( + """ + select judgment.customer_identity_judgment_id, + entity.corporate_entity_id, entity.entity_name, + judgment.distinct_post_count, judgment.verification_evidence_url + from customer_identity_judgment judgment + join customer_identity_binding binding + on binding.customer_identity_judgment_id = judgment.customer_identity_judgment_id + join corporate_entity entity + on entity.corporate_entity_id = binding.corporate_entity_id + where judgment.source_system_code is not distinct from $1 + and judgment.source_customer_code = $2 + and judgment.evidence_sha256 = $3 + and judgment.rubric_version = $4 + and judgment.judgment_status_code = $5 + """, + source_system_code, + source_customer_code, + evidence_sha256, + RUBRIC_VERSION, + _STATUS_PROMOTED, + ) + if row is None: + return None + return { + "corporate_entity_id": str(row["corporate_entity_id"]), + "entity_name": row["entity_name"], + "linked_post_count": row["distinct_post_count"], + "verification_evidence_url": row["verification_evidence_url"], + "customer_identity_judgment_id": str(row["customer_identity_judgment_id"]), + "resolution_status": _STATUS_PROMOTED, + "cached": True, + } + + +async def _persist_judgment( + conn: asyncpg.Connection, + *, + source_system_code: str | None, + source_customer_code: str, + candidate_name: str, + evidence_sha256: str, + records: Sequence[Mapping[str, Any]], + result: LLMJudgeResult, + temporal_order_source_code: str, + verification_evidence_url: str | None, +) -> str: + """Persist the decision, its IRT row, and exact supporting posts.""" + row = await conn.fetchrow( + """ + insert into customer_identity_judgment ( + source_system_code, source_customer_code, candidate_entity_name, + evidence_sha256, judgment_status_code, rubric_version, + distinct_post_count, judge_score, judge_accepted, judge_rationale, + orchestration_mode, trace_step_count, temporal_order_source_code, + verification_evidence_url + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + on conflict on constraint customer_identity_judgment_evidence_unique + do update set candidate_entity_name = excluded.candidate_entity_name, + judgment_status_code = excluded.judgment_status_code, + distinct_post_count = excluded.distinct_post_count, + judge_score = excluded.judge_score, + judge_accepted = excluded.judge_accepted, + judge_rationale = excluded.judge_rationale, + orchestration_mode = excluded.orchestration_mode, + trace_step_count = excluded.trace_step_count, + temporal_order_source_code = excluded.temporal_order_source_code, + verification_evidence_url = excluded.verification_evidence_url, + judged_at = now() + returning customer_identity_judgment_id + """, + source_system_code, + source_customer_code, + candidate_name, + evidence_sha256, + _STATUS_ABSTAINED, + RUBRIC_VERSION, + len(records), + result.score, + result.accepted, + result.rationale, + result.orchestration_mode, + result.trace_step_count, + temporal_order_source_code, + verification_evidence_url, + ) + judgment_id = str(row["customer_identity_judgment_id"]) + for response in irt_responses_from_result(result): + await conn.execute( + """ + insert into customer_identity_judgment_response ( + customer_identity_judgment_id, criterion_code, + criterion_score, response_category + ) values ($1, $2, $3, $4) + on conflict (customer_identity_judgment_id, criterion_code) + do update set criterion_score = excluded.criterion_score, + response_category = excluded.response_category + """, + judgment_id, + response.criterion_code, + result.criterion_scores[response.criterion_code], + response.response_category, + ) + for ordinal, record in enumerate(records): + await conn.execute( + """ + insert into customer_identity_judgment_post ( + customer_identity_judgment_id, post_id, evidence_ordinal, + observed_customer_name, excerpt_sha256 + ) values ($1, $2, $3, $4, $5) + on conflict (customer_identity_judgment_id, post_id) + do update set evidence_ordinal = excluded.evidence_ordinal, + observed_customer_name = excluded.observed_customer_name, + excerpt_sha256 = excluded.excerpt_sha256 + """, + judgment_id, + record["post_id"], + ordinal, + record["source_customer_name"], + record["excerpt_sha256"], + ) + return judgment_id + + +async def _bound_entity( + conn: asyncpg.Connection, + source_system_code: str | None, + source_customer_code: str, +) -> Mapping[str, Any] | None: + """Load the stable Customer Master binding for one exact source key.""" + return await conn.fetchrow( + """ + select entity.corporate_entity_id, entity.entity_name + from customer_identity_binding binding + join corporate_entity entity using (corporate_entity_id) + where binding.source_system_code is not distinct from $1 + and binding.source_customer_code = $2 + """, + source_system_code, + source_customer_code, + ) + + +async def _catalog_entity( + conn: asyncpg.Connection, + candidate_name: str, + context_text: str, + inference_client: CorporateHierarchyInferenceClient | None, + verification_client: RelationVerificationClient, +) -> str | None: + """Reuse ADR 0010/0012/0026 rather than creating a second catalog path.""" + rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity") + candidates = [ + CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) + for row in rows + ] + if inference_client is None: + return None + entity_id, _unresolved_reason = await get_or_create_corporate_entity( + conn, + candidate_name, + context_text, + inference_client, + verification_client, + candidates, + ) + return entity_id + + +async def _record_name( + conn: asyncpg.Connection, + entity_id: str, + candidate_name: str, + judgment_id: str, + observed_from: datetime, + rename_result: LLMJudgeResult | None, +) -> str: + """Preserve aliases and replace a preferred name only on strict rename proof.""" + entity = await conn.fetchrow( + "select entity_name, created_at from corporate_entity where corporate_entity_id = $1", + entity_id, + ) + current_name = entity["entity_name"] + await conn.execute( + """ + insert into corporate_entity_name_history ( + corporate_entity_id, entity_name, name_role_code, observed_from + ) + select $1, $2, 'entity_name_preferred', $3 + where not exists ( + select 1 from corporate_entity_name_history + where corporate_entity_id = $1 + and name_role_code = 'entity_name_preferred' + and observed_to is null + ) + """, + entity_id, + current_name, + entity["created_at"], + ) + if current_name.casefold() == candidate_name.casefold(): + return current_name + if rename_result is not None and rename_is_supported(rename_result): + await conn.execute( + """ + update corporate_entity_name_history + set name_role_code = 'entity_name_former', observed_to = $2 + where corporate_entity_id = $1 + and name_role_code = 'entity_name_preferred' + and observed_to is null + """, + entity_id, + observed_from, + ) + await conn.execute( + "update corporate_entity set entity_name = $2 where corporate_entity_id = $1", + entity_id, + candidate_name, + ) + await conn.execute( + """ + insert into corporate_entity_name_history ( + corporate_entity_id, entity_name, name_role_code, + observed_from, customer_identity_judgment_id + ) values ($1, $2, 'entity_name_preferred', $3, $4) + """, + entity_id, + candidate_name, + observed_from, + judgment_id, + ) + return candidate_name + await conn.execute( + """ + insert into corporate_entity_name_history ( + corporate_entity_id, entity_name, name_role_code, + observed_from, customer_identity_judgment_id + ) + select $1, $2, 'entity_name_alternate', $3, $4 + where not exists ( + select 1 from corporate_entity_name_history + where corporate_entity_id = $1 and lower(entity_name) = lower($2) + ) + """, + entity_id, + candidate_name, + observed_from, + judgment_id, + ) + return current_name + + +async def _persist_rename_result( + conn: asyncpg.Connection, + judgment_id: str, + result: LLMJudgeResult, +) -> None: + """Attach the separate strict rename judgment to the same evidence run.""" + await conn.execute( + """ + update customer_identity_judgment + set rename_judge_score = $2, + rename_judge_accepted = $3, + rename_judge_rationale = $4 + where customer_identity_judgment_id = $1 + """, + judgment_id, + result.score, + result.accepted, + result.rationale, + ) + for response in irt_responses_from_result(result): + await conn.execute( + """ + insert into customer_identity_judgment_response ( + customer_identity_judgment_id, criterion_code, + criterion_score, response_category + ) values ($1, $2, $3, $4) + on conflict (customer_identity_judgment_id, criterion_code) + do update set criterion_score = excluded.criterion_score, + response_category = excluded.response_category + """, + judgment_id, + response.criterion_code, + result.criterion_scores[response.criterion_code], + response.response_category, + ) async def resolve_customer_hint( @@ -30,138 +458,232 @@ async def resolve_customer_hint( resolution_client: CustomerHintResolutionClient, verification_client: RelationVerificationClient, hint_code: str, + *, + source_system_code: str | None = None, + authorized_corporate_entity_ids: Sequence[str] = (), + identity_judge_client: CustomerIdentityJudgeClient | None = None, + hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None, + tepp_client: TeppClient | None = None, ) -> dict[str, Any] | None: - """Resolve one `source_customer_code` hint to a real `corporate_entity`. - - Returns ``None`` when the resolver is unavailable, no eligible posts - carry this hint, or the proposed name was not externally corroborated. - Otherwise creates (or reuses, by case-insensitive exact name) the - entity and reclaims every post sharing this hint that still sits at - its account's default placeholder entity, returning the entity - id/name plus how many posts were reclaimed. - """ - if not resolution_client.available: + """Promote one exact source-system/customer-code key after collective proof.""" + normalized_hint = hint_code.strip() + if ( + not normalized_hint + or not resolution_client.available + or identity_judge_client is None + or not identity_judge_client.available + or not authorized_corporate_entity_ids + ): return None - # Five rows and 20,000 raw body characters per row bound both transfer and - # parsing before deterministic normalization. The SQL remains literal; - # only the observed hint code is a bound value. - rows = await conn.fetch( - """ - select post_title, left(post_body, 20000) as post_body - from source_post - where source_customer_code = $1 - and nullif(btrim(source_post.source_draft_code), '') is null - and nullif(btrim(source_post.source_deleted_flag), '') is null - and not ( - ( - nullif(btrim(source_post.source_author_code), '') is null - and nullif(btrim(source_post.source_author_name), '') is null - and nullif(btrim(source_post.source_company_code), '') is null - and nullif(btrim(source_post.source_company_name), '') is null - and nullif(btrim(source_post.source_process_unit_code), '') is null - and nullif(btrim(source_post.source_process_unit_name), '') is null - and nullif(btrim(source_post.source_sales_pool_code), '') is null - and nullif(btrim(source_post.source_sales_pool_name), '') is null - and nullif(btrim(source_post.source_customer_code), '') is null - and nullif(btrim(source_post.source_customer_name), '') is null - and nullif(btrim(source_post.source_project_code), '') is null - and nullif(btrim(source_post.source_project_name), '') is null - ) - and exists ( - select 1 - from source_post real_post - where ( - nullif(btrim(real_post.source_author_code), '') is not null - or nullif(btrim(real_post.source_author_name), '') is not null - or nullif(btrim(real_post.source_company_code), '') is not null - or nullif(btrim(real_post.source_company_name), '') is not null - or nullif(btrim(real_post.source_process_unit_code), '') is not null - 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_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 - or nullif(btrim(real_post.source_project_name), '') is not null - ) - ) - ) - order by created_at desc - limit 5 + # Safe SQL: eligibility is a closed schema fragment; all customer and scope values are bound. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select post.post_id, post.post_title, left(post_body, 20000) as post_body, + post.source_customer_name, post.created_at, post.updated_at, + post.author_account_id, entity.corporate_entity_code, + post.source_process_unit_code, post.source_author_code, + post.source_company_code, post.source_customer_code, + post.source_project_code, post.source_sales_pool_code, + post.source_system_code, post.visibility_code + from source_post post + join corporate_entity entity using (corporate_entity_id) + where nullif(btrim(post.source_customer_code), '') = $1 + and post.source_system_code is not distinct from $2 + and (post.visibility_code = 'public' or post.corporate_entity_id = any($3::uuid[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + order by post.created_at, post.post_id + limit $4 """, - hint_code, + normalized_hint, + source_system_code, + list(authorized_corporate_entity_ids), + _MAX_EVIDENCE_POSTS, ) - if not rows: + if len({str(row["post_id"]) for row in rows}) < 2: return None - vision_client = NullImageContentClient() - excerpts = "\n---\n".join( - f"{row['post_title']}\n" - f"{normalize_post_body(row['post_body'], vision_client=vision_client).text[:_EXCERPT_LENGTH]}" - for row in rows + records = _evidence_records(rows) + evidence_sha256 = _evidence_sha256(records) + cached = await _cached_promotion( + conn, source_system_code, normalized_hint, evidence_sha256 ) - resolution = await asyncio.to_thread( - resolve_and_verify_organization_name, - hint_code, - excerpts, - resolution_client, - verification_client, + if cached is not None: + return cached + records, temporal_source = await _temporally_order_records( + records, tepp_client, source_system_code, normalized_hint + ) + context_text = _judge_context(records, source_system_code, normalized_hint) + subject = max(rows, key=lambda row: (row["updated_at"], str(row["post_id"]))) + metadata = build_post_llm_metadata(str(subject["post_id"]), subject) + metadata.update( + { + "lineageweave_source_system_code": source_system_code or "", + "lineageweave_visibility": subject.get("visibility_code") or "", + "lineageweave_customer_evidence_sha256": evidence_sha256, + } + ) + with use_llm_metadata(metadata): + resolution = await asyncio.to_thread( + resolve_and_verify_organization_name, + normalized_hint, + context_text, + resolution_client, + verification_client, + ) + if resolution is None: + return None + with use_llm_metadata(metadata): + identity_result = await asyncio.to_thread( + identity_judge_client.judge_identity, + resolution.resolved_organization_name, + context_text, + ) + judgment_id = await _persist_judgment( + conn, + source_system_code=source_system_code, + source_customer_code=normalized_hint, + candidate_name=resolution.resolved_organization_name, + evidence_sha256=evidence_sha256, + records=records, + result=identity_result, + temporal_order_source_code=temporal_source, + verification_evidence_url=resolution.verification_evidence_url, ) - if resolution is None or resolution.verification_status_code != STATUS_CORROBORATED: + if ( + resolution.verification_status_code != STATUS_CORROBORATED + or not identity_is_promotable(identity_result, len(records)) + ): return None - entity_name = resolution.resolved_organization_name - existing = await conn.fetchrow( - "select corporate_entity_id from corporate_entity where lower(entity_name) = lower($1)", - entity_name, - ) - if existing is not None: - entity_id = existing["corporate_entity_id"] + binding = await _bound_entity(conn, source_system_code, normalized_hint) + if binding is None: + entity_id = await _catalog_entity( + conn, + resolution.resolved_organization_name, + context_text, + hierarchy_inference_client, + verification_client, + ) + if entity_id is None: + return None + previous_name = resolution.resolved_organization_name else: - # ON CONFLICT, not a plain INSERT: re-resolving the same hint_code - # is not guaranteed to get byte-identical LLM phrasing back, so the - # name-based lookup above can miss an entity this same hint already - # created -- corporate_entity_code (deterministic from hint_code) - # is the stable identity key a retry must key off instead. - entity_code = f"HINT-{hint_code}" - # Safe SQL: the statement is a literal migration-shaped query; both observed values are bound. - created = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + entity_id = str(binding["corporate_entity_id"]) + previous_name = binding["entity_name"] + + rename_result: LLMJudgeResult | None = None + if previous_name.casefold() != resolution.resolved_organization_name.casefold(): + with use_llm_metadata(metadata): + rename_result = await asyncio.to_thread( + identity_judge_client.judge_rename, + previous_name, + resolution.resolved_organization_name, + context_text, + ) + await _persist_rename_result(conn, judgment_id, rename_result) + + observed_from = max(row["created_at"] for row in rows) + async with conn.transaction(): + binding_row = await conn.fetchrow( """ - insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) - values ($1, $2, 'company') - on conflict (corporate_entity_code) - do update set entity_name = excluded.entity_name + insert into customer_identity_binding ( + source_system_code, source_customer_code, + corporate_entity_id, customer_identity_judgment_id + ) values ($1, $2, $3, $4) + on conflict on constraint customer_identity_binding_source_unique + do update set customer_identity_judgment_id = excluded.customer_identity_judgment_id, + updated_at = now() returning corporate_entity_id """, - entity_code, - entity_name, - ) - entity_id = created["corporate_entity_id"] - - # `corporate_entity_id` is NOT NULL, so a bulk-imported real record - # never sits at NULL waiting to be resolved -- it defaults to whatever - # entity its shared placeholder `author_account_id` happens to be - # affiliated with (the same shared-placeholder shape as the - # `author_affiliations` hint leak fixed in semantic_hints.py). Only - # reclaim a post still sitting at that default, never one some other - # resolution already bound to a specific entity. - linked = await conn.fetch( - """ - update source_post - set corporate_entity_id = $1 - where source_customer_code = $2 - and corporate_entity_id in ( - select corporate_entity_id from account_affiliation - where user_account_id = source_post.author_account_id - ) - returning post_id - """, - entity_id, - hint_code, - ) + source_system_code, + normalized_hint, + entity_id, + judgment_id, + ) + entity_id = str(binding_row["corporate_entity_id"]) + display_name = await _record_name( + conn, + entity_id, + resolution.resolved_organization_name, + judgment_id, + observed_from, + rename_result, + ) + for record in records: + await conn.execute( + """ + insert into post_customer_identity_mention ( + post_id, corporate_entity_id, customer_identity_judgment_id + ) values ($1, $2, $3) + on conflict (post_id, corporate_entity_id) + do update set customer_identity_judgment_id = excluded.customer_identity_judgment_id + """, + record["post_id"], + entity_id, + judgment_id, + ) + await conn.execute( + """ + update customer_identity_judgment + set judgment_status_code = $2, corporate_entity_id = $3 + where customer_identity_judgment_id = $1 + """, + judgment_id, + _STATUS_PROMOTED, + entity_id, + ) + for record in records: + await persist_edges_for_post(conn, str(record["post_id"])) return { - "corporate_entity_id": str(entity_id), - "entity_name": entity_name, - "linked_post_count": len(linked), + "corporate_entity_id": entity_id, + "entity_name": display_name, + "linked_post_count": len(records), "verification_evidence_url": resolution.verification_evidence_url, + "customer_identity_judgment_id": judgment_id, + "resolution_status": _STATUS_PROMOTED, + "cached": False, } + + +async def reconcile_customer_hints( + conn: asyncpg.Connection, + resolution_client: CustomerHintResolutionClient, + verification_client: RelationVerificationClient, + source_keys: Sequence[tuple[str | None, str]], + *, + authorized_corporate_entity_ids: Sequence[str], + identity_judge_client: CustomerIdentityJudgeClient, + hierarchy_inference_client: CorporateHierarchyInferenceClient, + tepp_client: TeppClient | None = None, +) -> dict[str, int]: + """Evaluate changed source keys after import without failing the import on provider outages.""" + keys = sorted({(system, code.strip()) for system, code in source_keys if code.strip()}) + counts = {"candidates": len(keys), "promoted": 0, "unresolved": 0, "unavailable": 0} + if not ( + resolution_client.available + and verification_client.available + and identity_judge_client.available + and hierarchy_inference_client.available + ): + counts["unavailable"] = len(keys) + return counts + for source_system_code, source_customer_code in keys: + try: + result = await resolve_customer_hint( + conn, + resolution_client, + verification_client, + source_customer_code, + source_system_code=source_system_code, + authorized_corporate_entity_ids=authorized_corporate_entity_ids, + identity_judge_client=identity_judge_client, + hierarchy_inference_client=hierarchy_inference_client, + tepp_client=tepp_client, + ) + except asyncpg.PostgresError: + raise + except Exception: # noqa: BLE001 - one provider failure must not block other source keys. + counts["unavailable"] += 1 + continue + counts["promoted" if result is not None else "unresolved"] += 1 + return counts diff --git a/backend/app/demo_scope.py b/backend/app/demo_scope.py index 6d25bee24..02b63cd22 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. """ @@ -32,14 +32,22 @@ async def has_real_source_context( where (visibility_code = 'public' or corporate_entity_id = any($1::uuid[])) and ( - nullif(btrim(source_post.source_author_code), '') is not null + nullif(btrim(source_post.source_system_code), '') is not null + or nullif(btrim(source_post.source_record_key), '') is not null + or nullif(btrim(source_post.source_author_code), '') is not null or nullif(btrim(source_post.source_author_name), '') is not null or nullif(btrim(source_post.source_company_code), '') is not null or nullif(btrim(source_post.source_company_name), '') is not null or nullif(btrim(source_post.source_process_unit_code), '') is not null or nullif(btrim(source_post.source_process_unit_name), '') is not null + or nullif(btrim(source_post.source_stage_code), '') is not null + or nullif(btrim(source_post.source_detail_state_code), '') 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 @@ -64,14 +72,22 @@ async def fetch_demo_corporate_entity_ids(conn: asyncpg.Connection) -> set[str]: from source_post real_post where real_post.corporate_entity_id = entity.corporate_entity_id and ( - nullif(btrim(real_post.source_author_code), '') is not null + nullif(btrim(real_post.source_system_code), '') is not null + or nullif(btrim(real_post.source_record_key), '') is not null + or nullif(btrim(real_post.source_author_code), '') is not null or nullif(btrim(real_post.source_author_name), '') is not null or nullif(btrim(real_post.source_company_code), '') is not null or nullif(btrim(real_post.source_company_name), '') is not null or nullif(btrim(real_post.source_process_unit_code), '') is not null or nullif(btrim(real_post.source_process_unit_name), '') is not null + or nullif(btrim(real_post.source_stage_code), '') is not null + or nullif(btrim(real_post.source_detail_state_code), '') 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..f7fb9aeec 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. @@ -140,6 +140,17 @@ async def fetch_relationship_network( missing-vs-guessed discipline as :func:`attach_resolved_entity_ids`. Capped at the 100 entities with the most total observed posts; ties break on name for a stable order. + + ``corporate_entity_ids`` is used as an ABAC scope, not a display + list: the query treats it exactly like ``_can_see_post``'s + ``or post.corporate_entity_id = any($1)`` clause, so it must be the + caller account's own real affiliations (``account.corporate_entity_ids``), + with any synthetic-only (demo/stale-grant) ids already excluded -- + the same filter Customer Master applies to its own entity tree. + Passing a broader Customer-Master listing that also includes + merely-*observed* entities (organizations only ever mentioned in a + post, never affiliated with) would let a private post owned by one + of those observed entities leak into ``relationship_network``. """ if not corporate_entity_ids: return [] diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py index 736a8ecd6..c9f65666d 100644 --- a/backend/app/five_w1h_ingestion.py +++ b/backend/app/five_w1h_ingestion.py @@ -9,7 +9,6 @@ from lineageweave.five_w1h import assemble_five_w1h_slots, slots_payload -from .entity_relationship_ingestion import fetch_post_counterparties from .post_chat_ingestion import find_linked_post_ids from .post_summary_ingestion import fetch_persisted_summary @@ -20,7 +19,7 @@ async def load_five_w1h_slots( can_see_post: Callable[[asyncpg.Record], bool], ) -> dict[str, Any]: """Build 5W1H from stored projections and visible lineage only.""" - summary = await fetch_persisted_summary(conn, post_id) or {} + summary = await fetch_persisted_summary(conn, post_id, allow_stale=True) or {} evidence_claims = await conn.fetch( """ select slot_code, value_text, evidence_text @@ -30,22 +29,21 @@ async def load_five_w1h_slots( """, post_id, ) - linked = await find_linked_post_ids(conn, post_id) + linked = await find_linked_post_ids(conn, post_id, can_see_post) candidate_ids = sorted(linked.direct | linked.indirect) 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, ) linked_titles = [row["post_title"] for row in rows if can_see_post(row)] - counterparties = await fetch_post_counterparties(conn, post_id) slots = assemble_five_w1h_slots( roles=summary.get("roles_and_responsibilities", []), key_events=summary.get("key_events", []), - counterparties=[row["counterparty_entity_name"] for row in counterparties], lineage_node_labels=linked_titles, evidence_claims=[dict(row) for row in evidence_claims], ) diff --git a/backend/app/global_ask_history.py b/backend/app/global_ask_history.py new file mode 100644 index 000000000..2a5ae149b --- /dev/null +++ b/backend/app/global_ask_history.py @@ -0,0 +1,445 @@ +"""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 + +from .post_eligibility import SOURCE_POST_READER_ELIGIBILITY_SQL + + +class GlobalAskConversationNotFound(LookupError): + """The requested conversation is absent or owned by another account.""" + + +class GlobalAskEvidenceChanged(RuntimeError): + """A cited post became unauthorized before the new turn could commit.""" + + +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 whether an account owns the requested conversation.""" + 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]: + """Return one reverse-chronological, account-owned conversation page.""" + 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_batch( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinals: list[int], + can_see_post: Callable[[asyncpg.Record], bool], + *, + source: bool, +) -> dict[int, tuple[list[str], dict[str, asyncpg.Record]]]: + """Reauthorize every turn's sources or citations in one query. + + Fetches all `turn_ordinals` at once instead of one query per turn, so a + conversation's query count stays constant regardless of how many turns + it has. Returns each turn's currently-visible post ids and rows, keyed + by turn ordinal; a turn with no visible rows still gets an empty entry. + """ + by_turn: dict[int, tuple[list[str], dict[str, asyncpg.Record]]] = { + ordinal: ([], {}) for ordinal in turn_ordinals + } + if not turn_ordinals: + return by_turn + if source: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.source_post_id::text as post_id, relation.source_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from global_ask_turn_source relation + join source_post post on post.post_id = relation.source_post_id + where relation.global_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.source_ordinal + """, + conversation_id, + turn_ordinals, + ) + else: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.cited_post_id::text as post_id, relation.citation_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from global_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.global_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.citation_ordinal + """, + conversation_id, + turn_ordinals, + ) + for row in rows: + if not can_see_post(row): + continue + ordinal = int(row["turn_ordinal"]) + post_id = str(row["post_id"]) + ids, id_map = by_turn[ordinal] + ids.append(post_id) + id_map[post_id] = row + return by_turn + + +async def _turn_evidence_batch( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinals: list[int], +) -> dict[int, list[asyncpg.Record]]: + """Fetch every turn's citation evidence facts in one query.""" + by_turn: dict[int, list[asyncpg.Record]] = {ordinal: [] for ordinal in turn_ordinals} + if not turn_ordinals: + return by_turn + rows = await conn.fetch( + """ + select turn_ordinal, 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 = any($2::int[]) + order by turn_ordinal, cited_post_id, fact_ordinal + """, + conversation_id, + turn_ordinals, + ) + for row in rows: + by_turn[int(row["turn_ordinal"])].append(row) + return by_turn + + +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: + """Return an authorized transcript page with only currently visible evidence.""" + 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: + 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: + 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() + ordinals = [int(turn["turn_ordinal"]) for turn in turns] + sources_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=True + ) + citations_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=False + ) + evidence_by_turn = await _turn_evidence_batch(conn, conversation_id, ordinals) + exchanges: list[dict[str, Any]] = [] + for turn in turns: + ordinal = int(turn["turn_ordinal"]) + source_ids, _ = sources_by_turn[ordinal] + cited_ids, cited_rows = citations_by_turn[ordinal] + evidence: dict[str, list[dict[str, str]]] = {} + for row in evidence_by_turn[ordinal]: + 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 _ensure_citations_visible( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinal: int, + cited_post_count: int, + can_see_post: Callable[[asyncpg.Record], bool], +) -> None: + """Lock and re-authorize new citations before their transaction commits.""" + rows = await conn.fetch( + """ + select relation.cited_post_id::text as post_id, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from global_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.global_ask_session_id = $1 + and relation.turn_ordinal = $2 + for share of post + """, + conversation_id, + turn_ordinal, + ) + if len(rows) != cited_post_count or any(not can_see_post(row) for row in rows): + raise GlobalAskEvidenceChanged + + +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]], + can_see_post: Callable[[asyncpg.Record], bool] | None = None, +) -> UUID: + """Persist one account-owned turn and atomically reauthorize its citations.""" + 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, + ) + if can_see_post is not None: + await _ensure_citations_visible( + conn, + conversation_id, + ordinal, + len(cited_ids), + can_see_post, + ) + 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/keyman_ingestion.py b/backend/app/keyman_ingestion.py index ade1044a1..06187f546 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -49,7 +49,7 @@ from __future__ import annotations import asyncio -from dataclasses import replace +from dataclasses import dataclass, replace import asyncpg @@ -67,11 +67,22 @@ NullOrganizationNameResolutionClient, OrganizationNameResolutionClient, ) -from lineageweave.relation_verification import NullRelationVerificationClient, RelationVerificationClient +from lineageweave.relation_verification import ( + NullRelationVerificationClient, + RelationVerificationClient, +) -from .corporate_entity_ingestion import get_or_create_corporate_entity +from .corporate_entity_ingestion import ( + PreparedCorporateEntityResolution, + apply_prepared_corporate_entity_resolution, + prepare_corporate_entity_resolution, +) from .knowledge_graph import persist_edges_for_post -from .organization_name_resolution_ingestion import resolve_organization_name +from .organization_name_resolution_ingestion import ( + PreparedOrganizationNameResolution, + apply_prepared_organization_name_resolution, + prepare_organization_name_resolution, +) async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]: @@ -174,7 +185,18 @@ async def _upsert_affiliation( ) -async def _resolve_affiliated_organization( +@dataclass(frozen=True) +class PreparedAffiliatedOrganization: + """No-write name and hierarchy plan for one affiliation mention.""" + + raw_name: str + resolved_name: str + name_resolution: PreparedOrganizationNameResolution | None + entity_resolution: PreparedCorporateEntityResolution | None + unresolved_reason: str | None + + +async def prepare_affiliated_organization( conn: asyncpg.Connection, organization_name: str, context_text: str, @@ -182,28 +204,92 @@ async def _resolve_affiliated_organization( verification_client: RelationVerificationClient, hierarchy_inference_client: CorporateHierarchyInferenceClient, candidates: list[CorporateEntityCandidate], -) -> tuple[str, str, str | None]: - """Resolve one affiliation without rewriting a known raw-name tie.""" +) -> PreparedAffiliatedOrganization: + """Prepare one affiliation without rewriting a known raw-name tie. + + Provider work completes here, while cache/catalog writes are deferred to + :func:`apply_prepared_affiliated_organization`. + """ raw_outcome = score_corporate_entity(organization_name, candidates) if raw_outcome.kind == RESOLUTION_TIE: - return organization_name, organization_name, None + return PreparedAffiliatedOrganization( + organization_name, + organization_name, + None, + None, + "reason_tied_candidates", + ) - resolved_name = await resolve_organization_name( + name_resolution = await prepare_organization_name_resolution( conn, resolution_client, verification_client, organization_name, context_text, ) - corporate_entity_id = await get_or_create_corporate_entity( - conn, - resolved_name, + entity_resolution = await prepare_corporate_entity_resolution( + name_resolution.resolved_name, context_text, hierarchy_inference_client, verification_client, candidates, ) - return organization_name, resolved_name, corporate_entity_id + return PreparedAffiliatedOrganization( + organization_name, + name_resolution.resolved_name, + name_resolution, + entity_resolution, + None, + ) + + +async def apply_prepared_affiliated_organization( + conn: asyncpg.Connection, + prepared: PreparedAffiliatedOrganization, + candidates: list[CorporateEntityCandidate], +) -> tuple[str, str, str | None, str | None]: + """Apply prepared cache/catalog writes without calling providers.""" + if prepared.name_resolution is None or prepared.entity_resolution is None: + return ( + prepared.raw_name, + prepared.resolved_name, + None, + prepared.unresolved_reason, + ) + resolved_name = await apply_prepared_organization_name_resolution( + conn, + prepared.name_resolution, + ) + corporate_entity_id, unresolved_reason = ( + await apply_prepared_corporate_entity_resolution( + conn, + prepared.entity_resolution, + candidates, + ) + ) + return prepared.raw_name, resolved_name, corporate_entity_id, unresolved_reason + + +async def _resolve_affiliated_organization( + conn: asyncpg.Connection, + organization_name: str, + context_text: str, + resolution_client: OrganizationNameResolutionClient, + verification_client: RelationVerificationClient, + hierarchy_inference_client: CorporateHierarchyInferenceClient, + candidates: list[CorporateEntityCandidate], +) -> tuple[str, str, str | None, str | None]: + """Prepare provider evidence, then persist affiliation catalog changes.""" + prepared = await prepare_affiliated_organization( + conn, + organization_name, + context_text, + resolution_client, + verification_client, + hierarchy_inference_client, + candidates, + ) + return await apply_prepared_affiliated_organization(conn, prepared, candidates) async def ingest_post_keymen( @@ -249,9 +335,11 @@ async def ingest_post_keymen( else: mentions = await asyncio.to_thread(client.extract, post_title, post_body) candidates = await _load_corporate_entity_candidates(conn) - resolved_by_mention: list[tuple[PersonMention, list[tuple[str, str, str | None]]]] = [] + resolved_by_mention: list[ + tuple[PersonMention, list[tuple[str, str, str | None, str | None]]] + ] = [] for mention in mentions: - resolved_orgs: list[tuple[str, str, str | None]] = [] + resolved_orgs: list[tuple[str, str, str | None, str | None]] = [] for organization_name in mention.affiliated_organization_names: resolved_orgs.append( await _resolve_affiliated_organization( @@ -279,7 +367,7 @@ async def ingest_post_keymen( person_id, ) resolved_names: list[str] = [] - for organization_name, resolved_name, corporate_entity_id in resolved_orgs: + for organization_name, resolved_name, corporate_entity_id, _reason in resolved_orgs: await _upsert_affiliation( conn, person_id, diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 71304ce92..c05d14bbe 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -8,16 +8,17 @@ 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, + EDGE_CUSTOMER_IDENTITY_OBSERVATION, EDGE_MENTION, EDGE_MENTION_ORGANIZATION, EDGE_MENTION_TEAM, @@ -34,10 +35,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.""" @@ -169,6 +196,10 @@ async def persist_edges_for_post( "select corporate_entity_id from post_organization_mention where post_id = $1", post_id, ) + customer_mention_rows = await conn.fetch( + "select corporate_entity_id from post_customer_identity_mention where post_id = $1", + post_id, + ) edges = knowledge_graph_edges_for_post( post_id, [str(row["person_id"]) for row in mention_rows], @@ -182,6 +213,7 @@ async def persist_edges_for_post( for row in team_affiliation_rows ], [str(row["corporate_entity_id"]) for row in organization_mention_rows], + [str(row["corporate_entity_id"]) for row in customer_mention_rows], ) for edge in edges: await conn.fetchrow( @@ -259,7 +291,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 +313,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 ( @@ -293,6 +327,10 @@ async def visible_affiliation_post_ids( select org_mention.post_id from post_organization_mention org_mention where org_mention.corporate_entity_id = $1 + union + select customer_mention.post_id + from post_customer_identity_mention customer_mention + where customer_mention.corporate_entity_id = $1 ) order by post.created_at, post.post_id """, @@ -310,7 +348,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 @@ -350,7 +389,13 @@ async def load_visible_subgraph( visible_post_ids, ) organization_ids = [row["corporate_entity_id"] for row in organization_rows] - if not person_ids and not team_ids and not organization_ids: + customer_rows = await conn.fetch( + "select distinct corporate_entity_id from post_customer_identity_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + customer_ids = [row["corporate_entity_id"] for row in customer_rows] + if not person_ids and not team_ids and not organization_ids and not customer_ids: return [] rows = await conn.fetch( """ @@ -431,6 +476,13 @@ async def load_visible_subgraph( and edge.target_node_id = any($14::uuid[])) ) ) + or ( + edge.edge_type_code = $15 + and edge.source_node_type_code = $13 + and edge.source_node_id = any($16::uuid[]) + and edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[]) + ) """, visible_post_ids, person_ids, @@ -446,6 +498,8 @@ async def load_visible_subgraph( EDGE_MENTION_ORGANIZATION, NODE_CORPORATE_ENTITY, organization_ids, + EDGE_CUSTOMER_IDENTITY_OBSERVATION, + customer_ids, ) return [edge_spec_from_row(row) for row in rows] @@ -539,6 +593,286 @@ 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: + """Build a deterministic post-scoped identifier for one semantic node.""" + 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.get(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], + } + ) + + research_rows = await conn.fetch( + """ + select judgment.post_source_research_judgment_id::text as judgment_id, + retrieval.post_source_research_retrieval_id::text as retrieval_id, + retrieval.evidence_url, retrieval.evidence_title, + retrieval.passage_text, retrieval.content_sha256, + judgment.sharing_actor_name + from post_source_research_lead lead + join post_source_research_judgment judgment + using (post_source_research_lead_id) + join post_source_research_citation citation + using (post_source_research_judgment_id) + join post_source_research_retrieval retrieval + using (post_source_research_retrieval_id) + where lead.post_id = $1 + and judgment.research_status_code = 'research_supported' + and btrim(coalesce(judgment.sharing_actor_name, '')) <> '' + order by lead.lead_ordinal, retrieval.retrieval_ordinal + limit ($2 + 1) + """, + post_id, + relation_limit, + ) + reference_predicate = semantic_predicate_annotations("dct_references") + attribution_predicate = semantic_predicate_annotations("prov_was_attributed_to") + for row in research_rows[:relation_limit]: + document_key = f"source-research-document:{row['retrieval_id']}" + actor_key = semantic_key("organization", row["sharing_actor_name"]) + nodes.setdefault( + document_key, + { + "id": document_key, + "node_type_code": "semantic_document", + "node_id": document_key, + "label": row["evidence_title"] or row["evidence_url"], + "ontology_iri": _SEMANTIC_NODE_CLASS_IRIS["document"], + "ontology_label": "Document", + "is_focus": False, + "is_evidence_text_node": True, + }, + ) + nodes.setdefault( + actor_key, + { + "id": actor_key, + "node_type_code": "semantic_organization", + "node_id": actor_key, + "label": row["sharing_actor_name"], + "ontology_iri": _SEMANTIC_NODE_CLASS_IRIS["organization"], + "ontology_label": "Organization", + "is_focus": False, + "is_evidence_text_node": True, + }, + ) + evidence = { + "assertion_status_code": "research_supported", + "evidence_text": row["passage_text"], + "evidence_url": row["evidence_url"], + "evidence_sha256": row["content_sha256"], + "research_judgment_id": row["judgment_id"], + "evidence_post_ids": [post_id], + } + edges.extend( + ( + { + "source": node_key(NODE_POST, post_id), + "target": document_key, + "edge_type_code": "dct_references", + "ontology_iri": reference_predicate.get("ontology_iri"), + "ontology_label": reference_predicate.get( + "ontology_label", "References" + ), + **evidence, + }, + { + "source": document_key, + "target": actor_key, + "edge_type_code": "prov_was_attributed_to", + "ontology_iri": attribution_predicate.get("ontology_iri"), + "ontology_label": attribution_predicate.get( + "ontology_label", "Was attributed to" + ), + **evidence, + }, + ) + ) + + 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 + or len(research_rows) > relation_limit + ), + } + + async def related_for_start( conn: asyncpg.Connection, node_type_code: str, @@ -576,7 +910,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 +924,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 +939,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..f04d959cf 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -10,6 +10,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime from typing import Any, Mapping @@ -72,7 +73,58 @@ async def persist_lineage_edges(conn: asyncpg.Connection, edges: list[Edge]) -> ) -async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: +@dataclass(frozen=True) +class LineageRebuildResult: + """One rebuild's persisted edges plus its corpus-wide coverage summary.""" + + edges: list[Edge] + coverage: dict[str, int] + + +def lineage_coverage_summary( + rows: list[Mapping[str, Any]], edges: list[Edge] +) -> dict[str, int]: + """Corpus-wide breakdown of what a rebuild actually found (ADR 0143). + + ``visible_lineage_graph`` reports this distinction per post, scoped to + one ABAC-visible reader; this is the same distinction aggregated across + every eligible post, for the operator who just ran the rebuild -- + counting a post as ``no_relation_found`` (its `reconstruct_group_key` + group has other members, but it ended up with no edge) versus + ``no_comparison_group`` (it was the only member of its group, so no + relation could have been found either way) rather than presenting a + reader-facing branching DAG's sparseness as undifferentiated silence. + """ + group_sizes: dict[str, int] = {} + for row in rows: + group = reconstruct_group_key(row) + group_sizes[group] = group_sizes.get(group, 0) + 1 + + posts_with_edges: set[str] = set() + for edge in edges: + posts_with_edges.add(edge.parent_id) + posts_with_edges.add(edge.child_id) + + posts_no_relation_found = 0 + posts_no_comparison_group = 0 + for row in rows: + post_id = str(row["post_id"]) + if post_id in posts_with_edges: + continue + if group_sizes[reconstruct_group_key(row)] > 1: + posts_no_relation_found += 1 + else: + posts_no_comparison_group += 1 + + return { + "total_posts": len(rows), + "posts_with_edges": len(posts_with_edges), + "posts_no_relation_found": posts_no_relation_found, + "posts_no_comparison_group": posts_no_comparison_group, + } + + +async def rebuild_lineage(conn: asyncpg.Connection) -> LineageRebuildResult: """Reconstruct lineage for every ``source_post`` and persist the edges.""" rows = await conn.fetch( "select post_id, post_title, voc_type_code, created_at, corporate_entity_id, " @@ -81,7 +133,7 @@ async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: ) edges = lineage_edge_specs(records_from_source_posts(rows)) await persist_lineage_edges(conn, edges) - return edges + return LineageRebuildResult(edges=edges, coverage=lineage_coverage_summary(rows, edges)) async def visible_lineage_graph( @@ -95,10 +147,17 @@ async def visible_lineage_graph( The persisted graph can contain tens of thousands of posts. The UI opens individual posts for complete lineage, while this landing projection keeps only the newest ``limit`` visible nodes and edges between them. + + A focused, empty-graph result also carries ``isolation_reason`` (ADR + 0143): ``"no_relation_found"`` when the post had other visible posts in + its ``reconstruct_group_key`` group and reconstruct still produced no + edge, or ``"no_comparison_group"`` when it was the only visible member + of its group. ``None`` otherwise (non-empty graph, or the landing view). """ 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)] @@ -106,6 +165,7 @@ async def visible_lineage_graph( "select parent_post_id, child_post_id, fused_score from post_lineage_edge" ) + isolation_reason: str | None = None if focus_post_id is None: visible = sorted( visible_all, @@ -116,10 +176,17 @@ async def visible_lineage_graph( else: focus_id = str(focus_post_id) focus_visible = any(str(row["post_id"]) == focus_id for row in visible_all) + visible_all_ids = {str(row["post_id"]) for row in visible_all} neighbors: dict[str, set[str]] = {} for edge in edge_rows: parent_id = str(edge["parent_post_id"]) child_id = str(edge["child_post_id"]) + # Both ends must be ABAC-visible: an edge to a hidden sibling + # must not make an otherwise-isolated post look connected, nor + # leak the mere existence of a hidden relationship through the + # isolation_reason it would then be denied. + if parent_id not in visible_all_ids or child_id not in visible_all_ids: + continue neighbors.setdefault(parent_id, set()).add(child_id) neighbors.setdefault(child_id, set()).add(parent_id) @@ -136,6 +203,28 @@ async def visible_lineage_graph( # still reports its empty direct/indirect lists. if len(component_ids) <= 1: visible = [] + # ADR 0143: distinguish "reconstruct compared this post against + # real candidates and found no relation" from "there was + # nothing to compare it against" -- only answerable, and only + # meaningful, when the focused post is itself ABAC-visible. A + # post outside `visible_all` (not visible, or nonexistent) + # reveals nothing here, same as the empty graph it already got. + if focus_visible: + focus_group = reconstruct_group_key( + next( + row + for row in visible_all + if str(row["post_id"]) == focus_id + ) + ) + group_size = sum( + 1 + for row in visible_all + if reconstruct_group_key(row) == focus_group + ) + isolation_reason = ( + "no_relation_found" if group_size > 1 else "no_comparison_group" + ) else: visible = [ row for row in visible_all if str(row["post_id"]) in component_ids @@ -173,4 +262,9 @@ async def visible_lineage_graph( } for row in visible_edges ] - return {"nodes": nodes, "edges": edges, "truncated": truncated} + return { + "nodes": nodes, + "edges": edges, + "truncated": truncated, + "isolation_reason": isolation_reason, + } diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..f0f64cbca 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 @@ -21,6 +23,7 @@ import asyncio import json +import logging from contextlib import asynccontextmanager from dataclasses import asdict from datetime import datetime @@ -31,7 +34,7 @@ import redis.asyncio as redis from fastapi import Depends, FastAPI, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, Field from lineageweave.adjudication_client import ( ContextualOrchestratorAdjudicationClient, @@ -65,6 +68,10 @@ ContextualOrchestratorCustomerHintResolutionClient, NullCustomerHintResolutionClient, ) +from lineageweave.customer_identity_judgment import ( + ContextualOrchestratorCustomerIdentityJudgeClient, + NullCustomerIdentityJudgeClient, +) from lineageweave.organization_name_resolution import ( ContextualOrchestratorOrganizationNameResolutionClient, NullOrganizationNameResolutionClient, @@ -84,7 +91,12 @@ from lineageweave.post_structure import ContextualOrchestratorPostStructureClient, NullPostStructureClient from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient +from lineageweave.source_research import ( + ContextualOrchestratorSourceResearchJudge, + SearxngSourceResearchClient, +) 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 @@ -103,12 +115,22 @@ ) from backend.app.analysis_run_worker import run_analysis_run_worker from backend.app.post_content_queue import ( + SUCCEEDED, ensure_post_content_job, + fetch_post_summary_source, post_content_api_status, post_content_is_complete, + post_content_summary_is_ready, + post_content_summary_status_message, + post_body_has_images, publish_post_content_event, + source_body_sha256, +) +from backend.app.post_content_worker import run_post_content_worker_supervised +from backend.app.source_research_ingestion import ( + decode_research_retrievals, + research_post_sources, ) -from backend.app.post_content_worker import run_post_content_worker from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock from backend.app.activity_stream import ( create_valkey_client, @@ -129,6 +151,22 @@ ingest_post_entity_relationships, ) from backend.app.five_w1h_ingestion import load_five_w1h_slots +from backend.app.global_ask_history import ( + GlobalAskConversationNotFound, + GlobalAskEvidenceChanged, + conversation_exists, + fetch_conversation, + list_conversations, + persist_turn, +) +from backend.app.post_ask_history import ( + PostAskConversationNotFound, + PostAskEvidenceChanged, + conversation_exists as post_ask_conversation_exists, + fetch_conversation as fetch_post_ask_conversation, + list_conversations as list_post_ask_conversations, + persist_turn as persist_post_ask_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 +195,7 @@ labels_for_codes, person_exists, persist_edges_for_post, + post_knowledge_graph, related_for_entity, related_for_person, related_for_team, @@ -179,11 +218,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 @@ -210,7 +255,7 @@ async def lifespan(app: FastAPI): ) ) app.state.post_content_worker = asyncio.create_task( - run_post_content_worker( + run_post_content_worker_supervised( app.state.valkey, app.state.pool, vision_factory=_vision_client, @@ -232,6 +277,8 @@ async def lifespan(app: FastAPI): await app.state.valkey.aclose() +_logger = logging.getLogger(__name__) + app = FastAPI(title="LineageWeave API", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -307,6 +354,18 @@ def _customer_hint_resolution_client(): ) +def _customer_identity_judge_client(): + """Build the fast-mlsirm identity Judge over contextual-orchestrator.""" + settings = load_settings() + if not (settings.orchestrator_base_url and settings.orchestrator_api_key): + return NullCustomerIdentityJudgeClient() + return ContextualOrchestratorCustomerIdentityJudgeClient( + base_url=settings.orchestrator_base_url, + api_key=settings.orchestrator_api_key, + timeout=200.0, + ) + + def _corporate_hierarchy_inference_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() @@ -354,6 +413,23 @@ def _post_structure_client(): ) +def _source_research_clients(): + """Build the search/crawl and Judge channels required by ADR 0133.""" + settings = load_settings() + if not ( + settings.searxng_base_url + and settings.orchestrator_base_url + and settings.orchestrator_api_key + ): + return None + return ( + SearxngSourceResearchClient(settings.searxng_base_url), + ContextualOrchestratorSourceResearchJudge( + settings.orchestrator_base_url, settings.orchestrator_api_key + ), + ) + + def _post_chat_client(): """Live orchestrator client when configured; otherwise the unavailable null.""" settings = load_settings() @@ -414,12 +490,29 @@ 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.""" + try: + state_code = normalize_source_detail_state_code(post["source_detail_state_code"]) + except KeyError: + return False + if state_code == WRITING_SOURCE_DETAIL_STATE_CODE: + return account.has_permission(_POST_ADMIN) or str( + post.get("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 +536,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 +548,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 +618,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 +631,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 +650,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 +664,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,56 +676,156 @@ 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") +_TENANT_SETTINGS_DEFAULTS: dict[str, str | int] = { + "brandName": "LineageWeave", + "systemName": "LineageWeave", + "copyrightYear": 2026, + "copyrightHolder": "LineageWeave", +} + + +def _tenant_settings_response(row: asyncpg.Record | None) -> dict[str, str | int]: + """Return the stable shell identity contract from one tenant settings row.""" + if row is None: + return dict(_TENANT_SETTINGS_DEFAULTS) + return { + "brandName": row["brand_name"], + "systemName": row["system_name"], + "copyrightYear": row["copyright_year"], + "copyrightHolder": row["copyright_holder"], + } + + +def _tenant_setting_text(payload: dict[str, Any], key: str, current: str) -> str: + """Validate and trim a required tenant display string at the API boundary.""" + value = payload.get(key, current) + if not isinstance(value, str) or not value.strip(): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, f"{key} must not be blank") + return value.strip() + + +def _tenant_copyright_year(payload: dict[str, Any], current: int) -> int: + """Validate the explicit copyright year instead of deriving it from the clock.""" + value = payload.get("copyrightYear", current) + if isinstance(value, bool) or not isinstance(value, int) or not 1900 <= value <= 2100: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "copyrightYear must be an integer between 1900 and 2100", + ) + return value + @app.get("/api/settings", response_model=dict) async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Return the authenticated tenant's current display configuration.""" async with pool.acquire() as conn: - row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") - if not row: - return {"brandName": "LineageWeave"} - return {"brandName": row["brand_name"]} + row = await conn.fetchrow( + """ + SELECT brand_name, system_name, copyright_year, copyright_holder + FROM tenant_settings + WHERE tenant_settings_id = 1 + """ + ) + return _tenant_settings_response(row) @app.patch("/api/settings", response_model=dict) async def update_tenant_settings( - payload: dict, + payload: dict[str, Any], account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Validate and persist tenant display configuration for an administrator.""" # Only admins can change settings _require_post_admin(account) - 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", - brand_name - ) - return {"brandName": brand_name} + async with conn.transaction(): + current_row = await conn.fetchrow( + """ + SELECT brand_name, system_name, copyright_year, copyright_holder + FROM tenant_settings + WHERE tenant_settings_id = 1 + FOR UPDATE + """ + ) + current = _tenant_settings_response(current_row) + brand_name = _tenant_setting_text(payload, "brandName", str(current["brandName"])) + system_name = _tenant_setting_text(payload, "systemName", str(current["systemName"])) + copyright_year = _tenant_copyright_year(payload, int(current["copyrightYear"])) + copyright_holder = _tenant_setting_text( + payload, "copyrightHolder", str(current["copyrightHolder"]) + ) + await conn.execute( + """ + INSERT INTO tenant_settings + (tenant_settings_id, brand_name, system_name, copyright_year, copyright_holder) + VALUES (1, $1, $2, $3, $4) + ON CONFLICT (tenant_settings_id) DO UPDATE SET + brand_name = EXCLUDED.brand_name, + system_name = EXCLUDED.system_name, + copyright_year = EXCLUDED.copyright_year, + copyright_holder = EXCLUDED.copyright_holder, + updated_at = now() + """, + brand_name, + system_name, + copyright_year, + copyright_holder, + ) + return { + "brandName": brand_name, + "systemName": system_name, + "copyrightYear": copyright_year, + "copyrightHolder": copyright_holder, + } +@app.get("/healthz") async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -630,39 +842,73 @@ 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, } class LocalePreferenceRequest(BaseModel): + """Validated body for updating the current account's locale.""" + preferred_locale: Literal["en", "ko", "zh", "ja", "vi"] class CustomerHintResolveRequest(BaseModel): + """Validated source-hint identifiers submitted for governed resolution.""" + hint_code: str + source_system_code: str | None = None @app.patch("/api/me/preferences") @@ -681,14 +927,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": [], @@ -702,7 +993,7 @@ async def read_customer_master( source_customer_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli f""" with scoped as ( - select post_id, post_title, created_at, + select post_id, post_title, created_at, source_system_code, nullif(btrim(source_customer_code), '') as customer_code, nullif(btrim(source_customer_name), '') as customer_name, case when nullif(btrim(source_customer_code), '') is null @@ -711,28 +1002,31 @@ 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 ( - partition by customer_code, customer_name_group + partition by source_system_code, customer_code, customer_name_group order by created_at desc, post_id desc ) as related_rank from scoped ), groups as ( - select customer_code, customer_name_group, - max(customer_name) as customer_name, + select source_system_code, customer_code, customer_name_group, + (array_agg(customer_name order by created_at desc, post_id desc) + filter (where customer_name is not null))[1] as customer_name, count(*) as post_count from ranked - group by customer_code, customer_name_group + group by source_system_code, customer_code, customer_name_group ), top_groups as materialized ( select * from groups - order by post_count desc, customer_code, customer_name + order by post_count desc, source_system_code, customer_code, customer_name limit 100 ), related as ( - select ranked.customer_code, ranked.customer_name_group, + select ranked.source_system_code, ranked.customer_code, ranked.customer_name_group, json_agg( json_build_object( 'post_id', post.post_id::text, @@ -742,21 +1036,36 @@ async def read_customer_master( ) as related_posts from ranked join top_groups - on top_groups.customer_code is not distinct from ranked.customer_code + on top_groups.source_system_code is not distinct from ranked.source_system_code + and top_groups.customer_code is not distinct from ranked.customer_code and top_groups.customer_name_group is not distinct from ranked.customer_name_group join source_post post on post.post_id = ranked.post_id where ranked.related_rank <= 20 - group by ranked.customer_code, ranked.customer_name_group + group by ranked.source_system_code, ranked.customer_code, ranked.customer_name_group ) - select top_groups.customer_code, top_groups.customer_name, top_groups.post_count, - coalesce(related.related_posts, '[]'::json) as related_posts + select top_groups.source_system_code, top_groups.customer_code, + top_groups.customer_name, top_groups.post_count, + coalesce(related.related_posts, '[]'::json) as related_posts, + coalesce(judgment.judgment_status_code, 'hint_only') as resolution_status, + binding.corporate_entity_id, entity.entity_name as resolved_entity_name, + binding.customer_identity_judgment_id from top_groups left join related - on related.customer_code is not distinct from top_groups.customer_code + on related.source_system_code is not distinct from top_groups.source_system_code + and related.customer_code is not distinct from top_groups.customer_code 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 + left join customer_identity_binding binding + on binding.source_system_code is not distinct from top_groups.source_system_code + and binding.source_customer_code = top_groups.customer_code + left join customer_identity_judgment judgment + on judgment.customer_identity_judgment_id = binding.customer_identity_judgment_id + left join corporate_entity entity + on entity.corporate_entity_id = binding.corporate_entity_id + order by top_groups.post_count desc, top_groups.source_system_code, + 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,29 +1204,80 @@ 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_mention as ( + select post_id, corporate_entity_id + from post_organization_mention + union + select post_id, corporate_entity_id + from post_customer_identity_mention + ), observed as ( + select mention.corporate_entity_id + from observed_mention mention + join source_post post on post.post_id = 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 (mention.corporate_entity_id = any($3::uuid[])) + group by mention.corporate_entity_id + order by count(distinct mention.post_id) desc, + 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] + entity_name_rows = await conn.fetch( + """ + select corporate_entity_id, entity_name, name_role_code, + observed_from, observed_to + from corporate_entity_name_history + where corporate_entity_id = any($1::uuid[]) + order by corporate_entity_id, observed_from, entity_name + """, + entity_ids, + ) source_author_affiliations = await _load_account_affiliation_hints( conn, [str(row["author_account_id"]) for row in source_author_rows], @@ -931,18 +1291,38 @@ 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, ) side_labels = await labels_for_codes(conn, [row["person_side_code"] for row in keyman_rows]) entity_level_labels = await labels_for_codes(conn, [row["entity_level_code"] for row in entity_rows]) - relationship_network = await fetch_relationship_network(conn, entity_ids) + # Same synthetic-only exclusion as the entity tree above (line + # ~1262): a stale demo-only grant is not a real affiliation once + # real source context exists, so it must not extend this ABAC scope. + authorized_relationship_entity_ids = [ + entity_id + for entity_id in account.corporate_entity_ids + if entity_id not in synthetic_only_entity_ids + ] + relationship_network = await fetch_relationship_network(conn, authorized_relationship_entity_ids) + + names_by_entity: dict[str, list[dict[str, Any]]] = {} + for row in entity_name_rows: + names_by_entity.setdefault(str(row["corporate_entity_id"]), []).append( + { + "entity_name": row["entity_name"], + "name_role_code": row["name_role_code"], + "observed_from": row["observed_from"], + "observed_to": row["observed_to"], + } + ) keymen_by_id: dict[str, dict[str, Any]] = {} for row in keyman_rows: @@ -984,12 +1364,15 @@ 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), + "name_history": names_by_entity.get(str(row["corporate_entity_id"]), []), } for row in entity_rows ], "keymen": list(keymen_by_id.values()), "source_customer_hints": [ { + "source_system_code": row["source_system_code"], "customer_code": row["customer_code"], "customer_name": row["customer_name"], "post_count": row["post_count"], @@ -998,7 +1381,18 @@ async def read_customer_master( if isinstance(row["related_posts"], str) else row["related_posts"] or [] ), - "resolution_status": "hint_only", + "resolution_status": row["resolution_status"], + "corporate_entity_id": ( + str(row["corporate_entity_id"]) + if row["corporate_entity_id"] is not None + else None + ), + "resolved_entity_name": row["resolved_entity_name"], + "customer_identity_judgment_id": ( + str(row["customer_identity_judgment_id"]) + if row["customer_identity_judgment_id"] is not None + else None + ), "hint_trust": customer_hint_trust(row["customer_name"], row["customer_code"]), "provenance": "source_post.source_customer_code/source_post.source_customer_name", } @@ -1058,11 +1452,21 @@ async def resolve_customer_master_hint( _require_post_admin(account) async with pool.acquire() as conn: try: + settings = load_settings() resolution = await resolve_customer_hint( conn, _customer_hint_resolution_client(), _relation_verification_client(), request.hint_code, + source_system_code=request.source_system_code, + authorized_corporate_entity_ids=tuple(account.corporate_entity_ids), + identity_judge_client=_customer_identity_judge_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + tepp_client=configured_tepp_client( + settings.tepp_transport_url, + settings.tepp_api_key, + settings.tepp_temporal_context_url, + ), ) except (HttpClientError, OSError) as exc: # resolve_and_verify_organization_name's resolution/verification @@ -1074,9 +1478,15 @@ 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. + _logger.exception("resolve_customer_master_hint failed unexpectedly") + 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, + status.HTTP_422_UNPROCESSABLE_CONTENT, "this hint could not be resolved to a corroborated organization name", ) return resolution @@ -1094,7 +1504,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, ) @@ -1112,8 +1522,8 @@ async def rebuild_lineage_graph( _require_post_admin(account) async with pool.acquire() as conn: async with conn.transaction(): - edges = await rebuild_lineage(conn) - return {"edge_count": len(edges)} + result = await rebuild_lineage(conn) + return {"edge_count": len(result.edges), "coverage": result.coverage} @app.get("/api/posts") @@ -1122,6 +1532,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 +1541,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 +1560,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,35 +1576,16 @@ 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. - rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - f""" - with page as ( - select post.post_id, post.post_title, post.voc_type_code, post.visibility_code, - post.source_stage_code, post.source_detail_state_code, - post.source_draft_code, post.source_deleted_flag, - post.source_author_code, post.source_author_name, - post.source_company_code, post.source_company_name, - post.source_process_unit_code, post.source_process_unit_name, - post.source_sales_pool_code, post.source_sales_pool_name, - post.source_customer_code, post.source_customer_name, - post.source_project_code, post.source_project_name, - post.source_system_code, - post.source_record_key, - post.corporate_entity_id, post.created_at, - case - when $1::text is null then 0 - when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0 - when post.post_id = any($5::uuid[]) then 1 - else 2 - end as search_priority, - count(*) over() as total_count - from source_post post - where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($2::text[])) - and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} + # Extracted once so the pagination-overshoot fallback below can + # reuse the identical predicate without duplicating ~170 lines of SQL. + _list_posts_predicate_sql = f""" + {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 +1604,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 +1638,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 +1650,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 +1663,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 +1685,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 +1760,48 @@ 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) + """ + # Safe SQL: page SQL is a closed schema query; every request value is an asyncpg parameter. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with page as ( + select post.post_id, post.post_title, post.voc_type_code, post.visibility_code, + post.source_stage_code, post.source_detail_state_code, + post.source_draft_code, post.source_deleted_flag, + post.source_author_code, post.source_author_name, + post.source_company_code, post.source_company_name, + post.source_process_unit_code, post.source_process_unit_name, + post.source_sales_pool_code, post.source_sales_pool_name, + post.source_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.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($6::uuid[]) then 1 + else 2 + end as search_priority, + count(*) over() as total_count + from source_post post + where {_list_posts_predicate_sql} 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 +1826,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,31 +1848,58 @@ 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) - total_count = int(rows[0]["total_count"]) if rows else 0 + if rows: + total_count = int(rows[0]["total_count"]) + else: + # count(*) over() only rides along on rows that survive this + # page's own OFFSET/LIMIT -- an offset past the last matching + # row returns zero rows and would otherwise silently report + # total_count=0 even though matches exist (a paginator relying + # on total_count to detect it overshot would see "no results" + # instead). $10/$11 in the shared predicate become $7/$8 here + # since this query has no offset/limit/sort parameters of its own. + fallback_predicate = _list_posts_predicate_sql.replace("$10", "$7").replace("$11", "$8") + # Safe SQL: identical predicate as the page query above, just renumbered; every value is still an asyncpg parameter. + fallback_total_count = await conn.fetchval( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f"select count(*) as total_count from source_post post where {fallback_predicate}", + search_term, + list(account.corporate_entity_ids), + voc_type_codes, + source_detail_state_codes, + visibility.strip() if visibility and visibility.strip() else None, + body_search_ids, + account.user_account_id, + account.has_permission(_POST_ADMIN), + ) + total_count = int(fallback_total_count or 0) return { "posts": [_serialize_post(row, labels) for row in visible], "total_count": total_count, "limit": limit, "offset": offset, "voc_type_options": voc_type_options, + "source_detail_state_options": source_detail_state_options, "visibility_options": visibility_options, } @@ -1417,7 +1926,7 @@ async def read_post( as_of_clock = parse_as_of_clock(as_of) except ValueError as exc: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "as_of must be an ISO-8601 timestamp. Use the run cutoff, " "then compare the known body with the live body.", ) from exc @@ -1428,11 +1937,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,100 +1979,138 @@ 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 + derived_content_is_current = False async with pool.acquire() as conn: - unit_rows = await conn.fetch( - """ - select unit.unit_index, unit.unit_kind_code, unit.unit_label, unit.unit_text, - coalesce(structure.indent_level, 0) as indent_level, - structure.decision_source_code, structure.confidence, - structure.evidence_text - from post_content_unit unit - left join post_content_unit_structure structure - on structure.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = $1 - order by unit.unit_index - """, - post_id, - ) - content_status = post_content_api_status( - None, - content_present=bool(unit_rows), - ) - body_row = await conn.fetchrow( - "select post_body from source_post where post_id = $1", post_id - ) - raw_body = None if body_row is None else body_row["post_body"] - if isinstance(raw_body, str) and raw_body.strip(): - content_present = bool(unit_rows) - content_complete = await post_content_is_complete( - conn, + unit_rows: list[asyncpg.Record] = [] + rows: list[asyncpg.Record] = [] + region_rows: list[asyncpg.Record] = [] + content_status = post_content_api_status(None, content_present=False) + job = None + async with conn.transaction(): + body_row = await conn.fetchrow( + "select post_body from source_post where post_id = $1 for update", 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(): + raw_body = None if body_row is None else body_row["post_body"] + if isinstance(raw_body, str) and raw_body.strip(): + 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 + ), + ) job = await ensure_post_content_job( conn, post_id, raw_body, content_complete=content_complete, ) + if job is not None: content_status = post_content_api_status( job.status_code, - content_present=content_present, + content_present=False, ) if job.should_publish: queue_event = (job.post_id, job.source_body_sha256) - rows = await conn.fetch( - """ - select image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption, - coalesce( - array_agg(tag.tag_text order by tag.tag_text) - filter (where tag.tag_text is not null), - '{}'::text[] - ) as tags - from post_content_unit unit - join post_content_image image - on image.post_content_unit_id = unit.post_content_unit_id - left join post_content_image_tag tag - on tag.post_content_image_id = image.post_content_image_id - where unit.post_id = $1 - group by image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption - order by unit.unit_index - """, - post_id, - ) - region_rows = await conn.fetch( - """ - select image.post_content_image_id, region.region_index, - region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption, - coalesce( - array_agg(tag.tag_text order by tag.tag_text) - filter (where tag.tag_text is not null), - '{}'::text[] - ) as tags - from post_content_image image - join post_content_image_region region - on region.post_content_image_id = image.post_content_image_id - left join post_content_image_region_tag tag - on tag.post_content_image_region_id = region.post_content_image_region_id - where image.post_content_image_id = any($1::uuid[]) - group by image.post_content_image_id, region.region_index, - region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption - order by image.post_content_image_id, region.region_index - """, - [row["post_content_image_id"] for row in rows], - ) if rows else [] + async with conn.transaction(): + source_binding = await conn.fetchrow( + "select post_body from source_post where post_id = $1 for share", + post_id, + ) + binding = await conn.fetchrow( + """ + select source_body_sha256, status_code + from post_content_ingestion_job + where post_id = $1 + for share + """, + post_id, + ) if source_binding is not None else None + bound_body = ( + None if source_binding is None else source_binding["post_body"] + ) + derived_content_is_current = bool( + isinstance(bound_body, str) + and binding is not None + and binding["status_code"] == SUCCEEDED + and binding["source_body_sha256"] == source_body_sha256(bound_body) + ) + if derived_content_is_current: + unit_rows = await conn.fetch( + """ + select unit.unit_index, unit.unit_kind_code, unit.unit_label, + unit.unit_text, + coalesce(structure.indent_level, 0) as indent_level, + structure.decision_source_code, + structure.structure_confidence, structure.evidence_text + from post_content_unit unit + left join post_content_unit_structure structure + on structure.post_content_unit_id = unit.post_content_unit_id + where unit.post_id = $1 + order by unit.unit_index + """, + post_id, + ) + content_status = post_content_api_status( + str(binding["status_code"]), + content_present=bool(unit_rows), + ) + elif job.status_code == SUCCEEDED: + content_status = "processing" + rows = await conn.fetch( + """ + select image.post_content_image_id, unit.unit_index, image.mime_type, + image.description_status_code, 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), + '{}'::text[] + ) as tags + from post_content_unit unit + join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + left join post_content_image_tag tag + on tag.post_content_image_id = image.post_content_image_id + where unit.post_id = $1 + group by image.post_content_image_id, unit.unit_index, + image.mime_type, image.description_status_code, + image.extracted_text, image.image_caption + order by unit.unit_index + """, + post_id, + ) if derived_content_is_current else [] + region_rows = await conn.fetch( + """ + select image.post_content_image_id, region.region_index, + region.x_ratio, region.y_ratio, region.width_ratio, + region.height_ratio, region.description_status_code, + region.extracted_text, region.image_caption, + coalesce( + array_agg(tag.tag_text order by tag.tag_text) + filter (where tag.tag_text is not null), + '{}'::text[] + ) as tags + from post_content_image image + join post_content_image_region region + on region.post_content_image_id = image.post_content_image_id + left join post_content_image_region_tag tag + on tag.post_content_image_region_id = region.post_content_image_region_id + where image.post_content_image_id = any($1::uuid[]) + group by image.post_content_image_id, region.region_index, + region.x_ratio, region.y_ratio, region.width_ratio, + region.height_ratio, region.description_status_code, + region.extracted_text, region.image_caption + order by image.post_content_image_id, region.region_index + """, + [row["post_content_image_id"] for row in rows], + ) if rows else [] if queue_event is not None: await publish_post_content_event( valkey, @@ -1574,7 +2128,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 +2142,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 +2153,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"]), []), } @@ -1612,8 +2166,14 @@ async def _load_visible_post( post_id: str, account: CurrentAccount, pool: asyncpg.Pool, + *, + allow_writing: bool = False, ) -> asyncpg.Record: - """Load one post the account may see, or raise 404 / 403.""" + """Load one post the account may see, or raise 404 / 403. + + Analysis callers keep the default W-state rejection. Non-analysis actions + such as an author's bookmark may explicitly read an authorized W row. + """ _require_post_read(account) async with pool.acquire() as conn: # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. @@ -1621,6 +2181,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 +2192,22 @@ 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 ( + not allow_writing + and normalize_source_detail_state_code(row.get("source_detail_state_code")) + == WRITING_SOURCE_DETAIL_STATE_CODE + ): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "Writing-in-progress posts are not analysis targets.", + ) return row @@ -1657,6 +2227,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 +2273,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 +2309,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 +2440,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 +2470,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 +2499,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 +2606,12 @@ 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. + _logger.exception("verify_post_entity_relationships failed unexpectedly") + 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, @@ -2039,54 +2633,163 @@ async def verify_post_entity_relationships( } -@app.post("/api/posts/{post_id}/extract-keymen") -async def extract_post_keymen( +@app.get("/api/posts/{post_id}/source-research") +async def read_post_source_research( post_id: str, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), - valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: - """Runs Keyman extraction over a post's own title+body and persists the - result (cataloged_person / person_affiliation / post_person_mention / - knowledge_graph_edge), then classifies each affiliated organization's - relationship to the post author's org (post_counterparty_entity). - Gated by post_admin, not post_read: this is a write action with a - real LLM-call cost, not a read. - """ - _require_post_admin(account) + """Read persisted URL/patent research without triggering external calls.""" post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - with use_llm_metadata(post_metadata): - keyman_client = _keyman_extraction_client() - if not keyman_client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", - ) - relationship_client = _entity_relationship_client() - async with pool.acquire() as conn: - body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - raw_body = "" if body_row is None else body_row["post_body"] + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + select lead.lead_ordinal, lead.lead_type_code, lead.query_text, + lead.evidence_text, lead.source_content_unit_id::text, + lead.source_image_region_id::text, + case judgment.research_status_code + when 'research_supported' then 'supported' + when 'research_refuted' then 'refuted' + else 'not_enough_information' + end as research_status_code, + judgment.sharing_actor_name, judgment.rationale_text, + coalesce( + jsonb_agg( + jsonb_build_object( + 'url', retrieval.evidence_url, + 'title', retrieval.evidence_title, + 'passage_text', retrieval.passage_text, + 'cited', citation.post_source_research_citation_id is not null + ) order by retrieval.retrieval_ordinal + ) filter (where retrieval.post_source_research_retrieval_id is not null), + '[]'::jsonb + ) as retrievals + from post_source_research_lead lead + join post_source_research_judgment judgment + using (post_source_research_lead_id) + left join post_source_research_retrieval retrieval + using (post_source_research_lead_id) + left join post_source_research_citation citation + on citation.post_source_research_judgment_id = judgment.post_source_research_judgment_id + and citation.post_source_research_retrieval_id = retrieval.post_source_research_retrieval_id + where lead.post_id = $1 + group by lead.post_source_research_lead_id, + judgment.post_source_research_judgment_id + order by lead.lead_ordinal + """, + post_id, + ) + research = [] + for row in rows: + item = dict(row) + item["retrievals"] = decode_research_retrievals(item["retrievals"]) + research.append(item) + return { + "post_id": str(post["post_id"]), + "research": research, + } + + +@app.post("/api/posts/{post_id}/source-research") +async def research_post_source_references( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Search, crawl, and Judge explicit URL/patent leads under ADR 0133.""" + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + clients = _source_research_clients() + if clients is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Source research is unavailable: configure SearXNG and contextual-orchestrator", + ) + search_client, judge_client = clients + try: + with use_llm_metadata(build_post_llm_metadata(post_id, post)): + researched = await research_post_sources( + pool, post_id, search_client, judge_client + ) + except (HttpClientError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Source research is unavailable: retrieval or adjudication produced no complete evidence", + ) from exc + await publish_activity_event( + valkey, + post_id, + "source_research_completed", + account.user_account_id, + f"Source research completed: {len(researched)} reference lead(s)", + ) + return { + "post_id": str(post["post_id"]), + "researched_count": len(researched), + } + + +@app.post("/api/posts/{post_id}/extract-keymen") +async def extract_post_keymen( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Runs Keyman extraction over a post's own title+body and persists the + result (cataloged_person / person_affiliation / post_person_mention / + knowledge_graph_edge), then classifies each affiliated organization's + relationship to the post author's org (post_counterparty_entity). + Gated by post_admin, not post_read: this is a write action with a + real LLM-call cost, not a read. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + with use_llm_metadata(post_metadata): + keyman_client = _keyman_extraction_client() + if not keyman_client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + ) + relationship_client = _entity_relationship_client() + async with pool.acquire() as conn: + body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) + raw_body = "" if body_row is None else body_row["post_body"] # HTML/base64-image content must never reach an LLM prompt raw -- # tags dilute the model's attention and a base64 payload sent as # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). - 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. + _logger.exception("extract_post_keymen.ingest_mentions failed unexpectedly") + 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 +2805,21 @@ 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. + _logger.exception("extract_post_keymen.ingest_relationships failed unexpectedly") + 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( @@ -2150,13 +2865,16 @@ async def read_post_lineage( """ await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: - linked = await find_linked_post_ids(conn, post_id) + linked = await find_linked_post_ids( + conn, post_id, lambda row: _can_use_post_for_analysis(account, row) + ) candidate_ids = linked.direct | linked.indirect rows = {} if candidate_ids: # Safe SQL: the eligibility predicate is an immutable schema fragment; candidate ids are bound. fetched = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli "select post_id, post_title, visibility_code, corporate_entity_id, " + "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 +2891,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 +2901,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 +2962,29 @@ 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. + _logger.exception("evaluate_post failed unexpectedly") + 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, @@ -2276,7 +3018,7 @@ async def compare_period_groupings( try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: rows = await fetch_period_comparison(conn, period_code) demo_entity_ids: set[str] = set() @@ -2287,7 +3029,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: @@ -2305,7 +3047,7 @@ async def list_period_reports( """Available calibrated periods for one grouping kind (FIPC trend).""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") async with pool.acquire() as conn: summaries = await list_period_report_summaries(conn, grouping_kind) demo_entity_ids: set[str] = set() @@ -2316,7 +3058,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: @@ -2335,11 +3077,11 @@ async def read_period_reports( """Calibrated IRT scores for one grouping kind and calendar period.""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: reports = await fetch_period_reports(conn, grouping_kind, period_code) demo_entity_ids: set[str] = set() @@ -2350,7 +3092,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 +3100,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( @@ -2385,11 +3149,11 @@ async def rebuild_period_report_endpoint( """Refit or FIPC-score every group in the period. post_admin only.""" _require_post_admin(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: async with conn.transaction(): reports = await rebuild_period_reports(conn, grouping_kind, period_code) @@ -2410,14 +3174,15 @@ async def read_post_summary( ) -> dict[str, Any]: """A Korean summary, key events, and R&R for the popup. - Returns a persisted row when one exists so a seeded demo stack is - not empty without a live LLM. Otherwise derives through the - orchestrator and stores the result. Missing both is 503 -- never a - fabricated summary. + Returns current persisted evidence when it exists. A stale text summary is + returned immediately with its explicit status; operator backfill refreshes + it without making a reader wait for two orchestrator calls. Missing both is + derived and persisted, or returns 503 -- never a fabricated summary. """ post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) queue_event: tuple[str, str] | None = None + 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 @@ -2428,21 +3193,88 @@ async def read_post_summary( ) except ValueError as exc: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc - stored = await fetch_persisted_summary(conn, post_id) - if stored is not None: - return stored - stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) + 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, + job.source_body_sha256, + ) + 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], + ) + queue_event = None + if summary_waiting_for_images: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + post_content_summary_status_message(job.status_code), + ) + normalized_body = await fetch_post_summary_source(conn, post_id) + if not normalized_body: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: persisted post content is not available", + ) + stored = await fetch_persisted_summary( + conn, + post_id, + summary_input=normalized_body, + ) + if stored is not None: + if queue_event is not None: + await publish_post_content_event( + valkey, + post_id=queue_event[0], + source_body_digest=queue_event[1], + ) + return stored + else: + normalized_body = ( + await asyncio.to_thread(normalize_post_body, raw_body) + ).text + stored = await fetch_persisted_summary( + conn, + post_id, + summary_input=normalized_body, + ) + if stored is not None: + return stored + stale = await fetch_persisted_summary( + conn, + post_id, + summary_input=normalized_body, + allow_stale=True, + ) + if stale is not None: + return stale with use_llm_metadata(post_metadata): client = _post_summary_client() if not client.available: - if stale is not None: - return stale raise HTTPException( 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: @@ -2453,38 +3285,53 @@ async def read_post_summary( else: summary = await asyncio.to_thread(client.summarize, post["post_title"], normalized_body) except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: - if stale is not None: - return stale raise HTTPException( 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. + _logger.exception("read_post_summary.summarize failed unexpectedly") + 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, + expected_source_body_sha256=source_body_sha256(raw_body), + require_image_evidence=image_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. + _logger.exception("read_post_summary.persist failed unexpectedly") + 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,20 +3353,60 @@ 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), ) class ChatRequest(BaseModel): """JSON body for ``POST /api/posts/{post_id}/chat``.""" - question: str + question: str = Field(max_length=4000) + conversation_id: UUID | None = None 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 + question: str = Field(max_length=4000) + conversation_id: UUID | None = None + anchor_post_id: UUID | None = None + + +async def _persist_post_ask_turn( + conn: asyncpg.Connection, + account: CurrentAccount, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: list[str], + cited_post_ids: list[str], +) -> UUID: + """Store one completed post Ask turn; missing conversations stay 404. + + Re-authorizes every citation inside the same transaction right before + commit; a citation whose authorization changed since it was gathered + aborts the whole turn with a stable 503 instead of persisting it. + """ + try: + return await persist_post_ask_turn( + conn, + account.user_account_id, + post_id, + conversation_id, + question, + answer_text, + source_post_ids, + cited_post_ids, + can_see_post=lambda row: _can_use_post_for_analysis(account, row), + ) + except PostAskEvidenceChanged as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is unavailable: authorized evidence changed; retry the question", + ) from exc + except PostAskConversationNotFound as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") from exc @app.get("/api/posts/{post_id}/chat") @@ -2560,20 +3447,35 @@ async def chat_about_post( """ question = request.question.strip() if not question: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) async with pool.acquire() as conn: + if request.conversation_id is not None and not await post_ask_conversation_exists( + conn, account.user_account_id, post_id, request.conversation_id + ): + raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") stored = await fetch_persisted_chat(conn, post_id, question) if stored is not None: source_ids = [post_id] source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + stored["answer_text"], + source_ids, + list(stored["cited_post_ids"]), + ) return { "post_id": post_id, "answer_text": stored["answer_text"], "cited_post_ids": stored["cited_post_ids"], "cited_posts": stored["cited_posts"], "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } with use_llm_metadata(post_metadata): client = _post_chat_client() @@ -2583,19 +3485,36 @@ 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. + _logger.exception("chat_about_post failed unexpectedly") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc cited_ids = list(answer.cited_post_ids) + source_ids = [source.post_id for source in sources] async with pool.acquire() as conn: await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + answer.answer_text, + source_ids, + cited_ids, + ) await publish_activity_event( valkey, post_id, @@ -2608,36 +3527,153 @@ async def chat_about_post( "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_post_summaries(sources, cited_ids), - "source_post_ids": [source.post_id for source in sources], + "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } +@app.get("/api/posts/{post_id}/chat/conversations") +async def read_post_chat_conversations( + post_id: str, + 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 this account's Ask conversations on one visible post.""" + await _load_visible_post(post_id, account, pool) + if (before_updated_at is None) != (before_conversation_id is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "before_updated_at and before_conversation_id must be provided together", + ) + async with pool.acquire() as conn: + return await list_post_ask_conversations( + conn, + account.user_account_id, + post_id, + limit=limit, + before_updated_at=before_updated_at, + before_conversation_id=before_conversation_id, + ) + + +@app.get("/api/posts/{post_id}/chat/conversations/{conversation_id}") +async def read_post_chat_conversation( + post_id: str, + 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 post Ask transcript with currently authorized citations.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + conversation = await fetch_post_ask_conversation( + conn, + account.user_account_id, + post_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.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_CONTENT, + "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") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") _require_post_read(account) + if request.anchor_post_id is not None: + await _load_visible_post(str(request.anchor_post_id), account, pool) + 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( status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) + settings = load_settings() 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, + anchor_post_id=str(request.anchor_post_id) if request.anchor_post_id else None, + tepp_client=configured_tepp_client( + settings.tepp_transport_url, + settings.tepp_api_key, + settings.tepp_temporal_context_url, + ), ) if not sources: - return { + response: dict[str, Any] = { "answer_text": "", "cited_post_ids": [], "cited_posts": [], @@ -2645,24 +3681,56 @@ 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. + _logger.exception("ask_agent failed unexpectedly") + 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], + } + 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"], + can_see_post=lambda row: _can_use_post_for_analysis(account, row), + ) + except GlobalAskEvidenceChanged as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable: authorized evidence changed; retry the question", + ) from exc + 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): + """Validated body for setting the current account's post bookmark.""" + bookmarked: bool @@ -2672,10 +3740,11 @@ async def read_post_bookmark( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - await _load_visible_post(post_id, account, pool) + """Report whether the current account bookmarked an authorized post.""" + await _load_visible_post(post_id, account, pool, allow_writing=True) 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, ) @@ -2689,12 +3758,13 @@ async def write_post_bookmark( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - await _load_visible_post(post_id, account, pool) + """Set or clear the current account's bookmark on an authorized post.""" + await _load_visible_post(post_id, account, pool, allow_writing=True) async with pool.acquire() as conn: 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 +3773,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 +3951,26 @@ 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. + _logger.exception("derive_post_commitment failed unexpectedly") + 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 +4181,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 +4189,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 +4221,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/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py index 9300586c4..7745fbfa5 100644 --- a/backend/app/organization_name_resolution_ingestion.py +++ b/backend/app/organization_name_resolution_ingestion.py @@ -4,10 +4,12 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import asyncpg from lineageweave.organization_name_resolution import ( + OrganizationNameResolution, OrganizationNameResolutionClient, resolve_and_verify_organization_name, ) @@ -17,14 +19,22 @@ ) -async def resolve_organization_name( +@dataclass(frozen=True) +class PreparedOrganizationNameResolution: + """Effective name plus an optional verified cache row awaiting apply.""" + + resolved_name: str + resolution: OrganizationNameResolution | None + + +async def prepare_organization_name_resolution( conn: asyncpg.Connection, resolution_client: OrganizationNameResolutionClient, verification_client: RelationVerificationClient, raw_name: str, context_text: str, -) -> str: - """Return the corroborated canonical name, otherwise ``raw_name``. +) -> PreparedOrganizationNameResolution: + """Resolve and verify a name without mutating the shared cache. Synchronous network adapters run in a worker thread so this async ingestion path does not block unrelated requests. @@ -36,10 +46,13 @@ async def resolve_organization_name( ) if cached is not None: if cached["verification_status_code"] == STATUS_CORROBORATED: - return cached["resolved_organization_name"] - return raw_name + return PreparedOrganizationNameResolution( + cached["resolved_organization_name"], + None, + ) + return PreparedOrganizationNameResolution(raw_name, None) if not resolution_client.available: - return raw_name + return PreparedOrganizationNameResolution(raw_name, None) resolution = await asyncio.to_thread( resolve_and_verify_organization_name, @@ -49,7 +62,26 @@ async def resolve_organization_name( verification_client, ) if resolution is None: - return raw_name + return PreparedOrganizationNameResolution(raw_name, None) + + return PreparedOrganizationNameResolution( + ( + resolution.resolved_organization_name + if resolution.verification_status_code == STATUS_CORROBORATED + else raw_name + ), + resolution, + ) + + +async def apply_prepared_organization_name_resolution( + conn: asyncpg.Connection, + prepared: PreparedOrganizationNameResolution, +) -> str: + """Persist a prepared cache row without making provider calls.""" + resolution = prepared.resolution + if resolution is None: + return prepared.resolved_name await conn.execute( """ @@ -68,6 +100,22 @@ async def resolve_organization_name( resolution.verification_status_code, resolution.verification_evidence_url, ) - if resolution.verification_status_code == STATUS_CORROBORATED: - return resolution.resolved_organization_name - return raw_name + return prepared.resolved_name + + +async def resolve_organization_name( + conn: asyncpg.Connection, + resolution_client: OrganizationNameResolutionClient, + verification_client: RelationVerificationClient, + raw_name: str, + context_text: str, +) -> str: + """Prepare provider evidence, then persist and return the effective name.""" + prepared = await prepare_organization_name_resolution( + conn, + resolution_client, + verification_client, + raw_name, + context_text, + ) + return await apply_prepared_organization_name_resolution(conn, prepared) diff --git a/backend/app/post_ask_history.py b/backend/app/post_ask_history.py new file mode 100644 index 000000000..a38489c04 --- /dev/null +++ b/backend/app/post_ask_history.py @@ -0,0 +1,409 @@ +"""Account-owned persistence for Ask conversations on one visible post. + +ADR 0136 reuses the ADR 0126 list/select/new contract with a required +post_id scope. Conversation ids are never Global Ask session ids. +""" + +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 + +from .global_ask_history import conversation_title +from .post_eligibility import SOURCE_POST_READER_ELIGIBILITY_SQL + + +class PostAskConversationNotFound(LookupError): + """The requested conversation is absent, on another post, or another account.""" + + +class PostAskEvidenceChanged(RuntimeError): + """A cited post became unauthorized before the new turn could commit.""" + + +async def conversation_exists( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID, +) -> bool: + """Return whether this account owns the conversation on ``post_id``.""" + return bool( + await conn.fetchval( + """ + select exists( + select 1 + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + ) + """, + conversation_id, + user_account_id, + post_id, + ) + ) + + +async def list_conversations( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + *, + limit: int = 50, + before_updated_at: datetime | None = None, + before_conversation_id: UUID | None = None, +) -> dict[str, Any]: + """Return this account's conversations on ``post_id``, newest first.""" + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select session.post_ask_session_id, + coalesce( + (select left(turn.question_text, 80) + from post_ask_turn turn + where turn.post_ask_session_id = session.post_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 post_ask_session session + left join post_ask_turn turn + on turn.post_ask_session_id = session.post_ask_session_id + where session.user_account_id = $1 + and session.post_id = $2 + and ( + $3 is null + or $4 is null + or session.updated_at < $3 + or (session.updated_at = $3 and session.post_ask_session_id < $4) + ) + group by session.post_ask_session_id, session.updated_at + order by session.updated_at desc, session.post_ask_session_id desc + limit $5 + """, + user_account_id, + post_id, + before_updated_at, + before_conversation_id, + limit + 1, + ) + 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["post_ask_session_id"]), + } + return { + "conversations": [ + { + "conversation_id": str(row["post_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_batch( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinals: list[int], + can_see_post: Callable[[asyncpg.Record], bool], + *, + source: bool, +) -> dict[int, tuple[list[str], dict[str, asyncpg.Record]]]: + """Reauthorize every turn's sources or citations in one query. + + Fetches all `turn_ordinals` at once instead of one query per turn, so a + conversation's query count stays constant regardless of how many turns + it has. Returns each turn's currently-visible post ids and rows, keyed + by turn ordinal; a turn with no visible rows still gets an empty entry. + """ + by_turn: dict[int, tuple[list[str], dict[str, asyncpg.Record]]] = { + ordinal: ([], {}) for ordinal in turn_ordinals + } + if not turn_ordinals: + return by_turn + if source: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.source_post_id::text as post_id, relation.source_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_source relation + join source_post post on post.post_id = relation.source_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.source_ordinal + """, + conversation_id, + turn_ordinals, + ) + else: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.cited_post_id::text as post_id, relation.citation_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.citation_ordinal + """, + conversation_id, + turn_ordinals, + ) + for row in rows: + if not can_see_post(row): + continue + ordinal = int(row["turn_ordinal"]) + post_id = str(row["post_id"]) + ids, id_map = by_turn[ordinal] + ids.append(post_id) + id_map[post_id] = row + return by_turn + + +async def fetch_conversation( + conn: asyncpg.Connection, + user_account_id: str, + post_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: + """Return one owned transcript with currently authorized citations.""" + header = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + """, + conversation_id, + user_account_id, + post_id, + ) + if header is None: + return None + + title_question = await conn.fetchval( + """ + select question_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal + limit 1 + """, + conversation_id, + ) + if before_turn_ordinal is None: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal desc + limit $2 + """, + conversation_id, + turn_limit + 1, + ) + else: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_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() + ordinals = [int(turn["turn_ordinal"]) for turn in turns] + sources_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=True + ) + citations_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=False + ) + exchanges: list[dict[str, Any]] = [] + for turn in turns: + ordinal = int(turn["turn_ordinal"]) + source_ids, _ = sources_by_turn[ordinal] + cited_ids, cited_rows = citations_by_turn[ordinal] + 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_value, "post_title": cited_rows[post_id_value]["post_title"]} + for post_id_value in cited_ids + ], + "source_post_ids": source_ids, + } + ) + title = conversation_title(title_question) if title_question else "New conversation" + return { + "conversation_id": str(header["post_ask_session_id"]), + "title": title, + "exchanges": exchanges, + "older_cursor": str(turns[0]["turn_ordinal"]) if has_older and turns else None, + } + + +async def _ensure_citations_visible( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinal: int, + cited_post_count: int, + can_see_post: Callable[[asyncpg.Record], bool], +) -> None: + """Lock and re-authorize new citations before their transaction commits.""" + rows = await conn.fetch( + """ + select relation.cited_post_id::text as post_id, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = $2 + for share of post + """, + conversation_id, + turn_ordinal, + ) + if len(rows) != cited_post_count or any(not can_see_post(row) for row in rows): + raise PostAskEvidenceChanged + + +async def persist_turn( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: Iterable[str], + cited_post_ids: Iterable[str], + can_see_post: Callable[[asyncpg.Record], bool] | None = None, +) -> UUID: + """Append one completed turn and return the conversation id.""" + source_ids = list(dict.fromkeys(str(post_id_value) for post_id_value in source_post_ids)) + source_set = set(source_ids) + cited_ids = list( + dict.fromkeys(str(post_id_value) for post_id_value in cited_post_ids if str(post_id_value) in source_set) + ) + async with conn.transaction(): + if conversation_id is None: + conversation_id = uuid4() + await conn.execute( + """ + insert into post_ask_session (post_ask_session_id, post_id, user_account_id) + values ($1, $2, $3) + """, + conversation_id, + post_id, + user_account_id, + ) + else: + conversation = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + for update + """, + conversation_id, + user_account_id, + post_id, + ) + if conversation is None: + raise PostAskConversationNotFound + + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from post_ask_turn where post_ask_session_id = $1", + conversation_id, + ) + ) + await conn.execute( + """ + insert into post_ask_turn + (post_ask_session_id, turn_ordinal, question_text, answer_text) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + question, + answer_text, + ) + for source_ordinal, source_post_id in enumerate(source_ids): + await conn.execute( + """ + insert into post_ask_turn_source + (post_ask_session_id, turn_ordinal, source_ordinal, source_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + source_ordinal, + source_post_id, + ) + for citation_ordinal, cited_post_id in enumerate(cited_ids): + await conn.execute( + """ + insert into post_ask_turn_citation + (post_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + citation_ordinal, + cited_post_id, + ) + await conn.execute( + "update post_ask_session set updated_at = now() where post_ask_session_id = $1", + conversation_id, + ) + if can_see_post is not None: + await _ensure_citations_visible( + conn, + conversation_id, + ordinal, + len(cited_ids), + can_see_post, + ) + assert conversation_id is not None + return conversation_id diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..2f1eabecc 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -19,8 +19,10 @@ import asyncio import re +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from datetime import UTC, datetime +from typing import Any import asyncpg @@ -33,6 +35,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 +44,16 @@ normalize_chat_question, ) from lineageweave.post_content_normalization import normalize_post_body +from lineageweave.source_lineage_hints import source_lineage_hint_facts +from lineageweave.tepp_client import ( + TemporalContextEvent, + TemporalContextRequest, + TeppClient, + TeppNotAvailable, +) from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph -from lineageweave.ontology import ontology_annotations +from .post_eligibility import SOURCE_POST_READER_ELIGIBILITY_SQL @dataclass(frozen=True) @@ -155,6 +165,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 +189,29 @@ 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"), + ) + + +def _semantic_event_time(value: object) -> datetime | None: + """Parse one explicit event clue instant; ambiguous/local times stay unavailable.""" + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip()) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(UTC) async def _semantic_facts_for_posts( @@ -191,14 +227,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 +247,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, @@ -221,7 +313,11 @@ async def _semantic_facts_for_posts( return {post_id: tuple(dict.fromkeys(values)) for post_id, values in facts.items()} -async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> LinkedPostIds: +async def find_linked_post_ids( + conn: asyncpg.Connection, + post_id: str, + can_see_post: Callable[[asyncpg.Record], bool], +) -> LinkedPostIds: """Both link kinds for `post_id`, NOT yet ABAC-filtered -- callers must check `can_see_post` on each id before showing or using it as chat context. @@ -252,6 +348,26 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked ) sibling_post_ids = list({str(row["post_id"]) for row in sibling_rows} | {post_id}) + # A hidden sibling's mentions must not seed the entity graph + # load_visible_subgraph walks: an unauthorized post's org/team/customer + # mention could otherwise bridge to an unrelated visible post through + # shared entity membership, fabricating an "indirect" relationship + # whose only real basis is content this account cannot see (the + # sibling itself stays correctly excluded from output either way). + if len(sibling_post_ids) > 1: + # Safe SQL: eligibility predicate is an immutable schema fragment; ids are bound. + visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + "select post_id, visibility_code, corporate_entity_id, " + "author_account_id, source_detail_state_code " + f"from source_post where post_id = any($1::uuid[]) and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')})", + sibling_post_ids, + ) + sibling_post_ids = [ + str(row["post_id"]) for row in visibility_rows if can_see_post(row) + ] + if post_id not in sibling_post_ids: + sibling_post_ids.append(post_id) + edges = await load_visible_subgraph(conn, sibling_post_ids) start = node_key(NODE_POST, post_id) scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) @@ -292,6 +408,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, @@ -313,7 +431,7 @@ async def gather_chat_sources( ) ] - linked = await find_linked_post_ids(conn, post_id) + linked = await find_linked_post_ids(conn, post_id, can_see_post) candidate_ids = [ *sorted(linked.direct), *sorted(linked.indirect), @@ -323,9 +441,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[]) " @@ -374,6 +495,8 @@ async def gather_global_chat_sources( vision_client: ImageContentClient | None = None, *, question: str | None = None, + anchor_post_id: str | None = None, + tepp_client: TeppClient | None = None, limit: int = 4, ) -> list[ChatSourceDocument]: """Assemble a bounded, ABAC-filtered source set for Global Ask. @@ -382,6 +505,7 @@ async def gather_global_chat_sources( needed for a much larger corpus; every selected body still uses the same image normalization and persisted graph evidence as post-scoped chat. """ + authorized_entity_ids = tuple(str(value) for value in authorized_corporate_entity_ids) if limit <= 0: return [] if vision_client is None: @@ -415,9 +539,18 @@ async def gather_global_chat_sources( "무엇", "무엇인가요", "인가요", + "있는", + "앞쪽", + "앞쪽에", + "이벤트", + "이벤트를", + "유관", + "선행", + "이텐트", + "찾아줘", } ) - )[:8] + )[:4] # A post whose title names the exact thing asked about is a far more # specific match than one that only shares a generic term (a common # word, or a hit buried in a 16KB body prefix); weighting every match @@ -425,52 +558,299 @@ 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( - """ + candidate_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with authorized_source_post as ( + select * + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}) + ) select post_id, matched_in from ( (select post_id, created_at, 'title' as matched_in - from source_post + from authorized_source_post where post_title ilike '%' || $1 || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in - from source_post + from authorized_source_post where lower(left(source_post_search_text(post_body), 16384)) like '%' || lower($1) || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in - from source_post + from authorized_source_post where to_tsvector('simple', source_post_search_text(post_body)) @@ plainto_tsquery('simple', $1) limit 32) union all (select post_id, created_at, 'source_field' as matched_in - from source_post + from authorized_source_post where concat_ws(' ', 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) ilike '%' || $1 || '%' limit 32) + union all + (select post.post_id, post.created_at, 'semantic' as matched_in + from authorized_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 authorized_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 authorized_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 authorized_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 authorized_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 authorized_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 authorized_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 """, term, + authorized_entity_ids, ) for row in candidate_rows: post_id = str(row["post_id"]) candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + _MATCH_WEIGHT[row["matched_in"]] candidate_ids = sorted(candidate_scores, key=lambda post_id: candidate_scores[post_id], reverse=True) + # A post opened in the Ask workspace is stronger context than words the + # reader happens to repeat in the question. Reuse persisted event and + # source semantics to retrieve earlier candidates; never manufacture a + # lineage edge from the similarity score. + if anchor_post_id: + prior_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with authorized_source_post as ( + select * + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}) + ), anchor as ( + select post_id, created_at, source_customer_code, source_project_code, + source_company_code, source_process_unit_code, + source_sales_pool_code, source_order_pool_code, + source_sales_order_code + from authorized_source_post + where post_id = $1 + ), anchor_events as ( + select string_agg(event_text, ' ') as event_text + from post_summary_event + where post_id = $1 + ), source_candidates as ( + select post.post_id + from authorized_source_post post + cross join anchor + where post.post_id <> anchor.post_id + and post.created_at < anchor.created_at + and (post.source_customer_code = anchor.source_customer_code + or post.source_project_code = anchor.source_project_code + or post.source_company_code = anchor.source_company_code + or post.source_process_unit_code = anchor.source_process_unit_code + or post.source_sales_pool_code = anchor.source_sales_pool_code + or post.source_order_pool_code = anchor.source_order_pool_code + or post.source_sales_order_code = anchor.source_sales_order_code) + order by post.created_at desc, post.post_id desc + limit 64 + ), event_candidates as ( + select distinct event.post_id + from post_summary_event event + join authorized_source_post post on post.post_id = event.post_id + cross join anchor + cross join anchor_events + where post.post_id <> anchor.post_id + and post.created_at < anchor.created_at + and to_tsvector('simple', event.event_text) + @@ plainto_tsquery('simple', anchor_events.event_text) + limit 128 + ), candidates as ( + select post_id from source_candidates + union + select post_id from event_candidates + ), ranked as ( + select post.post_id, post.created_at, + greatest( + coalesce(( + select max(word_similarity(event.event_text, anchor_events.event_text)) + from post_summary_event event + where event.post_id = post.post_id + ), 0), + 0.85 * (post.source_sales_order_code is not null and post.source_sales_order_code = anchor.source_sales_order_code)::int + + 0.15 * ( + (post.source_customer_code is not null and post.source_customer_code = anchor.source_customer_code)::int + + (post.source_project_code is not null and post.source_project_code = anchor.source_project_code)::int + + (post.source_company_code is not null and post.source_company_code = anchor.source_company_code)::int + + (post.source_process_unit_code is not null and post.source_process_unit_code = anchor.source_process_unit_code)::int + + (post.source_sales_pool_code is not null and post.source_sales_pool_code = anchor.source_sales_pool_code)::int + + (post.source_order_pool_code is not null and post.source_order_pool_code = anchor.source_order_pool_code)::int + + (post.source_sales_order_code is not null and post.source_sales_order_code = anchor.source_sales_order_code)::int + ) + ) as relevance + from anchor + cross join anchor_events + join candidates on true + join authorized_source_post post on post.post_id = candidates.post_id + ) + select post_id, relevance + from ranked + where relevance >= 0.25 + order by relevance desc, created_at desc, post_id desc + limit $3 + """, + anchor_post_id, + authorized_entity_ids, + limit * 4, + ) + candidate_scores[anchor_post_id] = candidate_scores.get(anchor_post_id, 0.0) + 10.0 + for row in prior_rows: + post_id = str(row["post_id"]) + candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + float(row["relevance"]) + candidate_ids = sorted(candidate_scores, key=lambda post_id: candidate_scores[post_id], reverse=True) + + # Discover new post ids through normalized KG evidence before asking + # the visible-subgraph walker to rank them. Only ontology-declared + # relations cross from the graph projection into retrieval. + kg_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + with anchor_nodes as ( + select edge.source_node_type_code as node_type_code, + edge.source_node_id as node_id + from knowledge_graph_edge edge + join knowledge_graph_edge_evidence evidence + using (knowledge_graph_edge_id) + where evidence.evidence_post_id = $1 + and not (edge.source_node_type_code = 'node_post' + and edge.source_node_id = $1) + union + select edge.target_node_type_code, edge.target_node_id + from knowledge_graph_edge edge + join knowledge_graph_edge_evidence evidence + using (knowledge_graph_edge_id) + where evidence.evidence_post_id = $1 + and not (edge.target_node_type_code = 'node_post' + and edge.target_node_id = $1) + ), related_edges as ( + select edge.knowledge_graph_edge_id, edge.edge_type_code, + edge.edge_weight + from anchor_nodes node + join knowledge_graph_edge edge + on edge.source_node_type_code = node.node_type_code + and edge.source_node_id = node.node_id + union + select edge.knowledge_graph_edge_id, edge.edge_type_code, + edge.edge_weight + from anchor_nodes node + join knowledge_graph_edge edge + on edge.target_node_type_code = node.node_type_code + and edge.target_node_id = node.node_id + ), related as ( + select distinct evidence.evidence_post_id as post_id, + edge.edge_type_code, edge.edge_weight + from related_edges edge + join knowledge_graph_edge_evidence evidence + using (knowledge_graph_edge_id) + ) + select related.post_id, related.edge_type_code, related.edge_weight + from related + join source_post post on post.post_id = related.post_id + join source_post anchor on anchor.post_id = $1 + where related.post_id <> $1 + and post.created_at < anchor.created_at + and (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='post')}) + order by related.edge_weight desc, post.created_at desc, related.post_id + limit $3 + """, + anchor_post_id, + authorized_entity_ids, + limit * 4, + ) + kg_discovered_ids = list( + dict.fromkeys( + str(row["post_id"]) + for row in kg_rows + if ontology_annotations(str(row["edge_type_code"])).get("ontology_iri") + ) + ) + for post_id in kg_discovered_ids: + candidate_scores.setdefault(post_id, 0.0) + candidate_ids = list( + dict.fromkeys([anchor_post_id, *kg_discovered_ids, *candidate_ids]) + ) + # A keyword match only proves one post's text is relevant -- the # account asking almost always wants to know what happened before and # after that event too, not just this one snapshot. Expand the single @@ -479,8 +859,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_anchor_id = anchor_post_id or (candidate_ids[0] if candidate_ids else None) + if kg_anchor_id: + kg_edges = await load_visible_subgraph(conn, candidate_ids[: limit * 4]) + 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 + 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 + lineage_anchor_id = anchor_post_id or (candidate_ids[0] if candidate_ids else None) if lineage_anchor_id: lineage_rows = await conn.fetch( "select child_post_id as other_id from post_lineage_edge where parent_post_id = $1 " @@ -501,26 +900,109 @@ async def gather_global_chat_sources( candidate_ids = [] lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) - rows = await conn.fetch( - """ + # Safe SQL: eligibility is a closed schema fragment; authorization, IDs, and limit are bound. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" 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 + source_project_code, source_project_name, created_at, updated_at, + ( + select min(clue.normalized_value_text) + from post_summary_event_clue clue + where clue.post_id = source_post.post_id + and clue.clue_type_code = 'clue_time' + and clue.assertion_code = 'assertion_affirmed' + and nullif(btrim(clue.normalized_value_text), '') is not null + having count(*) = 1 + ) as semantic_event_time, + ( + select result.computed_at + from post_summary_result result + where result.post_id = source_post.post_id + ) as semantic_event_available_at from source_post - where visibility_code = 'public' - or corporate_entity_id::text = any($1::text[]) + where (visibility_code = 'public' + or corporate_entity_id::text = any($1::text[])) + and ({SOURCE_POST_READER_ELIGIBILITY_SQL.format(alias='source_post')}) + and post_id = any($2::uuid[]) order by array_position($2::uuid[], post_id) nulls last, created_at desc, post_id desc limit $3 """, - list(authorized_corporate_entity_ids), + authorized_entity_ids, candidate_ids, limit, ) visible_rows = [row for row in rows if can_see_post(row)][:limit] + tepp_prior_ids: frozenset[str] = frozenset() + semantic_event_times = { + str(row["post_id"]): _semantic_event_time(row.get("semantic_event_time")) + for row in visible_rows + } + if ( + tepp_client is not None + and anchor_post_id + and len(visible_rows) > 1 + and all(row["author_account_id"] is not None for row in visible_rows) + ): + temporal_events: list[TemporalContextEvent] = [] + for row in visible_rows: + post_id = str(row["post_id"]) + semantic_time = semantic_event_times[post_id] + semantic_available_at = row.get("semantic_event_available_at") + is_semantic_event = semantic_time is not None and semantic_available_at is not None + event_time = semantic_time if is_semantic_event else row["created_at"] + available_time = ( + semantic_available_at if is_semantic_event else row["created_at"] + ) + temporal_events.append( + TemporalContextEvent( + event_id=f"{'semantic-event' if is_semantic_event else 'post-recorded'}:{post_id}", + source_post_id=post_id, + event_type_code="semantic_event" if is_semantic_event else "post_recorded", + event_label=( + "Persisted semantic event" + if is_semantic_event + else "Source post recorded" + ), + event_time=event_time.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + available_time=available_time.astimezone(UTC).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + project_reference=None, + actor_references=(str(row["author_account_id"]),), + ) + ) + request = TemporalContextRequest( + knowledge_cutoff=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + subject_post_id=anchor_post_id, + events=tuple(temporal_events), + ) + try: + temporal = await asyncio.to_thread(tepp_client.temporal_context, request) + except (TeppNotAvailable, OSError, TypeError, ValueError): + temporal = None + timeline = temporal["timeline_events"] if temporal else None + if timeline: + ordinal = { + str(item["source_post_id"]): int(item["sequence_ordinal"]) + for item in timeline + if isinstance(item, dict) + and isinstance(item.get("source_post_id"), str) + and isinstance(item.get("sequence_ordinal"), int) + } + if len(ordinal) == len(visible_rows) and anchor_post_id in ordinal: + anchor_ordinal = ordinal[anchor_post_id] + visible_rows.sort(key=lambda row: (str(row["post_id"]) != anchor_post_id, ordinal[str(row["post_id"])])) + tepp_prior_ids = frozenset( + post_id for post_id, value in ordinal.items() if value < anchor_ordinal + ) visible_ids = [str(row["post_id"]) for row in visible_rows] anchor_is_visible = lineage_anchor_id in visible_ids semantic_facts = await _semantic_facts_for_posts(conn, visible_ids) @@ -539,6 +1021,11 @@ async def gather_global_chat_sources( if post_id in lineage_neighbor_id_set and anchor_is_visible else () ) + tepp_fact = ( + ("TEPP temporal context: before the starting post; association_not_causal",) + if post_id in tepp_prior_ids + else () + ) sources.append( ChatSourceDocument( post_id, @@ -547,7 +1034,8 @@ async def gather_global_chat_sources( graph_facts=graph_facts if index == 0 else (), evidence_facts=_source_hint_facts(row) + semantic_facts.get(post_id, ()) - + lineage_fact, + + lineage_fact + + tepp_fact, ) ) return sources diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index dae640240..ed15458a5 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -3,13 +3,15 @@ from __future__ import annotations import hashlib -from datetime import timedelta from dataclasses import dataclass +from datetime import timedelta from typing import Any 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" @@ -23,6 +25,8 @@ @dataclass(frozen=True) class PostContentJobRequest: + """One queued or running post-content ingestion job.""" + post_id: str source_body_sha256: str status_code: str @@ -35,6 +39,7 @@ def source_body_sha256(body: str) -> str: def post_content_api_status(status_code: str | None, *, content_present: bool) -> str: + """Map an internal ingestion state to the reader-facing API status.""" if status_code in _ACTIVE: return "processing" if status_code == FAILED: @@ -44,6 +49,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, @@ -88,6 +105,24 @@ async def post_content_is_complete( ) ) ) + 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' + ) + ) + ) and ( not $3::boolean or not exists( @@ -111,6 +146,102 @@ async def post_content_is_complete( ) +async def post_content_summary_is_ready( + conn: asyncpg.Connection, + post_id: str, + source_body_digest: str, +) -> bool: + """Require current-body success and complete persisted VISION evidence.""" + return bool( + await conn.fetchval( + """ + select exists( + select 1 + from post_content_ingestion_job job + where job.post_id = $1 + and job.source_body_sha256 = $2 + and job.status_code = $3 + ) + and 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, + source_body_digest, + SUCCEEDED, + ) + ) + + +async def fetch_post_summary_source( + conn: asyncpg.Connection, + post_id: str, +) -> str | None: + """Return ordered semantic units plus persisted region VISION evidence.""" + rows = await conn.fetch( + """ + select unit.unit_index, unit.unit_text, + region.region_index, region.extracted_text, region.image_caption + from post_content_unit unit + left join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + left join post_content_image_region region + on region.post_content_image_id = image.post_content_image_id + where unit.post_id = $1 + order by unit.unit_index, region.region_index + """, + post_id, + ) + source_parts: list[str] = [] + previous_unit_index: int | None = None + for row in rows: + unit_index = int(row["unit_index"]) + if unit_index != previous_unit_index: + unit_text = row["unit_text"] + if isinstance(unit_text, str) and unit_text.strip(): + source_parts.append(unit_text.strip()) + previous_unit_index = unit_index + region_index = row["region_index"] + if region_index is None: + continue + region_evidence = [ + value.strip() + for value in (row["image_caption"], row["extracted_text"]) + if isinstance(value, str) and value.strip() + ] + if region_evidence: + source_parts.append( + f"[image region {int(region_index)}]\n" + "\n".join(region_evidence) + ) + source = "\n\n".join(source_parts) + 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} @@ -178,12 +309,14 @@ async def transition_post_content_job( failure_code: str | None = None, detail_text: str | None = None, expected_attempt_count: int | None = None, + expected_source_body_sha256: str | None = None, + expected_status_code: str | None = None, ) -> bool: """Update one job attempt and append its lifecycle event atomically. - ``expected_attempt_count`` fences stale workers after lease recovery. A - late completion from an older attempt must not overwrite the newer - attempt's status or append a misleading lifecycle event. + The optional claim fields fence stale workers after lease recovery. A late + completion from an older attempt must not overwrite the newer attempt's + status or append a misleading lifecycle event. """ updated = await conn.execute( """ @@ -201,6 +334,8 @@ async def transition_post_content_job( last_error_detail = $8 where post_id = $1 and ($9::integer is null or attempt_count = $9) + and ($10::text is null or source_body_sha256 = $10) + and ($11::text is null or status_code = $11) """, post_id, status_code, @@ -211,6 +346,8 @@ async def transition_post_content_job( failure_code, detail_text, expected_attempt_count, + expected_source_body_sha256, + expected_status_code, ) if not updated.endswith(" 1"): return False @@ -243,7 +380,7 @@ async def ensure_post_content_job( post_id, ) if row is None: - initial_status = SUCCEEDED if content_complete else QUEUED + initial_status = QUEUED await conn.execute( """ insert into post_content_ingestion_job @@ -273,7 +410,6 @@ async def ensure_post_content_job( update post_content_ingestion_job set source_body_sha256 = $2, status_code = $3, - attempt_count = 0, queued_at = now(), started_at = null, completed_at = null, @@ -321,7 +457,6 @@ async def requeue_failed_post_content_job( update post_content_ingestion_job set source_body_sha256 = $2, status_code = $3, - attempt_count = 0, queued_at = now(), started_at = null, completed_at = null, @@ -352,6 +487,13 @@ async def record_post_content_backfill_success( ) -> PostContentJobRequest: """Synchronize a completed operator backfill with the durable job ledger.""" digest = source_body_sha256(body) + source_row = await conn.fetchrow( + "select post_body from source_post where post_id = $1 for update", + post_id, + ) + current_body = None if source_row is None else source_row["post_body"] + if not isinstance(current_body, str) or source_body_sha256(current_body) != digest: + raise ValueError("source body changed during post-content backfill") row = await conn.fetchrow( """ select status_code @@ -410,21 +552,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.last_error_code is null + 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..1e70c5da1 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -11,13 +11,6 @@ import asyncpg import redis.asyncio as redis -from lineageweave.embedding_client import EmbeddingClient -from lineageweave.image_content import ImageContentClient -from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata -from lineageweave.post_content_normalization import normalize_post_body -from lineageweave.post_content_persistence import persist_post_content -from lineageweave.post_structure import PostStructureClient - from backend.app.config import load_settings from backend.app.post_content_queue import ( FAILED, @@ -29,14 +22,23 @@ STALE_RUNNING_INTERVAL, SUCCEEDED, post_content_is_complete, - transition_post_content_job, republish_queued_post_content_jobs, + source_body_sha256, + transition_post_content_job, ) +from lineageweave.embedding_client import EmbeddingClient +from lineageweave.image_content import ImageContentClient +from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata +from lineageweave.post_content_normalization import normalize_post_body +from lineageweave.post_content_persistence import persist_post_content +from lineageweave.post_structure import PostStructureClient _logger = logging.getLogger(__name__) _RECOVERY_INTERVAL_SECONDS = 30.0 +_WORKER_RESTART_DELAY_SECONDS = 1.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: @@ -52,32 +54,78 @@ async def _claim_job( *, embedding_model_code: str, require_structure: bool = False, -) -> asyncpg.Record | None: +) -> dict[str, object] | None: 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, + source_row = await conn.fetchrow( + """ + select p.* + from source_post p + where p.post_id = $1::uuid + and coalesce(upper(btrim(p.source_detail_state_code)), '') <> 'W' + for update of p + """, + post_id, + ) + if source_row is None: + return None + if str(source_row.get("source_detail_state_code") or "").strip().upper() == "W": + return None + raw_body = source_row["post_body"] + if not isinstance(raw_body, str): + return None + if source_body_sha256(raw_body) != source_body_digest: + return None + job_row = await conn.fetchrow( + """ + select j.source_body_sha256 as job_source_body_sha256, j.status_code as job_status_code, j.attempt_count as job_attempt_count, + ( + select count(*) + from post_content_ingestion_job_status_event event + where event.post_id = j.post_id + and event.status_code = $3 + and event.status_ordinal > coalesce( + ( + select max(boundary.status_ordinal) + from post_content_ingestion_job_status_event boundary + where boundary.post_id = j.post_id + and boundary.status_code = $4 + and boundary.failure_code is null + ), + -1 + ) + ) as job_cycle_attempt_count, j.started_at as job_started_at, j.queued_at as job_queued_at from post_content_ingestion_job j - join source_post p on p.post_id = j.post_id where j.post_id = $1::uuid and j.source_body_sha256 = $2 - for update of j, p + for update """, post_id, source_body_digest, + RUNNING, + QUEUED, ) - if row is None: + if job_row is None: return None + row = dict(source_row) + row.update(dict(job_row)) status_code = str(row["job_status_code"]) - attempt_count = int(row["job_attempt_count"]) + cycle_attempt_count = int(row["job_cycle_attempt_count"]) if status_code == FAILED: return None - if status_code == RUNNING and attempt_count >= POST_CONTENT_MAX_ATTEMPTS: + if status_code == RUNNING and row["job_started_at"] is not None: + stale = await conn.fetchval( + "select now() - $1::timestamptz > $2::interval", + row["job_started_at"], + STALE_RUNNING_INTERVAL, + ) + if not stale: + return None + if status_code == RUNNING and cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS: await transition_post_content_job( conn, post_id, @@ -86,7 +134,7 @@ async def _claim_job( detail_text="post-content ingestion attempt limit was already reached", ) return None - if status_code == QUEUED and attempt_count >= POST_CONTENT_MAX_ATTEMPTS: + if status_code == QUEUED and cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS: await transition_post_content_job( conn, post_id, @@ -95,9 +143,9 @@ async def _claim_job( detail_text="post-content ingestion attempt limit was already reached", ) return None - if status_code == QUEUED and attempt_count > 0: + if status_code == QUEUED and cycle_attempt_count > 0: retry_ready = await conn.fetchval( - "select now() >= $1 + $2::interval", + "select now() >= $1::timestamptz + $2::interval", row["job_queued_at"], POST_CONTENT_RETRY_INTERVAL, ) @@ -112,24 +160,67 @@ async def _claim_job( ) if content_complete: return None - if status_code == RUNNING and row["job_started_at"] is not None: - stale = await conn.fetchval( - "select now() - $1 > $2::interval", - row["job_started_at"], - STALE_RUNNING_INTERVAL, + claimed_attempt_count = int( + await conn.fetchval( + """ + update post_content_ingestion_job + set attempt_count = attempt_count + 1 + where post_id = $1 + and source_body_sha256 = $2 + returning attempt_count + """, + post_id, + source_body_digest, ) - if not stale: - return None - await conn.execute( - """ - update post_content_ingestion_job - set attempt_count = attempt_count + 1 - where post_id = $1 - """, + ) + await transition_post_content_job( + conn, post_id, + RUNNING, + expected_attempt_count=claimed_attempt_count, + expected_source_body_sha256=source_body_digest, ) - await transition_post_content_job(conn, post_id, RUNNING) - return row + claimed = dict(row) + claimed["job_attempt_count"] = claimed_attempt_count + claimed["job_cycle_attempt_count"] = cycle_attempt_count + 1 + return claimed + + +async def _lock_current_claim( + conn: asyncpg.Connection, + post_id: str, + *, + expected_source_body_sha256: str, + expected_attempt_count: int, +) -> bool: + """Lock and validate the immutable worker claim against current source.""" + source_row = await conn.fetchrow( + "select post_body from source_post where post_id = $1::uuid for update", + post_id, + ) + if source_row is None: + return False + raw_body = source_row["post_body"] + if not isinstance(raw_body, str) or ( + source_body_sha256(raw_body) != expected_source_body_sha256 + ): + return False + job_row = await conn.fetchrow( + """ + select status_code + from post_content_ingestion_job + where post_id = $1::uuid + and source_body_sha256 = $2 + and attempt_count = $3 + and status_code = $4 + for update + """, + post_id, + expected_source_body_sha256, + expected_attempt_count, + RUNNING, + ) + return job_row is not None async def _finish_job( @@ -137,21 +228,30 @@ async def _finish_job( post_id: str, status_code: str, *, + expected_source_body_sha256: str, expected_attempt_count: int, failure_code: str | None = None, detail_text: str | None = None, ) -> None: """Finish only the attempt that actually owns the running lease.""" - async with pool.acquire() as conn: - async with conn.transaction(): - await transition_post_content_job( - conn, - post_id, - status_code, - expected_attempt_count=expected_attempt_count, - failure_code=failure_code, - detail_text=detail_text, - ) + async with pool.acquire() as conn, conn.transaction(): + if not await _lock_current_claim( + conn, + post_id, + expected_source_body_sha256=expected_source_body_sha256, + expected_attempt_count=expected_attempt_count, + ): + return + await transition_post_content_job( + conn, + post_id, + status_code, + expected_attempt_count=expected_attempt_count, + expected_source_body_sha256=expected_source_body_sha256, + expected_status_code=RUNNING, + failure_code=failure_code, + detail_text=detail_text, + ) async def _finish_failed_job( @@ -160,7 +260,9 @@ async def _finish_failed_job( *, failure_code: str, detail_text: str, + expected_source_body_sha256: str, expected_attempt_count: int, + cycle_attempt_count: int, ) -> None: """Schedule one retry, or persist a terminal failure for this attempt. @@ -168,37 +270,29 @@ async def _finish_failed_job( a worker whose lease was reclaimed cannot retry or terminally fail a newer attempt. """ - async with pool.acquire() as conn: - async with conn.transaction(): - attempt_count = int( - await conn.fetchval( - """ - select attempt_count - from post_content_ingestion_job - where post_id = $1 - and status_code = $2 - for update - """, - post_id, - RUNNING, - ) - or -1 - ) - if attempt_count != expected_attempt_count: - return - terminal = attempt_count >= POST_CONTENT_MAX_ATTEMPTS - await transition_post_content_job( - conn, - post_id, - FAILED if terminal else QUEUED, - failure_code=_ATTEMPT_LIMIT_FAILURE_CODE if terminal else failure_code, - detail_text=( - "post-content ingestion reached its bounded retry limit" - if terminal - else detail_text - ), - expected_attempt_count=expected_attempt_count, - ) + async with pool.acquire() as conn, conn.transaction(): + if not await _lock_current_claim( + conn, + post_id, + expected_source_body_sha256=expected_source_body_sha256, + expected_attempt_count=expected_attempt_count, + ): + return + terminal = cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS + await transition_post_content_job( + conn, + post_id, + FAILED if terminal else QUEUED, + failure_code=_ATTEMPT_LIMIT_FAILURE_CODE if terminal else failure_code, + detail_text=( + "post-content ingestion reached its bounded retry limit" + if terminal + else detail_text + ), + expected_attempt_count=expected_attempt_count, + expected_source_body_sha256=expected_source_body_sha256, + expected_status_code=RUNNING, + ) async def process_post_content_job( @@ -210,6 +304,7 @@ async def process_post_content_job( embedding_factory: Callable[[], EmbeddingClient], structure_factory: Callable[[], PostStructureClient], ) -> None: + """Claim, process, and durably record one post-content ingestion job.""" settings = load_settings() row = await _claim_job( pool, @@ -220,7 +315,8 @@ async def process_post_content_job( ) if row is None: return - attempt_count = int(row["job_attempt_count"]) + 1 + attempt_count = int(row["job_attempt_count"]) + cycle_attempt_count = int(row["job_cycle_attempt_count"]) try: raw_body = row["post_body"] if not isinstance(raw_body, str) or not raw_body.strip(): @@ -232,7 +328,7 @@ async def process_post_content_job( vision_client = vision_factory() normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client) async with pool.acquire() as conn: - await persist_post_content( + persisted_count = await persist_post_content( conn, post_id, raw_body, @@ -242,7 +338,11 @@ async def process_post_content_job( normalized_result=normalized, structure_client=structure_client, post_title=str(row["post_title"]), + expected_source_body_sha256=source_body_digest, + expected_attempt_count=attempt_count, ) + if persisted_count is None: + return async with pool.acquire() as conn: complete = await post_content_is_complete( conn, @@ -258,20 +358,30 @@ async def process_post_content_job( post_id, failure_code=_INCOMPLETE_FAILURE_CODE, detail_text="post-content providers did not produce complete persisted evidence", + expected_source_body_sha256=source_body_digest, expected_attempt_count=attempt_count, + cycle_attempt_count=cycle_attempt_count, ) return - except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry. + except Exception: _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_source_body_sha256=source_body_digest, expected_attempt_count=attempt_count, + cycle_attempt_count=cycle_attempt_count, ) return - await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count) + await _finish_job( + pool, + post_id, + SUCCEEDED, + expected_source_body_sha256=source_body_digest, + expected_attempt_count=attempt_count, + ) async def consume_post_content_stream_once( @@ -283,6 +393,7 @@ async def consume_post_content_stream_once( embedding_factory: Callable[[], EmbeddingClient], structure_factory: Callable[[], PostStructureClient], ) -> str: + """Process one Valkey stream batch and return its last-seen cursor.""" batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000) for _stream_name, entries in batches: for entry_id, fields in entries: @@ -329,3 +440,33 @@ async def run_post_content_worker( embedding_factory=embedding_factory, structure_factory=structure_factory, ) + + +async def run_post_content_worker_supervised( + client: redis.Redis, + pool: asyncpg.Pool, + *, + vision_factory: Callable[[], ImageContentClient], + embedding_factory: Callable[[], EmbeddingClient], + structure_factory: Callable[[], PostStructureClient], +) -> None: + """Keep the durable worker alive after an unexpected iteration error. + + Cancellation remains a shutdown signal. Other exceptions are logged and + the worker is restarted so a transient Valkey, database, or provider + error cannot silently disable recovery for the rest of the process. + """ + while True: + try: + await run_post_content_worker( + client, + pool, + vision_factory=vision_factory, + embedding_factory=embedding_factory, + structure_factory=structure_factory, + ) + except asyncio.CancelledError: + raise + except Exception: + _logger.exception("post-content worker crashed; restarting") + await asyncio.sleep(_WORKER_RESTART_DELAY_SECONDS) diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 41473d9da..08451f52f 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,34 +1,82 @@ -"""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_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_stage_code", + "source_detail_state_code", "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: + """Build SQL that detects any nonblank source-context field on an alias.""" 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: + """Build SQL that requires every source-context field on an alias to be blank.""" 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 +91,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_evaluation_ingestion.py b/backend/app/post_evaluation_ingestion.py index c290a7bd3..d783a4beb 100644 --- a/backend/app/post_evaluation_ingestion.py +++ b/backend/app/post_evaluation_ingestion.py @@ -15,6 +15,8 @@ @dataclass(frozen=True) class PersistedEvaluation: + """One persisted per-criterion evaluation response for a post.""" + criterion_code: str criterion_label: str | None response_category: int @@ -50,6 +52,7 @@ async def ingest_post_evaluation( async def fetch_post_evaluation(conn: asyncpg.Connection, post_id: str) -> list[PersistedEvaluation]: + """Load a post's persisted evaluations ordered by criterion code.""" rows = await conn.fetch( """ select e.criterion_code, v.lookup_label as criterion_label, diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 7403185ed..4dda3d31f 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -28,6 +28,7 @@ from __future__ import annotations +import hashlib from typing import Any import asyncpg @@ -37,37 +38,58 @@ 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, RelationVerificationClient, ) -from .corporate_entity_ingestion import get_or_create_corporate_entity -from .keyman_ingestion import _load_corporate_entity_candidates +from .corporate_entity_ingestion import ( + PreparedCorporateEntityResolution, + apply_prepared_corporate_entity_resolution, + prepare_corporate_entity_resolution, +) +from .keyman_ingestion import ( + PreparedAffiliatedOrganization, + _load_corporate_entity_candidates, + apply_prepared_affiliated_organization, + prepare_affiliated_organization, +) from .knowledge_graph import persist_edges_for_post +from .post_content_queue import SUCCEEDED, fetch_post_summary_source, source_body_sha256 +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,10 +99,66 @@ def require_summary_source_body(body: str | None) -> str: return body +def summary_input_sha256(summary_input: str) -> str: + """Bind a summary row to its exact normalized source/evidence text.""" + return hashlib.sha256(summary_input.encode("utf-8")).hexdigest() + + +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 _lock_current_summary_input( + conn: asyncpg.Connection, + post_id: str, + *, + expected_source_body_sha256: str, + expected_summary_input: str, + require_image_evidence: bool, +) -> bool: + """Lock and recheck the exact source and persisted summary evidence.""" + row = await conn.fetchrow( + "select post_body from source_post where post_id = $1 for update", + post_id, + ) + if row is None: + return False + current_body = row["post_body"] + if not isinstance(current_body, str): + return False + if source_body_sha256(current_body) != expected_source_body_sha256: + return False + if not require_image_evidence: + return True + job = await conn.fetchrow( + """ + select status_code + from post_content_ingestion_job + where post_id = $1 + and source_body_sha256 = $2 + and status_code = $3 + for update + """, + post_id, + expected_source_body_sha256, + SUCCEEDED, + ) + if job is None: + return False + return await fetch_post_summary_source(conn, post_id) == expected_summary_input + + async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str, *, + summary_input: str | None = None, allow_stale: bool = False, ) -> dict[str, Any] | None: """Return the stored summary payload, or None when none is usable. @@ -89,21 +167,30 @@ 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. + A current row additionally requires an exact normalized-input binding. """ header = await conn.fetchrow( - "select korean_summary, summary_contract_version " + "select korean_summary, summary_contract_version, summary_input_sha256 " "from post_summary_result where post_id = $1", post_id, ) if header is None: return None summary_contract_version = header["summary_contract_version"] - if summary_contract_version != POST_SUMMARY_CONTRACT_VERSION and not allow_stale: + input_matches = bool( + summary_input is not None + and header.get("summary_input_sha256") == summary_input_sha256(summary_input) + ) + summary_is_current = ( + summary_contract_version == POST_SUMMARY_CONTRACT_VERSION and input_matches + ) + if not summary_is_current and not allow_stale: 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 +202,14 @@ 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, + role.catalog_unresolved_reason_code, + role.affiliation_catalog_unresolved_reason_code from post_summary_role role where role.post_id = $1 order by role.actor_name @@ -128,7 +218,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 +240,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 +317,24 @@ 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 + ), + "catalog_unresolved_reason_code": ( + row["catalog_unresolved_reason_code"] if catalog_node_id is None else None + ), + "affiliation_catalog_unresolved_reason_code": ( + row["affiliation_catalog_unresolved_reason_code"] + if row["cataloged_affiliated_corporate_entity_id"] is None + else None + ), **ontology_annotations(row["actor_type_code"]), } ) @@ -178,9 +342,7 @@ async def fetch_persisted_summary( "post_id": post_id, "korean_summary": header["korean_summary"], "summary_status": ( - "current" - if summary_contract_version == POST_SUMMARY_CONTRACT_VERSION - else "stale" + "current" if summary_is_current else "stale" ), "summary_contract_version": summary_contract_version, "key_events": [row["event_text"] for row in events], @@ -188,9 +350,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 +379,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"], } @@ -221,77 +460,173 @@ async def persist_post_summary( post_id: str, summary: PostSummary, *, - post_body: str | None = None, + post_body: str, + expected_source_body_sha256: str, + require_image_evidence: bool = False, + resolution_client: OrganizationNameResolutionClient | None = None, hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None, verification_client: RelationVerificationClient | None = None, ) -> dict[str, Any]: """Replace the stored summary for ``post_id`` and return the public payload. - ``post_body`` is the context an organization-actor hierarchy proposal - is inferred from (ADR 0010); it falls back to the summary's own Korean - text when not given. The pluggable clients default to unavailable Null + ``post_body`` is the exact normalized summary input and the context an + organization-actor hierarchy proposal is inferred from (ADR 0010). The + pluggable clients default to unavailable Null clients, so an organization actor then only resolves against an existing ``corporate_entity``. - Organization inference, verification, and any lock-protected catalog - creation complete before the atomic summary transaction. The catalog is - an idempotent shared identity registry; keeping that enrichment separate - prevents network latency and ``pg_advisory_xact_lock`` from extending the - summary replacement transaction while all post-owned rows still commit or - roll back together. + Provider-only organization proposals are prepared before the + exact-current source/evidence transaction. Catalog writes apply only after + that recheck and share its transaction with summary replacement, so neither + network latency nor stale provider output holds or mutates locked state + (ADR 0114). """ - if post_body is not None: - require_summary_source_body(post_body) + await require_summary_target(conn, post_id) + normalized_summary_input = require_summary_source_body(post_body) + summary_input_digest = summary_input_sha256(normalized_summary_input) 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 + context_text = normalized_summary_input candidates = ( await _load_corporate_entity_candidates(conn) if summary.roles_and_responsibilities else [] ) - resolved_organization_ids: dict[int, str] = {} + prepared_organizations: dict[int, PreparedCorporateEntityResolution] = {} + prepared_affiliations: dict[int, PreparedAffiliatedOrganization] = {} + unavailable_affiliations: dict[int, tuple[str, str | None, str]] = {} for role_index, role in enumerate(summary.roles_and_responsibilities): - if role.actor_type_code != ACTOR_TYPE_ORGANIZATION: + if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor( + role.actor_name + ): continue - corporate_entity_id = await get_or_create_corporate_entity( - conn, - role.actor_name, - context_text, - hierarchy_inference_client, - verification_client, - candidates, - ) - if corporate_entity_id is not None: - resolved_organization_ids[role_index] = corporate_entity_id + if role.actor_type_code == ACTOR_TYPE_ORGANIZATION: + prepared_organizations[role_index] = ( + await prepare_corporate_entity_resolution( + role.actor_name, + context_text, + hierarchy_inference_client, + verification_client, + candidates, + ) + ) + continue + if ( + role.actor_type_code in {ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM} + and role.affiliated_organization_name + ): + try: + prepared_affiliations[role_index] = ( + await prepare_affiliated_organization( + conn, + role.affiliated_organization_name, + context_text, + resolution_client, + verification_client, + hierarchy_inference_client, + candidates, + ) + ) + except (HttpClientError, OSError, TimeoutError, ValueError): + unavailable_affiliations[role_index] = ( + role.affiliated_organization_name, + None, + "reason_no_live_client", + ) + resolved_organization_ids: dict[int, str] = {} + resolved_organization_reasons: dict[int, str] = {} + resolved_affiliation_names: dict[int, str] = {} + resolved_affiliation_ids: dict[int, str] = {} + resolved_affiliation_reasons: dict[int, str] = {} async with conn.transaction(): + input_is_current = await _lock_current_summary_input( + conn, + post_id, + expected_source_body_sha256=expected_source_body_sha256, + expected_summary_input=normalized_summary_input, + require_image_evidence=require_image_evidence, + ) + if not input_is_current: + raise RuntimeError("post summary input is no longer current") + 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 + ): + prepared_affiliation = prepared_affiliations.get(role_index) + if prepared_affiliation is not None: + _, resolved_name, corporate_entity_id, affiliation_reason = ( + await apply_prepared_affiliated_organization( + conn, + prepared_affiliation, + candidates, + ) + ) + else: + resolved_name, corporate_entity_id, affiliation_reason = ( + unavailable_affiliations[role_index] + ) + resolved_affiliation_names[role_index] = resolved_name + if corporate_entity_id is not None: + resolved_affiliation_ids[role_index] = corporate_entity_id + elif affiliation_reason is not None: + resolved_affiliation_reasons[role_index] = affiliation_reason + continue + corporate_entity_id, unresolved_reason = ( + await apply_prepared_corporate_entity_resolution( + conn, + prepared_organizations[role_index], + candidates, + ) + ) + if corporate_entity_id is not None: + resolved_organization_ids[role_index] = corporate_entity_id + elif unresolved_reason is not None: + resolved_organization_reasons[role_index] = unresolved_reason await _replace_summary_projection( conn, post_id, summary, candidates, resolved_organization_ids, + resolved_affiliation_names, + resolved_affiliation_ids, + summary_input_digest, + resolved_organization_reasons, + resolved_affiliation_reasons, ) - - payload = await fetch_persisted_summary(conn, post_id) - if payload is None: - raise RuntimeError("persist_post_summary wrote no row") - return payload + payload = await fetch_persisted_summary( + conn, + post_id, + summary_input=normalized_summary_input, + ) + if payload is None: + raise RuntimeError("persist_post_summary wrote no row") + return payload async def _resolve_existing_cataloged_person_id( conn: asyncpg.Connection, person_name: str -) -> str | None: - """Return the earliest existing catalog person id for ``person_name``. +) -> tuple[str | None, str | None]: + """Return ``(earliest existing catalog person id, unresolved reason)``. Lookup orders by ``created_at``, then ``person_id``. This function does not insert a ``cataloged_person`` row (ADR 0009). A missing - catalog row stays unbound rather than inventing a person. + catalog row stays unbound rather than inventing a person; ADR 0141 + records that absence as ``reason_no_catalog_entry`` -- the only reason + code available here, since this lookup has no live-client dependency + to distinguish further. """ person_row = await conn.fetchrow( "select person_id from cataloged_person " @@ -300,8 +635,8 @@ async def _resolve_existing_cataloged_person_id( person_name, ) if person_row is None: - return None - return str(person_row["person_id"]) + return None, "reason_no_catalog_entry" + return str(person_row["person_id"]), None async def _replace_summary_projection( @@ -310,6 +645,11 @@ 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], + summary_input_digest: str, + resolved_organization_reasons: dict[int, str] | None = None, + resolved_affiliation_reasons: dict[int, str] | None = None, ) -> None: """Write one atomic replacement using pre-resolved shared identities.""" # Summary replacement owns only R&R projections. Keyman mentions remain @@ -321,15 +661,25 @@ 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) await conn.execute( "insert into post_summary_result " - "(post_id, korean_summary, summary_contract_version) values ($1, $2, $3)", + "(post_id, korean_summary, summary_contract_version, summary_input_sha256) " + "values ($1, $2, $3, $4)", post_id, summary.korean_summary, POST_SUMMARY_CONTRACT_VERSION, + summary_input_digest, ) for project in summary.project_mentions: project_key = normalize_project_key(project.canonical_name) @@ -338,13 +688,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 +705,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 +742,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,34 +787,104 @@ 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 + catalog_unresolved_reason_code = None + cataloged_affiliated_corporate_entity_id = resolved_affiliation_ids.get(role_index) + affiliation_catalog_unresolved_reason_code = ( + None + if cataloged_affiliated_corporate_entity_id is not None + else (resolved_affiliation_reasons or {}).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: cataloged_corporate_entity_id = resolved_organization_ids.get( role_index ) + if cataloged_corporate_entity_id is None: + catalog_unresolved_reason_code = (resolved_organization_reasons or {}).get( + role_index + ) elif role.actor_type_code == ACTOR_TYPE_PERSON: - cataloged_person_id = await _resolve_existing_cataloged_person_id( - conn, - role.actor_name, + cataloged_person_id, catalog_unresolved_reason_code = ( + await _resolve_existing_cataloged_person_id( + conn, + role.actor_name, + ) ) 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, " + "catalog_unresolved_reason_code, " + "affiliation_catalog_unresolved_reason_code) values " + "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", post_id, role.actor_name, role.responsibility, @@ -427,6 +893,9 @@ async def _replace_summary_projection( cataloged_team_id, cataloged_corporate_entity_id, cataloged_person_id, + cataloged_affiliated_corporate_entity_id, + catalog_unresolved_reason_code, + affiliation_catalog_unresolved_reason_code, ) if cataloged_team_id is not None: await conn.execute( @@ -435,22 +904,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/app/source_research_ingestion.py b/backend/app/source_research_ingestion.py new file mode 100644 index 000000000..a105b8b68 --- /dev/null +++ b/backend/app/source_research_ingestion.py @@ -0,0 +1,180 @@ +"""Persist ADR 0133 source-reference research with citation lineage.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from dataclasses import dataclass + +import asyncpg + +from lineageweave.source_research import ( + ContextualOrchestratorSourceResearchJudge, + ResearchJudgment, + ResearchLead, + RetrievedPassage, + SearxngSourceResearchClient, + discover_research_leads, +) + +_STATUS_CODES = { + "supported": "research_supported", + "refuted": "research_refuted", + "not_enough_information": "research_not_enough_information", +} + + +@dataclass(frozen=True) +class PersistedResearch: + """One persisted lead and its evidence-bearing judgment.""" + + lead: ResearchLead + passages: tuple[RetrievedPassage, ...] + judgment: ResearchJudgment + + +def decode_research_retrievals(value: object) -> list[dict[str, object]]: + """Normalize asyncpg's JSONB text codec into the API's array contract.""" + decoded = json.loads(value) if isinstance(value, str) else value + if not isinstance(decoded, list) or any(not isinstance(item, dict) for item in decoded): + raise ValueError("source research retrievals must be a JSON array of objects") + return decoded + + +async def research_post_sources( + pool: asyncpg.Pool, + post_id: str, + search_client: SearxngSourceResearchClient, + judge_client: ContextualOrchestratorSourceResearchJudge, +) -> tuple[PersistedResearch, ...]: + """Research persisted semantic units and atomically replace prior results.""" + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + select unit.unit_text as evidence_text, + unit.post_content_unit_id::text as source_content_unit_id, + null::text as source_image_region_id, + unit.unit_index, -1 as region_index + from post_content_unit unit + where unit.post_id = $1 + and unit.unit_kind_code <> 'image' + and btrim(unit.unit_text) <> '' + union all + select concat_ws(' ', image.image_caption, image.extracted_text), + unit.post_content_unit_id::text, + null::text, + unit.unit_index, -1 + from post_content_unit unit + join post_content_image image using (post_content_unit_id) + where unit.post_id = $1 + and image.description_status_code = 'described' + and btrim(concat_ws(' ', image.image_caption, image.extracted_text)) <> '' + and not 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' + ) + union all + select concat_ws(' ', region.image_caption, region.extracted_text), + null::text, + region.post_content_image_region_id::text, + unit.unit_index, region.region_index + from post_content_unit unit + join post_content_image image using (post_content_unit_id) + join post_content_image_region region using (post_content_image_id) + where unit.post_id = $1 + and region.description_status_code = 'described' + and btrim(concat_ws(' ', region.image_caption, region.extracted_text)) <> '' + order by unit_index, region_index + """, + post_id, + ) + leads = discover_research_leads( + [ + ( + str(row["evidence_text"]), + row["source_content_unit_id"], + row["source_image_region_id"], + ) + for row in rows + ] + ) + + def research() -> list[PersistedResearch]: + """Retrieve and judge every discovered lead outside the event loop.""" + researched: list[PersistedResearch] = [] + for lead in leads: + passages = search_client.retrieve(lead) + judgment = judge_client.judge(lead, passages) + researched.append(PersistedResearch(lead, tuple(passages), judgment)) + return researched + + researched = await asyncio.to_thread(research) + async with pool.acquire() as conn, conn.transaction(): + await conn.execute( + "delete from post_source_research_lead where post_id = $1", post_id + ) + for lead_ordinal, item in enumerate(researched): + lead_id = await conn.fetchval( + """ + insert into post_source_research_lead + (post_id, source_content_unit_id, source_image_region_id, + lead_ordinal, lead_type_code, query_text, evidence_text) + values ($1, $2, $3, $4, $5, $6, $7) + returning post_source_research_lead_id + """, + post_id, + item.lead.source_content_unit_id, + item.lead.source_image_region_id, + lead_ordinal, + item.lead.lead_type_code, + item.lead.query_text, + item.lead.evidence_text, + ) + retrieval_ids: dict[str, str] = {} + for retrieval_ordinal, passage in enumerate(item.passages): + retrieval_id = await conn.fetchval( + """ + insert into post_source_research_retrieval + (post_source_research_lead_id, retrieval_ordinal, + evidence_url, evidence_title, passage_text, content_sha256) + values ($1, $2, $3, $4, $5, $6) + returning post_source_research_retrieval_id + """, + lead_id, + retrieval_ordinal, + passage.url, + passage.title, + passage.text, + hashlib.sha256(passage.text.encode("utf-8")).hexdigest(), + ) + retrieval_ids[passage.url] = str(retrieval_id) + judgment_id = await conn.fetchval( + """ + insert into post_source_research_judgment + (post_source_research_lead_id, research_status_code, + sharing_actor_name, rationale_text) + values ($1, $2, $3, $4) + returning post_source_research_judgment_id + """, + lead_id, + _STATUS_CODES[item.judgment.status_code], + item.judgment.sharing_actor_name, + item.judgment.rationale, + ) + for cited_url in item.judgment.cited_urls: + await conn.execute( + """ + insert into post_source_research_citation + (post_source_research_lead_id, + post_source_research_judgment_id, + post_source_research_retrieval_id) + values ($1, $2, $3) + """, + lead_id, + judgment_id, + retrieval_ids[cited_url], + ) + return tuple(researched) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..4af01f8db 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,8 +12,10 @@ from __future__ import annotations +import logging import os import uuid +from contextlib import closing from pathlib import Path import jwt @@ -32,6 +34,11 @@ _VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0") _REALM = "lineageweave-demo" _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" +_GLOBAL_ASK_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0105_global_ask_conversation_history.sql" +) _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" _RECONSTRUCTION_MIGRATION = ( @@ -82,6 +89,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 +123,76 @@ / "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" +) +_TENANT_IDENTITY_METADATA_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0176_tenant_identity_metadata.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" +) +_CATALOG_UNRESOLVED_REASON_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0134_catalog_unresolved_reason.sql" +) +_POST_ASK_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0136_post_ask_conversation_history.sql" +) +_CUSTOMER_IDENTITY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0137_cross_post_customer_identity.sql" +) def _postgres_available() -> bool: @@ -195,6 +275,7 @@ def seeded_db(demo_analyst_token): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_GLOBAL_ASK_HISTORY_MIGRATION.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) @@ -213,6 +294,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 +308,20 @@ 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(_TENANT_IDENTITY_METADATA_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(_CATALOG_UNRESOLVED_REASON_MIGRATION.read_text()) + cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text()) + cur.execute(_CUSTOMER_IDENTITY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -282,6 +378,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 +396,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 +599,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 +662,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 +777,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 +798,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 +810,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 +823,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 +836,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 +847,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 +897,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 +969,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 +1003,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 +1072,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 +1115,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 +1188,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 +1243,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 +1275,137 @@ 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": "LineageWeave", + "systemName": "LineageWeave", + "copyrightYear": 2026, + "copyrightHolder": "LineageWeave", + } + + +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 -def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None: + _grant_post_admin(seeded_db["dsn"]) + allowed = client.patch( + "/api/settings", + json={ + "brandName": "LineageWeave Demo", + "systemName": "LineageWeave Intelligence", + "copyrightYear": 2025, + "copyrightHolder": "LineageWeave Demo", + }, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert allowed.status_code == 200 + assert allowed.json() == { + "brandName": "LineageWeave Demo", + "systemName": "LineageWeave Intelligence", + "copyrightYear": 2025, + "copyrightHolder": "LineageWeave Demo", + } + + with closing(psycopg2.connect(seeded_db["dsn"])) as connection: + with connection, connection.cursor() as cursor: + cursor.execute( + "update tenant_settings set updated_at = '2000-01-01T00:00:00Z' where tenant_settings_id = 1" + ) + + legacy = client.patch( + "/api/settings", + json={"brandName": "Legacy Client Brand"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert legacy.status_code == 200 + assert legacy.json() == { + "brandName": "Legacy Client Brand", + "systemName": "LineageWeave Intelligence", + "copyrightYear": 2025, + "copyrightHolder": "LineageWeave Demo", + } + + with closing(psycopg2.connect(seeded_db["dsn"])) as connection: + with connection, connection.cursor() as cursor: + cursor.execute("select updated_at from tenant_settings where tenant_settings_id = 1") + assert cursor.fetchone()[0].year > 2000 + + confirm = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert confirm.json() == legacy.json() + + +def test_settings_patch_rejects_blank_identity_and_invalid_copyright_year( + client, demo_analyst_token, seeded_db +) -> None: + _grant_post_admin(seeded_db["dsn"]) + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + + blank_system_name = client.patch( + "/api/settings", json={"systemName": " "}, headers=headers + ) + assert blank_system_name.status_code == 422 + + invalid_year = client.patch( + "/api/settings", json={"copyrightYear": 1899}, headers=headers + ) + assert invalid_year.status_code == 422 + + +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 +1431,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 +1442,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 +1501,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() @@ -1209,13 +1532,25 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys assert { "corporate_entity_id", "corporate_entity_code", "entity_name", "entity_level_code", "entity_level_label", "parent_entity_id", + "name_history", } <= set(entity) # Live UI finding (2026-08-19): the corporate entity list rendered # the raw entity_level_code ("company") instead of a human label -- # 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 +1560,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"} @@ -1240,6 +1577,7 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys assert solo["corporate_entity_id"] is None assert body["source_customer_hints"] == [ { + "source_system_code": None, "customer_code": "TEST-CUSTOMER-001", "customer_name": None, "post_count": 1, @@ -1248,10 +1586,25 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys "post_title": "Public post", }], "resolution_status": "hint_only", + "corporate_entity_id": None, + "resolved_entity_name": None, + "customer_identity_judgment_id": None, "hint_trust": "normal", "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,34 +1632,264 @@ 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_relationship_network_excludes_merely_observed_entity_posts( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A private post owned by a merely-*observed* entity (mentioned in a + visible post, but never actually affiliated with the account) must + not leak its counterparty classification into relationship_network. + fetch_relationship_network's corporate_entity_ids parameter is an + ABAC scope, not a display list -- it must be the account's own real + affiliations, not the broader Customer-Master entity_ids list that + also includes observed-only entities.""" + from backend.app import main as main_module + + captured_entity_ids: list[str] = [] + original_fetch = main_module.fetch_relationship_network + + async def capture(conn, corporate_entity_ids): + captured_entity_ids.extend(str(entity_id) for entity_id in corporate_entity_ids) + return await original_fetch(conn, corporate_entity_ids) + + monkeypatch.setattr(main_module, "fetch_relationship_network", capture) + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + # other_corp_id becomes "observed" (not affiliated) via a + # mention on a post the account can already see. + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s)", + (seeded_db["public_post_id"], seeded_db["other_corp_id"]), + ) + # Real source context on both posts -- the private post so it + # is not incidentally excluded by the demo/real-data + # eligibility heuristic (the leak must be defeated by ABAC + # scoping, not by an unrelated exclusion rule), and the + # mentioning public post so IT stays eligible too once any + # post in the fixture has real context (same two-post + # technique the existing giant Customer Master test uses). + cur.execute( + "update source_post set source_customer_code = %s, " + "source_author_code = %s, source_author_name = %s " + "where post_id = %s", + ( + "LEAK-TEST-CUSTOMER", + "LEAK-TEST-AUTHOR", + "Leak Test Author", + seeded_db["other_private_post_id"], + ), + ) + cur.execute( + "update source_post set source_project_code = %s where post_id = %s", + ("LEAK-TEST-PROJECT", seeded_db["public_post_id"]), + ) + cur.execute( + "insert into post_counterparty_entity " + "(post_id, counterparty_entity_name, relationship_type_code, verification_status_code) " + "values (%s, 'Leaked Private Counterparty', 'rel_voc', 'verify_pending')", + (seeded_db["other_private_post_id"],), + ) + finally: + admin_conn.close() + + response = client.get( + "/api/customer-master", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + body = response.json() + + observed = next( + item for item in body["corporate_entities"] if item["entity_name"] == "Other Corp" + ) + assert observed["scope_facets"] == ["observed_organization"] + assert seeded_db["other_corp_id"] not in captured_entity_ids + network = {row["counterparty_entity_name"]: row for row in body["relationship_network"]} + assert "Leaked Private Counterparty" not in network + + +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: - """A Customer Master hint (an opaque source_customer_code with no name) - must resolve to a real corporate_entity only once external search - corroborates the proposed name -- deterministic fake resolution/ - verification clients (not a real LLM or Searxng call) so this is - CI-stable; the point under test is the resolve-then-persist wiring. + """Two posts must pass resolution, Judge, and corroboration before binding. """ - from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationResult + from fast_mlsirm import LLMJudgeResult + + from lineageweave.corporate_hierarchy_inference import HierarchyProposal + from lineageweave.customer_identity_judgment import ( + IDENTITY_CRITERION_CODES, + RENAME_CRITERION_CODES, + ) + from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + RelationVerificationResult, + ) _grant_post_admin(seeded_db["dsn"]) admin_conn = psycopg2.connect(seeded_db["dsn"]) admin_conn.autocommit = True try: with admin_conn.cursor() as cur: - # corporate_entity_id is NOT NULL: a bulk-imported real record - # defaults to whatever entity its author account is affiliated - # with, never to a null "unresolved" sentinel. own_private_post_id - # already sits at that exact default (its author's own - # account_affiliation row) -- the case this endpoint reclaims. cur.execute( - "update source_post set source_customer_code = %s where post_id = %s", - ("HINT-CODE-001", seeded_db["own_private_post_id"]), + "update source_post set source_system_code = %s, source_customer_code = %s " + "where post_id in (%s, %s)", + ( + "synthetic-crm", + "HINT-CODE-001", + seeded_db["own_private_post_id"], + seeded_db["late_own_private_post_id"], + ), ) + cur.execute( + "select post_id::text, corporate_entity_id::text from source_post " + "where post_id in (%s, %s) order by post_id", + (seeded_db["own_private_post_id"], seeded_db["late_own_private_post_id"]), + ) + original_scopes = cur.fetchall() class _FakeResolutionClient: available = True @@ -1324,22 +1907,64 @@ def verify(self, organization_name: str, relationship_label: str) -> RelationVer status_code=STATUS_CORROBORATED, evidence_url="https://example.org/northridge" ) + class _FakeIdentityJudge: + available = True + + @staticmethod + def _result(codes): + return LLMJudgeResult( + score=1.0, + accepted=True, + rationale="synthetic repeated evidence", + criterion_scores={code: 1.0 for code in codes}, + raw_output="{}", + orchestration_mode="auto", + trace_step_count=2, + usage={}, + criterion_categories={code: 4 for code in codes}, + category_count=5, + category_method="cumulative_threshold", + ) + + def judge_identity(self, candidate_name: str, context_text: str): + assert candidate_name == "Northridge Grid" + assert context_text.count("source_customer_code=HINT-CODE-001") == 2 + return self._result(IDENTITY_CRITERION_CODES) + + def judge_rename(self, *_args): + return self._result(RENAME_CRITERION_CODES) + + class _FakeHierarchyClient: + available = True + + def infer(self, organization_name: str, _context_text: str): + assert organization_name == "Northridge Grid" + return HierarchyProposal(level_code="company", parent_name=None) + monkeypatch.setattr( "backend.app.main._customer_hint_resolution_client", lambda: _FakeResolutionClient() ) monkeypatch.setattr( "backend.app.main._relation_verification_client", lambda: _FakeVerificationClient() ) + monkeypatch.setattr( + "backend.app.main._customer_identity_judge_client", lambda: _FakeIdentityJudge() + ) + monkeypatch.setattr( + "backend.app.main._corporate_hierarchy_inference_client", + lambda: _FakeHierarchyClient(), + ) response = client.post( "/api/customer-master/resolve-hint", - json={"hint_code": "HINT-CODE-001"}, + json={"hint_code": "HINT-CODE-001", "source_system_code": "synthetic-crm"}, headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert response.status_code == 200, response.text body = response.json() assert body["entity_name"] == "Northridge Grid" - assert body["linked_post_count"] == 1 + assert body["linked_post_count"] == 2 + assert body["resolution_status"] == "customer_identity_promoted" with admin_conn.cursor() as cur: cur.execute( @@ -1347,12 +1972,45 @@ def verify(self, organization_name: str, relationship_label: str) -> RelationVer (body["corporate_entity_id"],), ) entity_row = cur.fetchone() - assert entity_row == ("Northridge Grid", "HINT-HINT-CODE-001") + assert entity_row[0] == "Northridge Grid" + assert entity_row[1].startswith("AUTO-") cur.execute( - "select corporate_entity_id from source_post where post_id = %s", - (seeded_db["own_private_post_id"],), + "select post_id::text, corporate_entity_id::text from source_post " + "where post_id in (%s, %s) order by post_id", + (seeded_db["own_private_post_id"], seeded_db["late_own_private_post_id"]), + ) + assert cur.fetchall() == original_scopes + cur.execute( + "select count(*) from post_customer_identity_mention " + "where corporate_entity_id = %s", + (body["corporate_entity_id"],), + ) + assert cur.fetchone()[0] == 2 + cur.execute( + "select entity_name, name_role_code from corporate_entity_name_history " + "where corporate_entity_id = %s and observed_to is null", + (body["corporate_entity_id"],), + ) + assert cur.fetchall() == [("Northridge Grid", "entity_name_preferred")] + cur.execute( + "select count(*) from knowledge_graph_edge " + "where edge_type_code = 'edge_customer_identity_observation' " + "and source_node_id = %s", + (body["corporate_entity_id"],), ) - assert str(cur.fetchone()[0]) == body["corporate_entity_id"] + assert cur.fetchone()[0] == 2 + master_response = client.get( + "/api/customer-master", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert master_response.status_code == 200, master_response.text + promoted_entity = next( + entity + for entity in master_response.json()["corporate_entities"] + if entity["corporate_entity_id"] == body["corporate_entity_id"] + ) + assert promoted_entity["scope_facets"] == ["observed_organization"] + assert promoted_entity["name_history"][0]["entity_name"] == "Northridge Grid" finally: admin_conn.close() @@ -1373,16 +2031,133 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, titles = {post["post_title"] for post in payload["posts"]} assert titles == { "Public post", - "Own-corp private post", - "Late own-corp private post", + "Own-corp private post", + "Late own-corp private post", + "Edited own-corp private post", + } + public = next(post for post in payload["posts"] if post["post_title"] == "Public post") + 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", } - public = next(post for post in payload["posts"] if post["post_title"] == "Public post") - assert public["voc_type_label"] == "Voice of Customer" - assert public["visibility_label"] == "Public" - assert {option["code"] for option in payload["voc_type_options"]} == {"voc"} - assert {option["code"] for option in payload["visibility_options"]} == {"public", "private"} - assert next(option for option in payload["visibility_options"] if option["code"] == "public")["label"] == "Public" def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, seeded_db) -> None: @@ -1409,6 +2184,22 @@ def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, see assert invalid_sort.status_code == 422 +def test_post_list_reports_total_count_when_offset_overshoots_the_last_page( + client, demo_analyst_token, seeded_db +) -> None: + """count(*) over() only rides along on rows that survive OFFSET/LIMIT -- + an offset past the last match must still report the real total_count, + not silently fall back to 0 as if nothing matched.""" + overshoot = client.get( + "/api/posts?limit=1&offset=4", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert overshoot.status_code == 200, overshoot.text + assert overshoot.json()["posts"] == [] + assert overshoot.json()["total_count"] == 4 + + def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['public_post_id']}", @@ -1435,7 +2226,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 +2332,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"],), ) @@ -1568,7 +2359,7 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token def test_stale_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 saved summary preserves reader 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"]) @@ -2141,7 +2932,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 +2945,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 +4129,372 @@ 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_global_ask_unexpected_defect_reaches_server_logs( + client, demo_analyst_token, seeded_db, monkeypatch, caplog +) -> None: + """An unexpected programming defect (not a classified provider error) + must still leave a traceback in server-side logs, even though the + customer-facing response stays the same stable 503 (issue #361).""" + from lineageweave.post_chat import ChatSourceDocument + + async def _one_source(*_args, **_kwargs) -> list[ChatSourceDocument]: + return [ChatSourceDocument(post_id=seeded_db["public_post_id"], post_title="post", post_body="body")] + + class _BrokenAskClient: + available = True + + def answer(self, question: str, sources) -> object: + raise AttributeError("simulated unexpected defect") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _BrokenAskClient()) + monkeypatch.setattr("backend.app.main.gather_global_chat_sources", _one_source) + + with caplog.at_level(logging.ERROR, logger="backend.app.main"): + response = client.post( + "/api/ask", + json={"question": "What triggers the unexpected defect path?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "simulated unexpected defect" not in response.text + + matching = [record for record in caplog.records if "ask_agent" in record.message] + assert matching, "unexpected defect must be logged server-side" + assert matching[0].levelno == logging.ERROR + assert matching[0].exc_info is not None + + +def test_global_ask_rolls_back_when_cited_evidence_is_revoked_mid_flight( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A citation that loses authorization between source selection and commit + must not poison the session: no orphaned turn/citation rows, and the same + session can immediately ask a safe follow-up (issue #362).""" + from lineageweave.post_chat import ChatAnswer + + public_post_id = seeded_db["public_post_id"] + + def _revoke_and_answer(question: str, sources) -> ChatAnswer: + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "update source_post set visibility_code = 'private', " + "corporate_entity_id = %s where post_id = %s", + (seeded_db["other_corp_id"], public_post_id), + ) + finally: + admin_conn.close() + return ChatAnswer(answer_text="cites the now-revoked post", cited_post_ids=(public_post_id,)) + + class _RaceConditionAskClient: + available = True + answer = staticmethod(_revoke_and_answer) + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _RaceConditionAskClient()) + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + raced = client.post("/api/ask", json={"question": "What does the public post say?"}, headers=headers) + assert raced.status_code == 503, raced.text + assert raced.json()["detail"] == "Ask Agent is unavailable: authorized evidence changed; retry the question" + + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + # The raced request was this account's first Ask call, so a clean + # rollback means no session, turn, or citation row exists at all. + cur.execute("select count(*) from global_ask_session") + assert cur.fetchone()[0] == 0 + cur.execute("select count(*) from global_ask_turn") + assert cur.fetchone()[0] == 0 + cur.execute("select count(*) from global_ask_turn_citation") + assert cur.fetchone()[0] == 0 + finally: + conn.close() + + class _FakeChatClient: + available = True + + def answer(self, question: str, sources) -> ChatAnswer: + return ChatAnswer(answer_text="a safe follow-up answer", cited_post_ids=()) + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FakeChatClient()) + follow_up = client.post( + "/api/ask", json={"question": "Ask something safe as a follow-up"}, headers=headers + ) + assert follow_up.status_code == 200, follow_up.text + + +def test_post_chat_rolls_back_when_cited_evidence_is_revoked_mid_flight( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """The per-post Ask history path (post_ask_history.py) has the same + persist-time reauthorization as Global Ask (issue #362): a citation + that loses authorization between source selection and commit must + not poison the session or persist a stale citation row.""" + from lineageweave.post_chat import ChatAnswer + + public_post_id = seeded_db["public_post_id"] + + def _revoke_and_answer(question: str, sources) -> ChatAnswer: + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "update source_post set visibility_code = 'private', " + "corporate_entity_id = %s where post_id = %s", + (seeded_db["other_corp_id"], public_post_id), + ) + finally: + admin_conn.close() + return ChatAnswer(answer_text="cites the now-revoked post", cited_post_ids=(public_post_id,)) + + class _RaceConditionAskClient: + available = True + answer = staticmethod(_revoke_and_answer) + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _RaceConditionAskClient()) + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + raced = client.post( + f"/api/posts/{public_post_id}/chat", + json={"question": "What does this post say that no seed already answers?"}, + headers=headers, + ) + assert raced.status_code == 503, raced.text + assert raced.json()["detail"] == "Post chat is unavailable: authorized evidence changed; retry the question" + + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute("select count(*) from post_ask_session") + assert cur.fetchone()[0] == 0 + cur.execute("select count(*) from post_ask_turn") + assert cur.fetchone()[0] == 0 + cur.execute("select count(*) from post_ask_turn_citation") + assert cur.fetchone()[0] == 0 + finally: + conn.close() + + +def test_global_ask_conversation_reauthorizes_each_turn_independently( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Batched per-conversation reauthorization (issue #358) must not leak a + citation across turns or under/over-filter when one turn's post is + revoked mid-conversation.""" + from lineageweave.post_chat import ChatAnswer, ChatSourceDocument + + public_post_id = seeded_db["public_post_id"] + own_post_id = seeded_db["own_private_post_id"] + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + + def _one_source_gatherer(post_id: str): + async def _gather(*_args, **_kwargs) -> list[ChatSourceDocument]: + return [ChatSourceDocument(post_id=post_id, post_title="post", post_body="body")] + + return _gather + + class _CitesPublicPost: + available = True + + def answer(self, question: str, sources) -> ChatAnswer: + return ChatAnswer(answer_text="cites the public post", cited_post_ids=(public_post_id,)) + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _CitesPublicPost()) + monkeypatch.setattr("backend.app.main.gather_global_chat_sources", _one_source_gatherer(public_post_id)) + first = client.post("/api/ask", json={"question": "What does the public post say?"}, headers=headers) + assert first.status_code == 200, first.text + conversation_id = first.json()["conversation_id"] + + class _CitesOwnPost: + available = True + + def answer(self, question: str, sources) -> ChatAnswer: + return ChatAnswer(answer_text="cites the own post", cited_post_ids=(own_post_id,)) + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _CitesOwnPost()) + monkeypatch.setattr("backend.app.main.gather_global_chat_sources", _one_source_gatherer(own_post_id)) + second = client.post( + "/api/ask", + json={"question": "What does the own post say?", "conversation_id": conversation_id}, + headers=headers, + ) + assert second.status_code == 200, second.text + + before = client.get(f"/api/ask/conversations/{conversation_id}", headers=headers) + assert before.status_code == 200, before.text + exchanges = before.json()["exchanges"] + assert len(exchanges) == 2 + assert exchanges[0]["cited_post_ids"] == [public_post_id] + assert exchanges[1]["cited_post_ids"] == [own_post_id] + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "update source_post set visibility_code = 'private', corporate_entity_id = %s " + "where post_id = %s", + (seeded_db["other_corp_id"], public_post_id), + ) + finally: + admin_conn.close() + + after = client.get(f"/api/ask/conversations/{conversation_id}", headers=headers) + assert after.status_code == 200, after.text + exchanges_after = after.json()["exchanges"] + assert exchanges_after[0]["cited_post_ids"] == [], "revoked citation must be dropped" + assert exchanges_after[1]["cited_post_ids"] == [own_post_id], "unrelated turn must be unaffected" + + +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) @@ -3529,6 +4686,60 @@ def test_other_corp_private_post_chat_is_forbidden(client, demo_analyst_token, s assert listed.status_code == 403 +def test_other_corp_private_post_content_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/content", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_other_corp_private_post_knowledge_graph_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/knowledge-graph", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_other_corp_private_post_evaluation_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/evaluation", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_other_corp_private_post_five_w1h_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/five-w1h", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_other_corp_private_post_lineage_endpoint_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + """The per-post lineage endpoint (direct/indirect links), not the + corpus-wide /api/lineage graph -- a separate ABAC-gated route.""" + response = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/lineage", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_other_corp_private_post_bookmark_is_forbidden(client, demo_analyst_token, seeded_db) -> None: + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + read = client.get(f"/api/posts/{seeded_db['other_private_post_id']}/bookmark", headers=headers) + assert read.status_code == 403 + written = client.post( + f"/api/posts/{seeded_db['other_private_post_id']}/bookmark", + json={"bookmarked": True}, + headers=headers, + ) + assert written.status_code == 403 + + @pytest.mark.skipif( not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY), reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run", @@ -3696,7 +4907,21 @@ def test_rebuild_lineage_recovers_the_a100_fork(client, demo_analyst_token, seed rebuild = client.post("/api/lineage/rebuild", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert rebuild.status_code == 200, rebuild.text - assert rebuild.json()["edge_count"] >= 2 + rebuild_body = rebuild.json() + assert rebuild_body["edge_count"] >= 2 + # ADR 0143 aggregate: "Unrelated: annual account review" (rec-006) shares + # group A-100 with five other fixture posts but gets no edge -- a real + # no_relation_found case, not silently absent from the coverage totals. + coverage = rebuild_body["coverage"] + assert coverage["total_posts"] >= rebuild_body["edge_count"] + assert coverage["posts_with_edges"] >= 1 + assert coverage["posts_no_relation_found"] >= 1 + assert ( + coverage["total_posts"] + == coverage["posts_with_edges"] + + coverage["posts_no_relation_found"] + + coverage["posts_no_comparison_group"] + ) graph = client.get("/api/lineage", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert graph.status_code == 200 @@ -3716,6 +4941,17 @@ def test_rebuild_lineage_recovers_the_a100_fork(client, demo_analyst_token, seed assert nodes["Unrelated: annual account review"]["group"] == "A-100" assert nodes["Technical specification review meeting"]["group"] == "B-200" + # ADR 0143: "Unrelated: annual account review" shares group A-100 with + # five other fixture posts but reconstruct gives it zero edges -- a + # checked-and-unrelated fact, not a missing-comparison-group one. + unrelated_id = nodes["Unrelated: annual account review"]["id"] + unrelated_focused = client.get( + f"/api/lineage?post_id={unrelated_id}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert unrelated_focused.status_code == 200 + assert unrelated_focused.json()["isolation_reason"] == "no_relation_found" + per_post = client.get( f"/api/posts/{fork['id']}/lineage", headers={"Authorization": f"Bearer {demo_analyst_token}"}, @@ -4022,6 +5258,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 +5871,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 +5926,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..269e83acc 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} @@ -166,6 +166,7 @@ services: LLM_GATEWAY_EMBEDDING_MODEL: ${LLM_GATEWAY_EMBEDDING_MODEL:-text-embedding-3-large} SEARXNG_BASE_URL: http://searxng:8080 TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} + TEPP_TEMPORAL_CONTEXT_URL: ${TEPP_TEMPORAL_CONTEXT_URL:-http://127.0.0.1:18081/v1/temporal-context} TEPP_API_KEY: ${TEPP_API_KEY:-} CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} RANKWEAVE_DISABLED: ${RANKWEAVE_DISABLED:-} @@ -185,6 +186,13 @@ services: searxng: condition: service_healthy + tepp: + build: + context: ${TEPP_SOURCE_DIR:-../TEPP} + network_mode: service:backend + depends_on: + - backend + frontend: build: context: ./frontend 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..7e065ce27 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_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0176_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docker/searxng/settings.yml b/docker/searxng/settings.yml index d1b363924..1d742f1e8 100644 --- a/docker/searxng/settings.yml +++ b/docker/searxng/settings.yml @@ -24,3 +24,14 @@ server: secret_key: "18d2532b5b9d0d16a5987bb3f203f9814c693df7056252bd5eb1273e7b5c578f" limiter: false image_proxy: false + +# The inherited general engines currently exhaust their anonymous anti-bot +# allowance in this long-running local stack. Bing's built-in SearXNG adapter +# remains responsive and gives the research/verification clients at least one +# real external channel; SearXNG still owns engine selection, not application +# code. +engines: + - name: bing + engine: bing + shortcut: bi + disabled: false 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/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md index bdf234b65..0297175fe 100644 --- a/docs/adr/0003-fast-mlsirm-report-integration.md +++ b/docs/adr/0003-fast-mlsirm-report-integration.md @@ -68,8 +68,11 @@ than one large PR: 1. **Infra slice** (next PR, not this one): add the pinned git dependency, add a Rust toolchain to `backend/Dockerfile`'s build - stage, and prove the import actually works in the built image -- no - product behavior yet. + stage, and prove the import actually works in the built image. Install the + locked third-party dependency layer before copying application source so a + Python-only LineageWeave edit does not rebuild the pinned PyO3 core; the + final sync installs only the changed project into that cached environment. + No product behavior changes in this slice. 2. **Evaluation slice**: a pluggable `PostEvaluationClient` (same `Null`/`ContextualOrchestrator` discipline as every other channel in this repo) that produces a structured judge result per post against @@ -144,6 +147,8 @@ where a synthetic ground truth is available for the test itself. step, increasing image build time -- justified because it is required for a dependency this org's own rules already mandate a Rust psychometrics layer for; not merely an optional convenience. +- The first clean backend image build still compiles `fast-mlsirm`, while + later application-source rebuilds reuse that locked dependency layer. - No product-visible behavior change ships in the infra-first PR; buyer-perceptible payoff lands with slice 2 and slice 3. - If `fast-mlsirm` is missing something this integration needs (e.g. a diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index ae0a1c331..e0e0d69b2 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -49,6 +49,17 @@ Thread-group run list visibility requires at least one ABAC-visible organization chip. - A later public post in a thread group no longer lists a January run that could not have known that post. +- `find_linked_post_ids`'s sibling-expansion step (finding every post that + mentions the same person as the focus post, before handing that set to + `load_visible_subgraph`) now filters siblings by ABAC visibility before + they can seed the entity graph. Previously an unauthorized sibling's + own org/team/customer mention could bridge to an unrelated *visible* + post through shared entity membership, fabricating an "indirect" + relationship whose only real basis was content the account could not + see -- the sibling itself was always correctly excluded from output, + but its influence on which other posts got pulled in was not. Fixed + 2026-08-25; the same missing filter existed at three call sites + (`read_post_lineage`, `gather_chat_sources`, `load_five_w1h_slots`). ## References diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 84fc71635..6e02f54cf 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -25,6 +25,14 @@ missing or the envelope is not a persistable measurement. ## Decision +TEPP's distinct `TemporalContextRequest` v1 contract may also order the +already-authorized, cutoff-eligible evidence supplied to Global Ask. That +request contains opaque post/event/actor references and timestamps, never post +bodies or identity labels. Its `before` relations are temporal associations +only (`association_not_causal`), not a calibrated measurement or causal claim. +If the temporal-context transport is absent, malformed, or incomplete, Ask +keeps the other authorized KG/ontology/semantic channels and drops TEPP. + `POST /api/analysis-runs/{id}/start` accepts Pending TEPP as well as Pending lineage. Period-report stays 422. TEPP start, in the same authorized transaction: 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/0042-source-hints-before-customer-binding.md b/docs/adr/0042-source-hints-before-customer-binding.md index c9f4a7228..775cfff8c 100644 --- a/docs/adr/0042-source-hints-before-customer-binding.md +++ b/docs/adr/0042-source-hints-before-customer-binding.md @@ -25,6 +25,12 @@ belong to a fictional organization. the resulting catalog id and provenance persisted separately. - Keep `기타` and unregistered customer values unresolved until corroborating evidence exists. +- Treat every non-lifecycle `source_*` provenance field, including source + system, source record identity, source stage, and source detail state, as + real-import evidence for Demo-scope and operator eligibility. Metadata-only + imports must not be classified as synthetic merely because customer, + project, or author fields are empty. Draft and deleted markers remain + separate lifecycle gates. - Include only ABAC-visible source-post references in each customer hint so a buyer can open the original post, read its body, and inspect semantic evidence without turning the hint into a customer binding. diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index a93985b40..856970490 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -18,12 +18,16 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post title, criterion short label, leftover-map distance, and the next -action (“Open this post to read the criterion it sat closest to / -farthest from after main effects.”). Clicking the button opens that -post with the same handler as a member row. +action naming both the post and the Post quality criterion +(“Open {post}, then read Post quality criterion {criterion}.”). +Clicking the button opens that post with `focusCriterionCode` and +lands on `#post-quality-criterion-{code}` (`aria-current`). Leftover +clicks do **not** use the member-row Event Lineage landing, so the +named criterion stays visible. After `make seed`, closest and farthest leftover pairs sit above the -member list. Click a pair to open that post. +member list. Click a pair to open that post and read the named +criterion. Missing leftover rows render nothing — never a placeholder pair. A hidden post never appears as a leftover pair. @@ -32,11 +36,14 @@ A hidden post never appears as a leftover pair. The authorized report payload carries `leftover_pairs` next to `members` and `selected_items`. Screen-reader names are -`Open leftover closest pair: {title}` and -`Open leftover farthest pair: {title}` so the control announces the -next action, not only the distance. +`Open leftover closest pair: {title} · {criterion}` and +`Open leftover farthest pair: {title} · {criterion}` so the control +announces the post **and** the criterion, not only the distance. + +Figma File ID: `1Su3lDRmiZdcUs47t1QwIX` (ADR 0118 / 0135). ## Related -Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and -[ADR 0003](0003-fast-mlsirm-report-integration.md). +Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0003](0003-fast-mlsirm-report-integration.md), and +[ADR 0135](0135-analysis-result-kind-exact-next-actions.md). diff --git a/docs/adr/0052-plain-orchestrator-semantic-evidence.md b/docs/adr/0052-plain-orchestrator-semantic-evidence.md index 6a6fd82cb..3046968b3 100644 --- a/docs/adr/0052-plain-orchestrator-semantic-evidence.md +++ b/docs/adr/0052-plain-orchestrator-semantic-evidence.md @@ -14,17 +14,29 @@ healthy while silently discarding roles and project mentions. ## Decision -`ContextualOrchestratorPostSummaryClient` makes two sequential calls to -contextual-orchestrator, both with `mode=route` and no raw LLM or provider call: +`ContextualOrchestratorPostSummaryClient` makes three sequential calls to +contextual-orchestrator, all with `mode=auto` and no raw LLM or provider call: 1. The first call returns a Korean evidence-grounded summary followed by a `KEY EVENTS:` line. -2. The second call returns `ROLES:` and `PROJECTS:` sections. Each role row is +2. The second call returns the plain evidence sections. Loss-sensitive + `FACTS:` appears before the potentially high-cardinality `CLUES:` and + `MEASUREMENTS:` sections. This ordering is normative: a bounded provider + response must not lose source-grounded facts merely because earlier lists + consumed the response budget. Each role row is `actor | responsibility | actor type | affiliation`; a compact three-field role row defaults to the existing `prov_person` contract. Each project row is `name | canonical name | evidence | confidence`; a compact three-field row is `name | evidence | confidence` and derives only the deterministic comparison key with `normalize_project_key`. +3. The third call is a focused `RELATIONS:` extraction. It receives the same + source and weak context hints, but no competing role, clue, measurement, or + fact output. A missing `RELATIONS:` marker is an unavailable channel rather + than a successful empty result; `RELATIONS: NONE` is the explicit supported + negative outcome. It emits every explicit source relation rather than + choosing one representative edge. A named base-to-later-variant chronology + cannot be replaced by another product merely because that product appears + in a nearby enumeration. Structured source fields remain weak, provenance-labelled hints. Values such as `기타`, `미등록고객`, `unknown`, and `other` cannot confirm a customer or @@ -43,6 +55,29 @@ current evidence. The Buyer UI renders the localized ontology label and localized extraction/provenance labels; it never renders the ontology IRI or contextual-orchestrator/storage identifiers as user-facing text. +A reader GET does not synchronously refresh an existing text-only summary from +an older contract. It returns that row immediately with `summary_status=stale`, +while `scripts/backfill_post_summaries.py --post-id ...` remains the explicit, +durable operator path for regeneration. Source-post open and source rendering +never wait for summary, VISION, or embedding work. + +An image-bearing post has a stricter evidence boundary. The summary endpoint +enqueues or observes the durable post-content job and does not call VISION +synchronously. It withholds both current and stale persisted summaries until +the durable job for the current raw-body SHA-256 has status `succeeded` and the +parent image and every persisted visual region have status `described`. +Queued or running evidence is reported as processing; a terminal failure is +reported unavailable until the explicit ADR 0115 retry. Once ready, a current +persisted summary may be returned only when its normalized summary-input SHA-256 +matches the exact ordered persisted semantic-unit, parent-image, and +region-evidence text. A stale or legacy-unbound image-bearing summary is +regenerated. New image-bearing +summaries use only persisted semantic units in document order, including +completed OCR and captions; an unavailable placeholder is never promoted into +summary evidence. Text-only summaries bind the same column to the normalized +source text; a source revision makes the prior row explicitly stale continuity +rather than current evidence. + Ask Agent citations expose the persisted source and semantic facts associated with each cited post through a Buyer-safe projection. Prompt metadata such as ontology IRIs, provider names, extraction identifiers, and storage provenance @@ -53,14 +88,18 @@ for reading the complete body and related evidence. - Semantic roles and projects are no longer silently dropped when the summary call succeeds. -- The channel incurs a second orchestrator request and therefore a bounded - latency/cost increase. +- Source-grounded facts are emitted before larger clue and measurement lists, + and explicit ontology relations have a dedicated bounded response. +- The channel incurs two additional orchestrator requests and therefore a bounded + latency/cost increase on explicit regeneration, not on a stale reader GET. +- Image evidence can delay only the image-bearing summary projection; opening + and reading the source post remains immediate. - Plain line parsing is intentionally narrow; unsupported provider output is rejected rather than promoted into ontology facts. ## Verification -- Unit tests cover both plain calls, compact rows, title-backed evidence, and - rejection of unsupported project evidence. +- Unit tests cover all three plain calls, the loss-sensitive section order, compact + rows, title-backed evidence, and rejection of unsupported project evidence. - Runtime verification uses only the repository's synthetic fixture and the contextual-orchestrator service. 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/0090-global-ask-lineage-timeline-expansion.md b/docs/adr/0090-global-ask-lineage-timeline-expansion.md index da98d9388..673191182 100644 --- a/docs/adr/0090-global-ask-lineage-timeline-expansion.md +++ b/docs/adr/0090-global-ask-lineage-timeline-expansion.md @@ -44,6 +44,11 @@ expansion: timeline neighbor of post_id=...` evidence fact, and only when the anchor post itself is visible -- an expanded neighbor must never cite an anchor id the requesting account cannot see. +- Treats one exact, non-null sales-order code match as sufficient retrieval + evidence for an earlier post. Broader organization, customer, project, PU, + and pool hints remain `hint_only` and need either a second matching hint or + semantic-event similarity; retrieval never promotes either case to a + lineage or ontology assertion. This is the event-centric temporal retrieval problem DyG-RAG frames (Sun et al., 2025): a single temporally-anchored record answers "what does diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md index 4077e398d..b08ee51c2 100644 --- a/docs/adr/0098-valkey-backed-post-content-ingestion.md +++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md @@ -45,11 +45,17 @@ placed in a stream message. unresolved structure decisions when structure adjudication is configured. Incomplete provider output is retried with an explicit failure code rather than being reported as succeeded. The frontend polls that status while - continuing to show the source post. + continuing to show the source post. Persisted units, images, and regions are + exposed only when the job is `succeeded` for the exact current raw-body + SHA-256. Otherwise those derived arrays are withheld and the independently + loaded current raw source body remains the rendering fallback. ## Consequences -- Slow VISION and region embedding work no longer blocks summary or post-open. +- Slow VISION and region embedding work never blocks source-post open or source + rendering. Region embeddings do not block summary. For an image-bearing + post, persisted parent-image and region descriptions are source evidence and + therefore block only the current summary projection until they are complete. - A provider failure is recorded and can be retried without deleting the raw source body or fabricating buyer content. - Valkey is load-bearing as a wake-up queue, but PostgreSQL remains the durable @@ -64,6 +70,25 @@ the API enqueue path. A successful job that has units but lacks the configured unit or described-region embeddings is still eligible for Valkey requeue and MUST NOT be silently skipped by checking only for unit presence. +The same predicate MUST reject an image unit when its persisted parent image +is missing or its VISION status is not `described`, and MUST reject it when +any persisted visual region is not `described`. The operator selector uses +the same image/region condition, so an unavailable image cannot become a +permanent false-ready result merely because its text-unit embeddings exist. +The existing bounded automatic retry limit and the explicit terminal retry +operation in ADR 0115 remain unchanged. + +The narrower `post_content_summary_is_ready` predicate checks only the +persisted VISION evidence required by an image-bearing summary; embeddings and +non-image structure decisions remain outside that predicate. Readiness also +requires the durable job row to match the current raw-body SHA-256 and have +status `post_content_ingestion_succeeded`; described units from an earlier +body cannot make a newly queued, running, or failed revision ready. The +summary read path may enqueue or observe the durable job. It MUST NOT call VISION directly +or summarize an unavailable image placeholder. Queued and running jobs remain +processing. A terminal failed job remains unavailable until ADR 0115's explicit +operator retry. + When contextual-orchestrator is configured, the same predicate also requires every persisted unit to have a non-`unresolved` structure decision. Without an available structure channel, `unresolved` remains an explicit unavailable @@ -76,6 +101,9 @@ existing `(post_id, source_body_sha256)` wake-up to Valkey, and `_claim_job` reclaims it under the same lease predicate. This keeps a process restart or lost consumer from leaving a job permanently running while retaining at-least-once persistence semantics. +Duplicate wake-ups for a fresh `running` lease are ignored before applying the +attempt-limit rule. A final permitted attempt becomes terminal only when that +lease is stale; a duplicate wake-up cannot fail work that is still active. On worker startup, the stream cursor begins at the current Valkey stream tail, not at `0-0`. Historical wake-ups are not authoritative work state; the @@ -83,12 +111,30 @@ normalized PostgreSQL ledger is scanned and queued/stale rows are republished after the cursor is established. This prevents a restart from replaying an unbounded historical stream before processing current work. -Lease recovery also fences completion by `attempt_count`. A worker whose -15-minute lease was reclaimed may finish after the replacement worker has -started; its success, retry, or terminal failure transition is accepted only -when the PostgreSQL row is still `running` for that exact attempt. A stale -worker therefore cannot overwrite the newer attempt or append a false status -event. +Lease recovery fences persistence and completion with the claimed source-body +SHA-256 plus `attempt_count` as a monotonic claim identity. `attempt_count` is +incremented on every claim and is never reset by a changed digest or explicit +retry, so an A-to-B-to-A body sequence cannot recreate an old claim identity. +The bounded automatic-retry count is derived from the existing status-event +ledger after the latest non-failure queued boundary. Before replacing artifacts +or completing/failing work, the worker locks the job and source row and +requires the job to remain `running` for that exact attempt and digest and the +source body to still hash to that digest. A stale worker therefore cannot +overwrite newer artifacts, complete a requeued attempt, or append a false +status event. + +A missing ledger row is always inserted as `queued`, even if pre-ledger +artifacts happen to satisfy the structural completeness predicate. Those +artifacts have no binding to the current raw-body digest; only a fenced worker +or the explicit ADR 0115 backfill finalization may register success. + +The synchronous operator backfill performs provider work before its short +database transaction. Inside that transaction it locks and rechecks the +current source body and non-active ledger row, records the bound ledger +success, and replaces all derived artifacts atomically. It cannot overwrite an +active worker's artifacts or commit success for a body that changed during +provider work. The synchronous source-import adapter uses the same finalization +fence so every production artifact writer participates in that serialization. ## Corpus backfill (2026-08-20) 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/0109-oidc-deep-link-state-recovery.md b/docs/adr/0109-oidc-deep-link-state-recovery.md index 808f12aff..832a4b70a 100644 --- a/docs/adr/0109-oidc-deep-link-state-recovery.md +++ b/docs/adr/0109-oidc-deep-link-state-recovery.md @@ -21,6 +21,11 @@ when the member's OIDC session is otherwise valid. - Persist the same validated same-origin path in both `sessionStorage` and `localStorage` before redirecting to OIDC. `localStorage` is only a bounded recovery fallback, not an authentication or authorization store. +- The signed-out **Log in** control computes that path with + `returnUrlFromLocation()`, persists it with `rememberOidcReturnUrl`, and + passes the same value in `signinRedirect` state. +- Tenant admin settings mount only after authentication provides an access + token; the signed-out login shell never renders `AdminPanel`. - On callback, remove the key from both stores and use session storage before local storage. Reject external and protocol-relative URLs. - Keep member language preference account-scoped in diff --git a/docs/adr/0114-stale-summary-buyer-continuity.md b/docs/adr/0114-stale-summary-buyer-continuity.md index 17cdfc004..2c2e9f761 100644 --- a/docs/adr/0114-stale-summary-buyer-continuity.md +++ b/docs/adr/0114-stale-summary-buyer-continuity.md @@ -20,18 +20,40 @@ though the source post remains authorized and available. 2. The post-summary endpoint first attempts a current summary. If the orchestrator is unavailable or the refresh returns an incomplete provider response, it returns the last persisted summary with - `summary_status: "stale"` and its stored contract version. + `summary_status: "stale"` and its stored contract version. This continuity + applies immediately to text-only posts. An image-bearing stale summary is + withheld until its persisted parent and region descriptions are complete, + then regenerated unless its persisted normalized summary-input SHA-256 + matches the exact current ordered image-evidence text. Legacy rows with no + input binding are never current. For text-only posts, a normalized-input + mismatch downgrades a current-contract row to explicit stale continuity. 3. The buyer popup labels the stale state and offers a retry action. Stale content is never labelled current and is never used to create new catalog identities or semantic rows. 4. A successful contextual-orchestrator refresh remains the only path that atomically replaces the stale projection. Failed refreshes never delete the prior summary or source body. +5. After provider work and before replacing any summary-owned semantic or + shared catalog projection, persistence locks and rechecks the current + source-body SHA-256. For image-bearing input it also requires the exact + current succeeded content job and re-reads the ordered persisted image + evidence; that evidence must match the normalized summary input byte for + byte. Organization name and hierarchy provider calls first produce frozen, + no-write proposals before that transaction begins. After the recheck, + applying those proposals to shared name/catalog tables, replacing the + summary-owned projection, and fetching the current payload complete in the + same transaction while the source/evidence lock remains held. Provider + calls never run while source, job, or catalog advisory locks are held. A + source or evidence change during provider work therefore rejects every + proposed catalog mutation, leaves the prior summary projection intact, and + cannot return the superseded result as current. ## Consequences - Buyers can read source-grounded prior context while the semantic gateway is - unavailable instead of seeing a fail-closed summary panel. + unavailable instead of seeing a fail-closed summary panel. Image-bearing + posts remain fail-closed at the VISION evidence boundary without delaying + source-post open or source rendering. - The UI makes the refresh boundary visible, so an old contract cannot be mistaken for current ontology evidence. - A durable background refresh remains useful for large-scale regeneration; diff --git a/docs/adr/0115-explicit-terminal-content-retry.md b/docs/adr/0115-explicit-terminal-content-retry.md index 8a75c8dc6..5fbf259d6 100644 --- a/docs/adr/0115-explicit-terminal-content-retry.md +++ b/docs/adr/0115-explicit-terminal-content-retry.md @@ -14,10 +14,12 @@ would silently weaken the retry limit and could create an endless loop. ## Decision Keep automatic retry and read-time behavior unchanged. Provide an explicit, -single-post operator command that may requeue only a `failed` job, resets its -attempt counter, recomputes the current source-body digest, appends an audit +single-post operator command that may requeue only a `failed` job, starts a new +bounded retry cycle, recomputes the current source-body digest, appends an audit status event, and publishes one Valkey wake-up. The command is not exposed as -a public HTTP route and does not reset a queued, running, or succeeded job. +a public HTTP route and does not reset a queued, running, or succeeded job. It +never resets the monotonic `attempt_count` claim identity; a later claim must +remain distinguishable from every worker that ran before the operator retry. The command must use the existing queue function and must not call a provider directly. It is an operational recovery action, not a buyer-visible status @@ -25,10 +27,12 @@ override; the worker still performs the normal VISION, structure, and embedding completeness checks. The synchronous operator backfill is a separate repair path. After it -persists derived evidence, it must call the queue module's ledger-finalization -function in a database transaction. It must never leave a previously failed -job marked failed while presenting newly persisted content as a successful -backfill. +finishes provider work, it must call the queue module's ledger-finalization +function and replace derived evidence in one database transaction. The +finalizer locks and rechecks the current raw body and rejects an active worker; +artifact replacement and ledger success therefore commit or roll back +together. It must never leave a previously failed job marked failed while +presenting newly persisted content as a successful backfill. ## Consequences 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..6853f5308 --- /dev/null +++ b/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md @@ -0,0 +1,71 @@ +# 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..b146ce01b --- /dev/null +++ b/docs/adr/0126-global-ask-conversation-history.md @@ -0,0 +1,48 @@ +# 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. +* Reauthorization for a conversation's turns is batched (`_visible_post_ids_batch` + / `_turn_evidence_batch`, one query per relation type per page instead of + per turn), so query count stays bounded by `turn_limit` rather than + growing with exchange count. Same fail-closed, per-turn authorization + boundary as before -- issue #358 (originally an implementation detail + raised on PR #342's exact-head review, not a new decision). 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..bcff70fb0 --- /dev/null +++ b/docs/adr/0129-standards-aligned-event-clue-ontology-profile.md @@ -0,0 +1,153 @@ +# 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. + LineageWeave's `TemporalEntity` is a subclass of `time:TemporalEntity`. + Explicit chronology is stored canonically as earlier temporal entity + `time:before` later temporal entity; `time:after` is not duplicated as an + inverse row. A product, organization, or document is not relabeled as a + temporal entity. Its evidenced release, introduction, milestone, instant, + or interval is the temporal endpoint, and `time:before` alone does not imply + specialization, revision, causation, or product succession. +- 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-tenant-identity-metadata.md b/docs/adr/0132-tenant-identity-metadata.md new file mode 100644 index 000000000..cd9ecace0 --- /dev/null +++ b/docs/adr/0132-tenant-identity-metadata.md @@ -0,0 +1,54 @@ +# ADR 0132: Explicit tenant identity and copyright metadata + +## Status + +Accepted + +## Date + +2026-08-22 + +## Figma + +File ID `1Su3lDRmiZdcUs47t1QwIX` + +## Context + +ADR 0118 requires the header to distinguish the brand identity from the web +system name and requires the footer copyright to show an explicit year and +rights holder. The current shell stores one `brand_name` and renders the +browser's current year, so an administrator cannot provide the approved +identity metadata for a tenant. + +The repository does not contain an approved CI/BI asset or usage permission. +This decision therefore adds editable metadata only; it does not invent or +ship a logo asset. + +## Decision + +1. Extend the existing single-row `tenant_settings` PostgreSQL table with + `system_name`, `copyright_year`, and `copyright_holder`. +2. Keep `brand_name` as the brand identity and retain the existing + `brandName` API field for compatibility. +3. `GET /api/settings` returns all four display values. `PATCH /api/settings` + remains restricted to `post_admin`; omitted fields retain their current + values so older clients can still update only `brandName`. +4. The API and database reject blank text and copyright years outside + 1900--2100. The deployment default is `LineageWeave` and `2026`; a release + owner must replace these with the tenant's approved CI/BI and legal values + before production release. +5. The React shell renders the configured system name in the header and the + configured year/rights holder in the footer. It does not use the browser's + current year. + +## Consequences + +- Header and footer identity fields now have a persisted, auditable source. +- Existing callers that send only `brandName` continue to work. +- The approved CI/BI image remains an explicit operational gap until the + tenant supplies the asset and usage permission. + +## References + +- ADR 0118: UI·UX Standard Guide Ver.3.0 Design Overhaul +- 웹 시스템 UI·UX 표준 가이드 Ver.3.0, §§2.2.1, 2.2.3--2.2.4 diff --git a/docs/adr/0133-source-reference-research-agent.md b/docs/adr/0133-source-reference-research-agent.md new file mode 100644 index 000000000..f97495550 --- /dev/null +++ b/docs/adr/0133-source-reference-research-agent.md @@ -0,0 +1,87 @@ +# ADR 0133: Evidence-bearing source-reference research agent + +- Status: Accepted +- Date: 2026-08-23 +- Related: [0004](0004-knowledge-graph-ontology.md), [0005](0005-relation-verification-agent.md), [0062](0062-semantic-unit-embedding.md), [0076](0076-paper-grounded-model-policy.md), [0084](0084-lineage-research-grounding.md) + +## Context + +A post can cite a URL, patent, publication, or address without explicitly +naming the organization that published or shared it. The existing relation +verification agent checks whether an already extracted organization has a web +footprint. It cannot discover the missing actor, follow the cited resource, or +decide whether retrieved evidence supports the post's claim. Treating a search +hit as proof would preserve the observed failure under a different label. + +## Decision + +Add a post-scoped source-reference research workflow with separate evidence +channels: + +1. Discover URL and patent leads from persisted semantic units and completed + image-region OCR. A lead retains its source unit or image-region identity; + deterministic discovery is not an entity binding. +2. Retrieve candidates through the existing self-hosted SearXNG boundary. + Fetch only public HTTP(S) result pages with redirect and private-network + rejection. Store bounded extracted text, content digest, retrieval time, + and final URL; never store credentials, cookies, or arbitrary binary files. +3. Ask contextual-orchestrator to judge each claim against the retrieved + passages. The only outcomes are `supported`, `refuted`, and + `not_enough_information`. A sharing actor is returned only when a cited + passage explicitly identifies it. LineageWeave does not select a provider + model or call one directly. +4. Persist leads, retrievals, and judgments in normalized tables. Project a + supported actor/reference relation through the existing semantic and + Knowledge Graph layers with PROV-O evidence; do not create a private edge + alias for a W3C property. +5. LLM-as-a-Judge output remains a judgment, not a psychometric score. When a + research judgment is used in calibrated evaluation, emit a provenance- + bearing response event through `tepp_client`; TEPP owns calibration and a + missing or unpersisted result remains Failed. TEPP has not yet published a + response-event wire contract, so this release does not invent one: research + judgments are excluded from calibrated evaluation until that contract is + published and added to `tepp_client`. +6. The workflow is an explicit `post_admin` action because it performs external + retrieval and writes derived evidence. Readers see persisted evidence, + uncertainty, citations, and the next action; they never trigger hidden web + calls by opening a post. + +## Implementation status + +The current worktree implements source-unit and described-image-region lead +discovery, bounded SearXNG retrieval with public-host and redirect rejection, +contextual-orchestrator judgment, normalized lead/retrieval/judgment/citation +persistence, migration replay, explicit admin execution, read-only retrieval, +and a reader panel. Cited supported actors are projected into the Knowledge +Graph as `dcterms:references` and `prov:wasAttributedTo` relations while +retaining the retrieval digest and judgment identity. Focused synthetic +source-research, migration-replay, graph, and panel tests cover those +boundaries; no live external or authenticated runtime validation is claimed. + +The stored `evidence_url` is the SearXNG result URL because redirects are +rejected; canonical/final URL capture remains open. TEPP response-event +integration remains unavailable until TEPP publishes that contract. The +implemented actor guard requires a supported judgment, a citation, and literal +actor presence in the cited passage; whether that passage explicitly identifies +the actor as a publisher or sharer remains Judge-dependent until live evidence +validates it. + +## Consequences + +- An address alone remains a place. It cannot fill Who or become an + organization without retrieved, cited evidence. +- Search, crawl, Judge, KG, ontology, and TEPP remain distinguishable signals; + failure of one cannot be converted into confidence from another. +- Existing SearXNG, contextual-orchestrator, semantic-unit, PROV-O, KG, and + `tepp_client` boundaries are reused. No provider SDK or second crawler + dependency is added. + +## References + +- Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: + A large-scale dataset for fact extraction and verification. *Proceedings of + NAACL-HLT 2018*, 809-819. https://aclanthology.org/N18-1074/ +- World Intellectual Property Organization. (2024). *WIPO Standard ST.96: + XML resources for IP data*. https://www.wipo.int/standards/en/st96/ +- World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. + https://www.w3.org/TR/prov-o/ diff --git a/docs/adr/0134-token-backed-exception-messages.md b/docs/adr/0134-token-backed-exception-messages.md new file mode 100644 index 000000000..d2b8fe528 --- /dev/null +++ b/docs/adr/0134-token-backed-exception-messages.md @@ -0,0 +1,69 @@ +# ADR 0134: Token-backed exception messages name a next reader action + +- Status: Accepted +- Date: 2026-08-23 +- Figma: File ID `1Su3lDRmiZdcUs47t1QwIX` +- Related: [0002](0002-figma-access-boundary.md), [0099](0099-badge-and-accent-color-tokens.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0123](0123-provider-error-boundary.md) + +## Context + +Reader-facing failures were often a color-only red paragraph (`

`). +That surface did not identify the failure in text independent of color, did not +name a next action, and sometimes interpolated a raw exception, OIDC diagnostic, +or HTTP 5xx payload. WCAG 2.2 requires identifying input errors in text (3.3.1), +suggesting a correction (3.3.3), and announcing status changes through a +programmatic live region (4.1.3). ADR 0123 already forbids exposing provider +payloads at the API and browser-client boundary (CWE-209); the workspace UI +must not reintroduce that leak by rendering `String(err)`, `err.stack`, or +`auth.error.message`. + +The Figma file recorded in ADR 0002 and ADR 0118 remains the design source. +This decision applies that file to exception feedback only; it does not restyle +the rest of the workspace. + +## Decision + +1. Reuse the shipped unavailable pattern (`SummaryStatus` title + description + + optional detail + retry) as `ExceptionAlert`. Do not invent a second visual + language, toast, or snackbar. +2. Style that surface with light and dark `--color-exception-{background,border,accent,text,heading}` + tokens. Contrast is not color-only: heading text, a left accent border, and + a filled background identify the failure. Recovery controls keep + `--size-control-min` and the existing focus-visible treatment. +3. Map transport and unexpected failures through `productExceptionCopy` before + render. HTTP 5xx and status-0 keep the sanitized `BackendError` message. + Raw exception types, stacks, and provider payloads become + `{action} could not be completed.` Client-actionable 4xx validation text is + retained only when it is not a raw exception. +4. Every reader-facing failure names a next action in copy (retry, continue + with saved evidence, log in, correct highlighted fields). When recovery is + possible, expose a focusable control. Failures use `role="alert"`; + processing and empty states stay `role="status"`. +5. Sign-in failures never render the OIDC `error.message`. They show a product + title, a log-in next action, and a Log in control. + +## Consequences + +- Auth, board, popup/panel fetch, Ask/chat, summary unavailable, admin form, + source research, and cited-evidence miss share one token-backed pattern. +- Storybook records unavailable, retryable transport, form-field, auth, and + continue-with-saved-evidence scenes. +- ADR 0123 remains the payload policy. This ADR is the reader-facing + presentation of that fail-closed copy. + +## References — APA 7th + +MITRE. (2026). *CWE-209: Generation of error message containing sensitive +information*. https://cwe.mitre.org/data/definitions/209.html + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2024). *Error identification* (Understanding +SC 3.3.1). https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html + +World Wide Web Consortium. (2024). *Error suggestion* (Understanding SC +3.3.3). https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html + +World Wide Web Consortium. (2024). *Status messages* (Understanding SC +4.1.3). https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html diff --git a/docs/adr/0135-analysis-result-kind-exact-next-actions.md b/docs/adr/0135-analysis-result-kind-exact-next-actions.md new file mode 100644 index 000000000..c05563174 --- /dev/null +++ b/docs/adr/0135-analysis-result-kind-exact-next-actions.md @@ -0,0 +1,67 @@ +# ADR 0135: Analysis-result next actions stay kind-and-status exact + +- Status: Accepted +- Date: 2026-08-23 +- Figma: File ID `1Su3lDRmiZdcUs47t1QwIX` +- Related: [0014](0014-authorized-analysis-run-read.md), [0016](0016-analysis-run-knowledge-cutoff-posts.md), [0021](0021-authorized-analysis-run-start.md), [0022](0022-authorized-tepp-start.md), [0025](0025-source-post-revision.md), [0049](0049-leftover-pair-report-ui.md), [0050](0050-seed-period-report-analysis-run.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0137](0137-cross-post-customer-identity.md) + +## Context + +The analysis-run list caption is `kind · status · entity`. Next-action copy +on that list and on the opened detail must not mix kinds: a failed lineage +row is not a missing TEPP transport, a failed TEPP row is not a +reconstruction retry, and a failed period report is not a measurement. +A running row whose copy says the work is already queued must not also +offer Start reconstruction / Start TEPP measurement. A succeeded period +report must not say the report is unbuilt. Opening a cutoff-rewritten +title must name both clocks and show **Body this run knew** only when a +revision covers the cutoff. + +## Decision + +1. Map next-action copy in `analysisRunGuidance` by `run_kind_code` × + `status_code`. Tests feed representative run records into that function + and into `AnalysisRunNextAction` without mocking the panel away. +2. Start is pending lineage or pending TEPP only. Running rows expose + Refresh this run. Failed TEPP stays terminal (connect transport; do not + invent a Pending TEPP row). Failed reports with a week key open the + period-report surface so rebuild is the control that follows rebuild copy. +3. Succeeded report landing still focuses the report period, sets grouping + to the run's corporate entity when that is the opened run, and places + the opened-grouping next-action status, mean θ, and member posts ahead + of other groupings and the week strip. +4. Cutoff live-body warning plus `CutoffKnownBody` remain the comparison + path. A missing revision is omitted; the live body is never labeled as + reconstructed evidence without that comparison. +5. Leftover closest/farthest pairs name the post **and** the Post quality + criterion. Clicking a pair opens that post with `focusCriterionCode` and + lands on that criterion. It does not reuse the member-row Event Lineage + landing (ADR 0049). +6. Catalog-unbound, dropped/unavailable channel, and confident-negative + are three distinct reader states, each with next-action copy. A Null + channel is dropped and renormalized, never scored as zero. A glued + job-title + relationship-type phrase stays one source string until a + reviewed `POST_SUMMARY_CONTRACT_VERSION` bump; do not infer “operates”. + +Cross-post customer identity is [ADR 0137](0137-cross-post-customer-identity.md), +not this record. The two decisions previously collided on number 0135. + +## Consequences + +- Copy and the following control cannot both claim "already queued" and + "start over". +- Storybook records failed lineage, failed TEPP, failed report, pending + TEPP, running queued, succeeded-report landing, cutoff live-body + warning, leftover closest/farthest landing, and catalog-unbound / + dropped-channel / confident-negative scenes. Each scene has a `play` + function that clicks or asserts the following control. +- ADR 0013 still forbids storing θ on the analysis-run registry. Mean θ + on the period-report panel is report evidence, not an invented theta. + +## References — APA 7th + +World Wide Web Consortium. (2024). *Error suggestion* (Understanding SC +3.3.3). https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/0136-post-ask-conversation-history.md b/docs/adr/0136-post-ask-conversation-history.md new file mode 100644 index 000000000..bcdc9b893 --- /dev/null +++ b/docs/adr/0136-post-ask-conversation-history.md @@ -0,0 +1,87 @@ +# ADR 0136: Persisted per-post Ask conversation history + +* Status: Accepted +* Date: 2026-08-23 +* Figma: File ID `1Su3lDRmiZdcUs47t1QwIX` +* Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0126](0126-global-ask-conversation-history.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md) + +## Context + +Global Ask already persists account-owned conversations with list, select, +and new-conversation controls (ADR 0126). The post popup **Ask about this +lineage** surface did not. It stored one shared `post_chat_result` row per +`(post_id, question_norm)` and rendered a linear transcript plus re-ask +chips. Leaving the popup, switching questions, or starting a new thread +could not reopen an earlier account-owned conversation on that post. + +That is a different metaphor from the conversation-history sidebar the +reader already uses on Ask Agent. Seeded fixture answers remain the +orchestrator-off demo cache; they are not a substitute for account-owned +history. + +## Decision + +Persist per-post Ask conversations under the authenticated `user_account` +and the visible `source_post`. Reuse the ADR 0126 contract: an explicit +conversation id, list/select/new, and visibility-filtered citations on +read. Do not represent this id as a Global Ask session id or as a fake +post-scoped orchestrator session id. + +Normalized tables: + +* `post_ask_session` — one conversation per account and post +* `post_ask_turn` — ordered questions and answers +* `post_ask_turn_citation` / `post_ask_turn_source` — cited and retrieved + posts + +The composite index `(user_account_id, post_id, updated_at desc)` leads +with the account so a hot post cannot concentrate list traffic on one +partition key. A turn is written only after a complete answer exists +(seeded cache hit or orchestrator object). History reads re-apply current +post visibility before returning titles or citations. + +`post_chat_result` stays the post-level seeded/cache store used when the +orchestrator is off. Account history is additional, not a replacement. + +## Consequences + +* A reader can list saved questions on a post, reopen one and see its + turns, and start a new conversation without losing the list. +* A user cannot read another account's post conversation by changing a + UUID, and cannot load a conversation against a different post id. +* Revoked post visibility removes that post's citation projection; the + stored answer remains account-owned transcript data. +* TEPP topic modeling of how many posts can connect, and how many + lineages form under temporal precedence, remains deferred. +* Reauthorization for a conversation's turns is batched + (`_visible_post_ids_batch`, one query per relation type per page instead + of per turn), matching the same fix applied to Global Ask history (ADR + 0126) -- issue #358. +* `persist_turn` now re-authorizes every citation inside its own commit + transaction (`_ensure_citations_visible`, row-share-locked, raising + `PostAskEvidenceChanged` -> 503), matching the fix ADR 0126 already had + for Global Ask (issue #362). This module never received that fix when + it was first written; a citation that lost authorization between + source-gathering and commit would have its facts served in the answer + and its citation row persisted regardless. + +## Implementation Plan + +* **Affected paths:** `migrations/0136_post_ask_conversation_history.sql`, + `backend/app/post_ask_history.py`, `backend/app/main.py`, + `frontend/src/api.ts`, `frontend/src/App.tsx` (`ChatPanel`), + `frontend/src/i18n.ts`, `tests/test_post_ask_history.py`, + `frontend/src/App.test.tsx` +* **Pattern:** copy the ADR 0126 list/load/write boundary with a required + `post_id` scope on every query. +* **Verification:** backend tests drive `list_conversations`, + `fetch_conversation`, and `persist_turn`. Frontend tests click New + conversation, select a saved conversation, and assert the matching turns. + +## References — APA 7th + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/0137-cross-post-customer-identity.md b/docs/adr/0137-cross-post-customer-identity.md new file mode 100644 index 000000000..12e45b008 --- /dev/null +++ b/docs/adr/0137-cross-post-customer-identity.md @@ -0,0 +1,136 @@ +# ADR 0137: Cross-post customer identity judgment and name history + +- Status: Accepted +- Date: 2026-08-23 +- Numbering: this decision was drafted as a second ADR 0135; analysis-result + next actions keep [0135](0135-analysis-result-kind-exact-next-actions.md) + and this record is 0137. +- Extends: [ADR 0003](0003-fast-mlsirm-report-integration.md), + [ADR 0004](0004-knowledge-graph-ontology.md), + [ADR 0009](0009-cross-post-actor-identity.md), + [ADR 0010](0010-corporate-hierarchy-auto-creation.md), + [ADR 0026](0026-tied-organization-similarity.md), and + [ADR 0042](0042-source-hints-before-customer-binding.md) + +## Context + +Customer hints are currently grouped by `source_customer_code` alone. The same +opaque code can belong to different source systems, and the resolver reads a +bounded set of posts but does not retain which posts supported its decision, +an LLM-as-a-Judge response, or an IRT-ready measurement row. A corroborated +name can therefore be written to `corporate_entity` without proving that it was +repeated across distinct posts. `corporate_entity.entity_name` also overwrites +the only stored label when wording changes, so an alias and a formal rename are +indistinguishable. + +The product needs a collective identity decision, not another per-post +classifier. Bhattacharya and Getoor (2007) show why relational evidence across +records matters for entity resolution. Zheng et al. (2023) also document that +LLM judges have position and other biases; a judge score alone is not a safe +master-data write authority. + +## Decision + +1. The source identity key is `(source_system_code, source_customer_code)`. + `source_system_code` remains nullable because older authorized imports may + not provide it, but null is one explicit key value (`NULLS NOT DISTINCT`), + never a wildcard spanning named systems. +2. A customer identity judgment requires at least two distinct eligible, + authorized posts carrying that exact key. The evidence fingerprint covers + post ids, source timestamps, source customer names, and normalized excerpt + hashes. An unchanged fingerprint reuses the persisted decision and avoids a + second paid model call. +3. Candidate-name extraction continues through contextual-orchestrator. A + second, versioned `fast_mlsirm.ContextualOrchestratorJudge` rubric evaluates + cross-post recurrence, same-organization consistency, and candidate-name + support. Its result is persisted through `LLMJudgeResult.to_irt_row()` in + `customer_identity_judgment_response`; LineageWeave does not invent a + parallel judge-to-IRT conversion. +4. Promotion requires all of the following: two or more distinct posts, the + judge's accepted decision and minimum rubric category, external search + corroboration, and a unique catalog resolution. A miss enters ADR 0010's + verified hierarchy-creation path under ADR 0012's advisory lock. A tie stays + unbound under ADR 0026. Missing contextual-orchestrator, search, TEPP, or + hierarchy channels never produce substitute evidence. +5. TEPP's temporal-context contract may order the observation events. Its + `association_not_causal` result is stored only as the ordering source. If + TEPP is unavailable, source `created_at`/`updated_at` facts retain their + deterministic order; no TEPP score or causal claim is fabricated. +6. `customer_identity_binding` is the stable Customer Master link. Evidence + posts project through `post_customer_identity_mention` into the Knowledge + Graph as `edge_customer_identity_observation`, distinct from an R&R + organization mention. The edge's support remains post-scoped and ABAC + filtered. +7. `corporate_entity_name_history` stores preferred, former, and alternate + labels. This follows SKOS preferred/alternate-label semantics. A differing + candidate becomes an alternate label by default. It replaces the preferred + label only after a separate strict rename rubric proves the same legal + identity, an explicit name-change assertion, and temporal succession. The + prior preferred label then becomes former; the observation times are not + presented as a legal effective date. +8. The judgment, its criterion responses, supporting posts, binding, name + history, and graph mention remain normalized records with foreign keys. The + judgment-to-post table is application audit evidence, not a new PROV-O + predicate; `knowledge_graph_edge` remains only the navigation projection. +9. The PostgreSQL source importer collects only customer keys changed in that + run and reconciles them after content and lineage persistence. Missing + channels or a provider outage leave aggregate `unavailable` evidence and do + not roll back authorized source records; a failure for one key does not stop + the remaining keys. The admin endpoint remains an explicit retry path. + +## Runtime sequence + +```mermaid +sequenceDiagram + participant Importer as PostgreSQL importer + participant Store as LineageWeave PostgreSQL + participant Orch as contextual-orchestrator + participant Search as SearXNG + participant TEPP + + Importer->>Store: Upsert authorized source posts + Importer->>Store: Load repeated exact customer key + Store-->>Importer: At least two eligible post records + Importer->>TEPP: Order opaque observation events (optional) + Importer->>Orch: Resolve candidate and run fast-mlsirm Judge + Importer->>Search: Corroborate candidate organization + alt all promotion gates pass + Importer->>Store: Persist judgment, IRT rows, binding, names, post mentions + Importer->>Store: Project customer-observation KG edges + else evidence is missing, tied, or weak + Importer->>Store: Preserve abstention evidence; do not bind + end +``` + +## Consequences + +- One plausible post cannot promote a customer master record. +- Source-system code collisions and same-name catalog ties fail closed. +- A reader can trace a promoted customer to every supporting post and the exact + judge rubric version without storing source text again. +- Judge categories are audit measurements, not calibrated probability or + theta. Population calibration remains a later `fast-mlsirm` report concern. +- Formal renames are intentionally rarer than aliases. An operator can review + ambiguous labels without losing the current preferred name; former and + alternate names are visible when the Customer Master entity is expanded. + +## References (APA 7th) + +Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in +relational data. *ACM Transactions on Knowledge Discovery from Data, 1*(1), +Article 5. https://doi.org/10.1145/1217299.1217304 + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-O: The PROV ontology*. +World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Zheng, L., Chiang, W.-L., Sheng, Y., Zhuang, S., Wu, Z., Zhuang, Y., Lin, Z., +Li, Z., Li, D., Xing, E. P., Zhang, H., Gonzalez, J. E., & Stoica, I. (2023). +Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. *Advances in Neural +Information Processing Systems, 36*. https://arxiv.org/abs/2306.05685 diff --git a/docs/adr/0141-catalog-unresolved-diagnosability.md b/docs/adr/0141-catalog-unresolved-diagnosability.md new file mode 100644 index 000000000..e198cc50e --- /dev/null +++ b/docs/adr/0141-catalog-unresolved-diagnosability.md @@ -0,0 +1,142 @@ +# ADR 0141 — Catalog-unresolved reason is explicit, not one flat label + +**Decision status:** Proposed +**Date:** 2026-08-22 + +## Context + +`docs/product-technical-gap-baseline.md` (§5, "R&R role/relationship +conflation and catalog-linking boundary") records a live UI/UX finding: +"카탈로그 미연결" ("Not linked to catalog", `frontend/src/i18n.ts`) is the +correct, honest label whenever a role's `catalog_node_id` is `null` +(`fetch_persisted_summary`, `backend/app/post_summary_ingestion.py`), but it +gives the reader no way to tell apart the reasons that null can happen: + +- An organization actor's mention tied two or more equally-similar catalog + candidates (`RESOLUTION_TIE`, `score_corporate_entity`, + `lineageweave/corporate_hierarchy_resolution.py`; ADR 0026 — a tie must + stay unbound, never first-win a homonym). +- `get_or_create_corporate_entity`'s `inference_client` or + `verification_client` (`backend/app/corporate_entity_ingestion.py`) + defaulted to a `Null*Client` because no live contextual-orchestrator / + SearXNG transport is wired in this environment — in that state the + function can only match an *already-cataloged* entity, never create one. +- A live client was available and was actually called, but hierarchy + inference proposed nothing, or verification declined to corroborate the + proposed placement (`placement_result.status_code != STATUS_CORROBORATED`) + — a considered "no" rather than an unattempted check. +- A person actor's name has no matching `cataloged_person` row yet + (`_resolve_existing_cataloged_person_id`) — ADR 0009 forbids inventing one. + +These are different facts a reader would act on differently ("try again once +the orchestrator is configured" vs. "this was checked and is not the same +entity" vs. "wait for another mention to disambiguate the tie"), but today +they are indistinguishable from the UI. This is a genuine information loss: +the resolution kind (`unique` / `tie` / `miss`) and the client-availability +state already exist as in-memory facts inside +`get_or_create_corporate_entity` and `_resolve_affiliated_organization` for +the duration of one call — they are simply discarded before +`_replace_summary_projection` persists the row. + +Per AGENTS.md, a schema/behavior change needs its ADR before code. This is +the ADR for that change; it does not touch extraction, the summary contract, +or catalog creation itself (those are unrelated, separately-scoped changes — +see `docs/product-technical-gap-baseline.md` §5 items 2 and 3 for the ones +this ADR deliberately does not fix). + +## Decision + +1. Add two nullable, `common_lookup_value`-backed reason columns to + `post_summary_role`, following the same pattern as ADR 0125's + `affiliation_scope_code` (`migrations/0134_catalog_unresolved_reason.sql`): + `catalog_unresolved_reason_code` (why the primary person/organization/team + actor has no catalog link) and `affiliation_catalog_unresolved_reason_code` + (why the role's *affiliated* organization, + `cataloged_affiliated_corporate_entity_id` / ADR 0127, has none). Two + columns, not one, because a role commonly has its primary actor resolved + (a known person) while its affiliation is not, or vice versa — one shared + column could not represent both states at once. The shipped "Not linked + to catalog" label (`frontend/src/components/RoleEvidence.tsx`, + `unresolvedLabel` prop) currently renders next to the *affiliation* + field, so `affiliation_catalog_unresolved_reason_code` is the column that + closes the gap-baseline finding as literally reported; + `catalog_unresolved_reason_code` closes the same gap for the primary + actor, which today shows no reason at all when unresolved. Each column is + set only when its corresponding `cataloged_*_id` column is `null`; both + stay `null` when a catalog link exists, and both stay `null` on + historical rows written before this migration (no retroactive reason is + invented for history — the frontend falls back to today's behavior, a + plain name with no reason shown, when the reason is absent). +2. Closed vocabulary, four values: + - `reason_tied_candidates` — `RESOLUTION_TIE`: two or more candidates + shared the top similarity score. + - `reason_no_live_client` — `inference_client.available` or + `verification_client.available` was `False` (including the + `HttpClientError` / `OSError` / `TimeoutError` catches that already + exist in `get_or_create_corporate_entity`, which are the same "channel + unavailable this run" fact as an unavailable client, not a decision). + - `reason_not_corroborated` — a live client ran, but inference proposed + nothing or verification returned a non-`STATUS_CORROBORATED` result. + - `reason_no_catalog_entry` — the person-catalog name lookup + (`_resolve_existing_cataloged_person_id`) found no row. This is the only + reason code available to a person actor; the resolver has no client + dependency to distinguish further, and inventing a finer distinction + here would misrepresent what the function actually checked. +3. `get_or_create_corporate_entity` returns the reason alongside the id + (`(catalog_id: str | None, reason_code: str | None)`) instead of just the + id, so the caller can persist it without re-deriving state the callee + already computed and discarded. `_resolve_affiliated_organization` grows + a fourth return element carrying the same reason for the affiliation + case (its Keyman-ingestion caller, a separate surface out of this ADR's + scope, discards it unchanged). `_resolve_existing_cataloged_person_id` + gains the equivalent single-reason return. +4. `fetch_persisted_summary`'s `payload_roles` entries add both + `catalog_unresolved_reason_code` and + `affiliation_catalog_unresolved_reason_code` (each `null` when linked or + historical). `frontend/src/api.ts` extends the role type to carry both. +5. The frontend replaces the single `unresolvedLabel` string + (`RoleEvidence.tsx`, wired from `frontend/src/App.tsx`'s R&R row + renderer) with a small lookup from reason code to one of four specific, + translated messages, falling back to today's plain rendering (no reason + shown) when the reason is `null`. The primary actor's own name gains the + same treatment where it currently shows no diagnostic at all. No + confirmation dialog, retry button, or invented certainty is added — this + is read-only diagnostic text. +6. This does not change ABAC/visibility, does not create a catalog row that + wasn't already going to be created, and does not change the tie/miss/ + corroboration policy itself (ADR 0009, 0010, 0026 govern those). It only + makes an already-computed-and-discarded fact visible. + +## Considered alternatives + +- **Do nothing; leave the flat label.** Rejected: the gap-baseline finding is + that this is undiagnosable today, and the reader currently cannot tell "not + yet possible to check" from "checked and declined." +- **Infer the reason at read time from current client configuration instead + of persisting it.** Rejected: the reason is a fact about what happened + *when the role was last persisted*, not about the reader's current + environment. A role summarized while an orchestrator was configured, then + read after it goes down, would get the wrong "no live client" reason. The + reason must be captured at write time, not derived at read time. +- **One generic "insufficient evidence" code instead of four.** Rejected: + the gap-baseline finding is specifically that a reader cannot act on an + undifferentiated null; collapsing back to one bucket reproduces the same + problem it exposed. + +## Consequences + +- Historical rows keep the honest gap ("we didn't record why") rather than a + fabricated backfilled reason; only newly-persisted summaries get the + specific code. This is consistent with how ADR 0125 handled its own + migrated column. +- `get_or_create_corporate_entity`'s signature change is a small, mechanical + ripple to its ~7 in-repo callers (`backend/app/keyman_ingestion.py`, + `backend/app/post_summary_ingestion.py`) and its existing test suite + (`tests/test_tied_organization_no_create.py` and the corporate-hierarchy + resolution tests) — each call site now reads a tuple instead of a bare id. + No test asserts on the old bare-id return shape in a way that survives + unchanged; those tests are updated alongside the implementation. +- Keyman-side organization/affiliation resolution + (`backend/app/keyman_ingestion.py`) can reuse the same reason vocabulary + later if the same undiagnosability complaint is raised there; this ADR + scopes only the R&R post-summary path the gap-baseline finding names. diff --git a/docs/adr/0142-planned-facility-project-evidence.md b/docs/adr/0142-planned-facility-project-evidence.md new file mode 100644 index 000000000..3cdc58362 --- /dev/null +++ b/docs/adr/0142-planned-facility-project-evidence.md @@ -0,0 +1,153 @@ +# ADR 0142 — A planned facility becomes project/entity evidence only through the existing semantic-relationship channel, with an explicit "planned" predicate + +**Decision status:** Accepted +**Date:** 2026-08-24 + +## Context + +`docs/product-technical-gap-baseline.md` (§5, item 3) records a live finding: +a key event whose text names a specific planned facility (its own example: +"X 충전소 구축 계획", i.e. "plan to build X charging station") produces +`key_events` / `key_event_details` prose only. It is never checked against +`post_project_mention` / the entity graph, so the facility itself is not +recognized as an entity, and no relationship is inferred between it and the +organization that the post's own R&R evidence says would operate it. + +The gap doc is explicit that this needs its own ADR before any inference +code, because the risky part is not rendering — it is deciding *when* text +that describes a *plan* is allowed to become a persisted *relationship*. +ADR 0010's fail-closed hierarchy-creation design exists precisely to stop an +organization mention from being auto-created without independent +corroboration; inferring an "operates" relationship from event-adjacent +context risks the same class of mistake — asserting a fact ("Org X operates +Facility Y") that the source text does not actually state ("Org X plans to +build Facility Y"). + +The initial proposed revision was decision-only. The separately reviewed +follow-up now implements the accepted admission rule without adding a second +relationship table or catalog-creation path. + +### What already exists that this can reuse + +- `post_summary_semantic_relationship` / `SemanticRelationship` + (`lineageweave/post_summary.py`) is already a generic, closed-vocabulary, + evidence-required subject–predicate–object channel: + `SEMANTIC_RELATION_NODE_TYPES` already includes `organization`, + `industrial_asset`, `industrial_process`, `place`, and `project`; + `SEMANTIC_RELATION_PREDICATES` is a closed set of PROV-O / SKOS / SOSA / + ODRL / `lw_*` codes. A relationship row without a non-empty `evidence_text` + is rejected by `SemanticRelationship.__post_init__` today. +- `post_project_mention` already records a project name/key with its own + `mention_confidence` and `extraction_method`, independent of key events. +- Neither channel today has a predicate that means "named as the operator of + a facility this post says is only planned, not existing." + +Given this, the missing piece is not a new table or a new relationship +class — it is one new closed predicate code plus the admission rule that +governs when the extractor may emit it. + +## Decision + +1. **Reuse `post_summary_semantic_relationship`; do not add a new table.** + Add one new predicate code, `lw_plans_to_operate`, to + `SEMANTIC_RELATION_PREDICATES` and to `docs/ontology/lineageweave-kg.ttl` + (`LW.predicateCode` / `LW.predicateIri` per `semantic_predicate_annotations`'s + existing lookup contract). The subject is the organization/team actor + named in the post's own R&R evidence as the one carrying out the plan; + the object is the facility, typed `industrial_asset` (or `place` when the + text gives no asset-specific detail). +2. **The predicate name itself carries the epistemic status.** + `lw_plans_to_operate` names an announced intention, not a standing fact. + The extractor must never emit `lw_has_actor`/`org_*` operate-style + predicates for a facility the post itself describes only as planned or + under construction. If a later post's evidence says the facility is + actually operating, that is new evidence for a new relationship row (an + `lw_supports`/successor predicate is a separate, future decision if that + need materializes) — this ADR does not retroactively upgrade a prior + "planned" row. +3. **Admission rule — every one of these must hold before the relationship + is emitted:** + - The post's own text (not the operator's inference, not world knowledge) + names both the organization/team actor and the facility in the same + event or adjacent evidence span. + - The facility is also captured as a `post_project_mention` row (or is + eligible to be, at extraction time) so the entity has independent + project-evidence backing, not just a phrase inside `key_event_details`. + A facility named only in event prose, with no corresponding project + mention, does not qualify — this mirrors ADR 0010's requirement that + enrichment corroborate before binding, not merely mention. + - `confidence` reflects the extractor's own stated confidence in the + *plan-to-operate* framing specifically, not the general event + confidence. A low-confidence event does not get rounded up. + - `evidence_text` is the literal source span naming both the actor and + the facility (already required by `SemanticRelationship`, restated here + because it is the actual safety mechanism: a reviewer or a later + retraction path can always re-check the row against the exact quoted + text). +4. **Contract-version discipline applies the same way it does to the R&R + job-title/relationship-type field split.** Adding a new predicate to a + closed vocabulary that flows through the extraction prompt requires a + `POST_SUMMARY_CONTRACT_VERSION` bump and a reviewed prompt change, because + every future extraction depends on the contract version + (`lineageweave/post_summary.py`). That prompt change is the follow-up + implementation work this ADR authorizes in shape but does not itself + perform. +5. **No new organization/facility catalog row is created by this feature.** + The organization side resolves through the existing + `get_or_create_corporate_entity` path (ADR 0010, ADR 0026) exactly as any + other organization mention would; a planned-facility relationship is + never itself grounds to auto-create an ambiguous or uncorroborated + organization. If the organization side is unresolved, the relationship + row still records the raw actor name (as `SemanticRelationship` already + allows via `subject_name`) — it is enrichment, not a gate on the row + existing. + +## Considered alternatives + +- **A dedicated `project_operator_relationship` table / new relationship + class.** Rejected: `post_summary_semantic_relationship` already is that + table in shape (typed subject/object, closed predicate vocabulary, + mandatory evidence, confidence). A parallel table would duplicate the + schema, the ontology-annotation lookup, and the frontend rendering path + for no behavioral gain, and would need its own migration, API surface, and + tests that the existing channel already has. +- **Infer "operates" directly instead of "plans to operate."** Rejected: + this is exactly the fact-invention risk the gap doc calls out. The source + text supports a plan; asserting operation is a stronger claim the + evidence does not carry. +- **Do nothing; leave facility mentions as unlinked prose.** Rejected: this + is the status quo the gap doc flags as a real product gap — an + entity-and-relationship-shaped fact in the source text is currently + invisible to the ontology/entity graph. + +## Consequences + +- The implementation bumps `POST_SUMMARY_CONTRACT_VERSION`, extends the + extraction prompt and ontology registry, and independently rechecks the + R&R actor, project backing, facility type, and literal evidence span before + retaining a model-emitted `lw_plans_to_operate` row. Missing evidence drops + only that relationship; other supported semantic relationships remain. +- Migration 0138 extends the existing write-time `predicate_code` check with + `lw_plans_to_operate`; the ontology annotation lookup remains the read-time + IRI/label projection. Existing Compose volumes replay that idempotent + constraint replacement through the established migration boundary. +- A downside accepted here: `lw_plans_to_operate` is a LineageWeave-local + (`lw_*`) predicate, not a borrowed PROV-O/SKOS/ODRL term, because none of + the already-adopted standard vocabularies (PROV-O, SKOS, DCT, SOSA, ODRL) + has a term for an announced-but-not-yet-executed operational + relationship. This is consistent with the existing `lw_*` namespace's + purpose (`lw_has_goal`, `lw_has_next_step`, etc. are the same kind of + LineageWeave-specific narrative-structure predicate already in the + vocabulary), not a new category of exception. + +## References (APA 7th) + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization +system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +W3C OWL Working Group. (2012). *OWL 2 Web Ontology Language document overview +(2nd ed.)*. World Wide Web Consortium. https://www.w3.org/TR/owl2-overview/ diff --git a/docs/adr/0143-lineage-isolation-reason.md b/docs/adr/0143-lineage-isolation-reason.md new file mode 100644 index 000000000..ab52378a6 --- /dev/null +++ b/docs/adr/0143-lineage-isolation-reason.md @@ -0,0 +1,129 @@ +# ADR 0143 — Event Lineage distinguishes a genuinely isolated post from a post with no comparison group + +**Decision status:** Proposed +**Date:** 2026-08-22 + +## Context + +`docs/product-technical-gap-baseline.md` (§5, "Lineage coverage") records: +"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." + +Today, when a post has no Event Lineage DAG, `EventLineageSection` +(`frontend/src/App.tsx`) renders one flat message: "No linked posts yet." +This is the same undiagnosability shape ADR 0141 already fixed for R&R +catalog links — a reader cannot tell "reconstruct compared this post +against real candidates and found no relation" from "there was nothing to +compare this post against in the first place." + +### Why "nothing to compare against" is a real, common case + +`reconstruct_group_key` (`backend/app/lineage_ingestion.py`) determines the +comparison scope `reconstruct()` (`lineageweave/reconstruct.py`) ever +considers for a post: the persisted `thread_group_key`, falling back to +`process_unit_id` or `corporate_entity_id`. Critically, +`scripts/import_postgresql_posts.py`'s import sets +`thread_group_key = mapping.thread_group value, or the import's own +process_unit_code` when the source mapping has no explicit thread column — +so `thread_group_key` is populated for essentially every imported post +either way. A non-empty `thread_group_key` therefore does **not** mean "a +real thread was identified"; it can just as easily mean "no explicit thread +mapping existed, so this row fell back to its whole process unit's code." + +The signal that actually distinguishes the two cases the gap doc names is +**group membership size**, not key presence: + +- If a post's `reconstruct_group_key` group (among ABAC-visible, eligible + posts) has **more than one member**, real candidates existed for + `reconstruct()` to compare against. Zero resulting edges is + `reconstruct()`'s own considered conclusion — a real fact about the + channels' similarity scoring, not a data gap. +- If a post's group has **exactly one member** (itself), there was nothing + to compare against at all. Reporting this the same way as the case above + falsely implies the post was checked and found unrelated to everything, + when in fact its true thread was likely never distinguished from a coarse + process-unit/corporate-entity fallback. + +This is independent of, and does not duplicate, the separate open PR fixing +`rebuild_lineage()`'s adjudication-client wiring (a different bug: the +highest-weighted comparison channel not running at all). This ADR's signal +is available regardless of which channels ran; it answers "was there a +comparison group," not "which channels compared it." + +## Decision + +1. `visible_lineage_graph` (`backend/app/lineage_ingestion.py`) gains an + `isolation_reason` computation for the focused-post case + (`GET /api/lineage?post_id=...`, the query `EventLineageSection` drives). + Using data it already fetches (`thread_group_key`, `process_unit_id`, + `corporate_entity_id` on every ABAC-visible eligible post — no new query, + no schema change), it groups the visible set by `reconstruct_group_key` + and reports one of: + - `null` — the post has a non-empty DAG; no reason needed. + - `"no_relation_found"` — the post's group has other visible members, but + `reconstruct()` produced zero edges for it. + - `"no_comparison_group"` — the post is the only visible member of its + group; there was nothing to compare it against. +2. The API response shape stays additive: `{"nodes": [...], "edges": [...], + "truncated": false, "isolation_reason": null | "no_relation_found" | + "no_comparison_group"}`. `isolation_reason` is only meaningful (non-null) + when `focus_post_id` was supplied and the resulting `nodes` list is + empty; it is always `null` for the un-focused landing-view call. +3. `frontend/src/api.ts`'s `LineageGraph` gains `isolation_reason?: string | + null`. `EventLineageSection`'s existing "No linked posts yet." branch + (the `!hasLinks` case only — the doc's complaint is specifically about + the DAG being presented as complete, not about the separate + knowledge-graph-edge-based `direct`/`indirect` lists `RelatedPostsSection` + already reports honestly) replaces that one string with a lookup: a + specific message for `no_relation_found`, a different one for + `no_comparison_group`, and today's generic message as the fallback when + `isolation_reason` is `null`/absent (e.g. an older backend). +4. This does not change `reconstruct()`'s clustering, scoring, or channel + weights, does not add a migration, and does not gate or block anything — + it is read-only diagnostic text, the same discipline ADR 0141 used. + +## Considered alternatives + +- **A corpus-wide aggregate ("N% coverage") instead of a per-post reason.** + Rejected for this iteration: the gap doc's complaint is specifically about + a reader opening one post and being told a DAG is complete when it isn't + diagnosable; a global percentage doesn't answer "why is *this* post's DAG + empty." A corpus-wide aggregate is a reasonable follow-up for an + operator-facing rebuild/health view, not a substitute for the per-post fix. +- **Treat `thread_group_key` presence as the signal.** Rejected: as shown + above, the import path back-fills `thread_group_key` from the process + unit code whenever no explicit thread mapping exists, so presence alone + is not evidence a real thread was identified. Group *size* is the honest + signal available today without a schema change. +- **Wait for the adjudication-client wiring fix to land first.** Rejected: + that fix (a different, already-open PR) changes how many real edges + `reconstruct()` finds; it does not change whether a post had any + candidates to compare against in the first place. The two fixes are + complementary, not sequential — shipping this one first does not need to + be redone once the other lands. + +## Consequences + +- A demo/synthetic dataset where most posts fall back to a shared + process-unit group will show few `no_comparison_group` results (most + groups have many members) and mostly `no_relation_found` instead — which + is an honest reflection of today's coarse fallback grouping, not a defect + in this feature. A future fix to make thread identification more precise + at import time is separate, tracked work. +- `isolation_reason` is computed on every focused `GET /api/lineage` call by + scanning the already-fetched ABAC-visible post list once (O(n) group-by); + no new database round-trip is added. +- The connected-component BFS (used to decide whether a focused post has + *any* visible neighbor before falling back to `isolation_reason`) now + filters `post_lineage_edge` rows to both endpoints being ABAC-visible + before building the neighbor graph. An edge to a hidden sibling post + previously made the BFS treat the focus post as connected, silently + dropping `isolation_reason` to `None` -- which leaked the *existence* of + a hidden relationship through an absence rather than a value, and hid a + true isolation fact from an otherwise-authorized viewer. Found + independently on a divergent history line (PR #493) and ported here + 2026-08-25 (a peer session flagged the collision via cross-session + coordination). diff --git a/docs/adr/0144-superseded-index-migration-replay.md b/docs/adr/0144-superseded-index-migration-replay.md new file mode 100644 index 000000000..34b21db43 --- /dev/null +++ b/docs/adr/0144-superseded-index-migration-replay.md @@ -0,0 +1,42 @@ +# ADR 0144: Skip superseded search indexes during migration replay + +- Status: Accepted +- Date: 2026-08-23 + +## Context + +The Compose migration runner deliberately replays an idempotent migration +window on every existing volume. Migration 0035 creates two large legacy body +search indexes; migration 0036 replaces them with normalized rendered-text +indexes and drops the legacy pair. Replaying both files therefore rebuilt and +discarded hundreds of megabytes of indexes on every service restart. + +## Decision + +Migration 0035 checks for each corresponding 0036 successor index before +running its `CREATE INDEX CONCURRENTLY`. If the successor exists, the legacy +build is skipped. A fresh database still follows the original ordered path: +0035 creates its indexes, 0036 creates the normalized successors, then removes +the legacy pair. Existing volumes retain the replay safety of the current +migration runner without repeating superseded work. + +The condition uses PostgreSQL `to_regclass` through psql's native `\gset` and +`\if` commands. No application-side migration ledger or second migration tool +is introduced. + +## Consequences + +- Existing-volume restarts no longer rebuild indexes that the next migration + immediately deletes. +- Fresh initialization remains compatible with the historical migration + order. +- A future non-idempotent migration family still requires the migration ledger + already identified by `docker/postgres-init/migrate.sh`; this decision does + not silently broaden that scope. + +## Verification + +- A static replay contract test requires both successor guards. +- Compose replay must exit successfully with only the normalized indexes + present, then a second replay must not enter `pg_stat_progress_create_index` + for the legacy names. diff --git a/docs/adr/0145-dashboard-home-route.md b/docs/adr/0145-dashboard-home-route.md new file mode 100644 index 000000000..7f3a55e44 --- /dev/null +++ b/docs/adr/0145-dashboard-home-route.md @@ -0,0 +1,94 @@ +# ADR 0145: Dashboard replaces the Board as the `/` landing route + +- Status: Accepted +- Date: 2026-08-24 + +## Context + +`/` opened directly onto the Board (`PostList`): a find-and-filter surface, +not a landing page. New and returning readers had no single place to see +which posts and which projects mattered right now. The product brief asks +for a news-portal-style front page ranking "important posts" and "important +projects", explicitly forbidding an invented/hand-tuned weight and naming +[TEPP](https://github.com/ContextualWisdomLab/TEPP) and +[fast-mlsirm](https://github.com/ContextualWisdomLab/fast-mlsirm) (with its +LLM-as-a-Judge step) as the required ranking sources. + +This repo already computes exactly that signal. ADR 0003's staged +`fast-mlsirm` integration ships `report_period_score` (a Fixed-Item +Parameter Calibration-linked mean theta) per grouping and period, for a +`GROUPING_KINDS` set that already includes `"project"` alongside +`process_unit`, `corporate_entity`, `thread_group`, and `team` (see +`backend/app/report_ingestion.py`). Each grouping's `report_member_score` +rows carry a per-post `theta_eap` — the same LLM-as-a-Judge-to-IRT pipeline +scored at the individual post level. `GET /api/reports/project` and +`GET /api/reports/project/{period_code}` already serve this, unmodified. + +`TEPP` has no live HTTP transport yet (`lineageweave/tepp_client.py`'s +default transport raises `TeppNotAvailable`; see also +[[tepp_readiness_watch]]). ADR 0003 already forbids growing a second +measurement engine to route around that. The Dashboard therefore does not +attempt a TEPP-sourced importance signal — using it would mean either +inventing one or duplicating TEPP's model, both excluded by standing +decisions. This is a fail-closed omission, not a silent one: the honest +state is "not available yet," matching every other TEPP integration point +in this repo. + +When no project period report has been calibrated at all (a fresh +deployment before any `rebuild` has run), the post list falls back to the +existing RankWeave fused ranking (`GET /api/rankings`, ADR 0024) — also a +real, paper-grounded fusion of visible-post channels, never an invented +score, just not fast-mlsirm-calibrated. + +## Decision + +1. Add `Dashboard` (`frontend/src/components/Dashboard.tsx`) as a new + `WorkspaceDestination`. It calls only existing endpoints — no new backend + route — reusing `fetchPeriodReportIndex`/`fetchPeriodReports` (grouping + kind `"project"`) for "Important projects" (sorted by `mean_theta` desc) + and "Important posts" (each grouping's members deduplicated by post, + sorted by `theta_eap` desc), and `fetchRankings` as the RankWeave + fallback when no project theta exists yet. +2. `/` (no `?workspace=` param) now resolves to `"dashboard"` instead of + `"board"`; the Board stays one click away as the first Workspace nav + item after Dashboard. Global search and admin board-tool deep links + still route explicitly to `"board"`, unchanged. +3. Every card's next action is "open this post" — clicking a project card + opens its highest-theta member post, since no dedicated project detail + view exists yet; clicking a post card opens that post directly, reusing + the same `postToOpen`/`changeDestination("board")` hand-off + `CalendarPanel` and `CustomerMasterPanel` already use. +4. No score renders without stating its source: post/project theta badges + read `fast-mlsirm θ {value}`; RankWeave-fallback posts read + `RankWeave fusion`. Empty states name the next action ("Ask an + administrator to run a period-report rebuild") rather than a bare "no + data" message. + +## Consequences + +- No new database objects, migrations, or backend endpoints — this is a + frontend-only aggregation of two already-shipped, already-tested read + paths, per Ponytail's reuse-before-build rung. +- The Dashboard is silent about `TEPP` rather than fabricating a temporal + signal from it; that gap closes only when TEPP ships a live transport + (tracked in [[tepp_readiness_watch]]), at which point it becomes a third + ranking input, not a replacement for the fast-mlsirm theta. +- A grouping kind can, in principle, hold zero `"project"` rows if no post + ever resolved a `secondary_grouping_key` (project mention). The Dashboard + treats that the same as "not yet calibrated" — RankWeave fallback, honest + empty-state copy — rather than erroring. +- i18n: all new Dashboard strings ship translated into ko/zh/ja/vi in the + same PR, consistent with this repo's existing translation discipline. + +## Related + +Builds on [ADR 0003](0003-fast-mlsirm-report-integration.md) (fast-mlsirm +integration decision and staging), the existing `report_ingestion.py` / +`period_report.py` calibration pipeline, [[tepp_readiness_watch]] (TEPP has +no live transport yet), and ADR 0024 (RankWeave fused post ranking). + +## References + +Bock, R. D., & Mislevy, R. J. (1982). Adaptive EAP estimation of ability in +a microcomputer environment. *Applied Psychological Measurement, 6*(4), +431–444. https://doi.org/10.1177/014662168200600405 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..18ccef03b 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" . @@ -138,6 +156,13 @@ rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; :lookupCode "edge_mention_organization" . +:observedCustomerIdentityIn a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "customer identity observed in post" ; + rdfs:comment "A governed Customer Master identity is supported by a source post through a persisted cross-post judgment (post_customer_identity_mention)." ; + :lookupCode "edge_customer_identity_observation" . + ################################################################# # Object properties -- entity_relationship_type # (post_counterparty_entity.relationship_type_code) @@ -160,19 +185,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 +338,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 +463,305 @@ ################################################################# :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 . + +:plansToOperate a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:label "plans to operate"@en ; + rdfs:comment "An explicit source-backed intention to operate a planned facility, not evidence that the facility is already operating."@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 +786,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 +800,780 @@ :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:subClassOf time:TemporalEntity ; + 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_plans_to_operate" ; :predicateIri :plansToOperate ; rdfs:label "Plans to operate"@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..d869bea1b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,26 +1,1144 @@ # 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 reader/source-context worktree and all +> 56 open PRs, compared with protected `main`, the UI/UX Standard Guide v3.0, +> ADR 0118, the accepted TEPP contracts, and contextual-orchestrator. Real +> source identifiers are deliberately replaced with case labels; they must not +> enter repository artifacts. + +## 1. Exact-head evidence + +### 1.1 Current continuation head + +Observed at `2026-08-24T06:45:00+09:00`: protected `main` and `origin/main` +remain `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. This loop's continuation +head is PR #490 `feat/board-source-detail-state-filter` at +`154a13ef180a5f5e859c52fe056f4925c7fe2757` (docs refresh follows). The live queue has 56 open PRs, +all targeting protected `main` and GitHub-`MERGEABLE`. Protected merge stays +blocked by ruleset `18156473` (two approvals and approval-after-last-push) +and ruleset `21065108` (no force-push). This loop closed leftover-pair +next-action jargon on that head (ADR 0049: `Open {post}, then read Post +quality criterion {criterion}.`) and kept saved evaluation scores when the +evaluation channel is down. Worker factory review threads +`PRRT_kwDOT22WIM6biC_J` / `_K` / `_L` are resolved. Leftover-map PRs +#533 `ef38a8473bcf`, #532 `53f84127cd2a`, #531 `e359dcd28e5c`, #530 +`2ca0974625e8`, and #529 `54f3f69fb3f7` had the inherited unauthenticated +AdminPanel TypeScript break repaired in source. Those leftover-map PRs still fork `ef6f5a5f` +independently rather than stacking on #426/#490. Local evidence on the +continuation head: worker pytest `20` passed twice, frontend Vitest `358` +passed twice, oxlint 0, Storybook static build completed. No real +organization or person names are used in this receipt. + +Earlier observed at `2026-08-23T10:31:20Z`: protected `main` and `origin/main` are both +`ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. The dirty continuation worktree is +based on `65f0a412c18286434c272eb8c4b38efeb2cd45c0`; relative to `origin/main` +it is `821` commits ahead and `0` behind, so this checkout is not a +merge-ready PR head and its local changes must not be attributed to any open +PR without a fresh branch/commit comparison. + +Conversation-history review (example: a sidebar with New conversation and +selectable saved conversations): Ask Agent already ships list/select/new on +this checkout (ADR 0126, migration `0105`, `AskAgentPanel`, Vitest +`restores saved Ask Agent history and can start a new conversation`). Open +PRs in the current queue, including #484, #482, #258, #481, #468, #421, and +#418, do not add the same account-owned history to each post's +Ask-about-this-lineage surface. That gap is closed here as ADR 0136: +`post_ask_session` / `post_ask_turn` stay account-owned and post-scoped, +`post_chat_result` remains the seeded/cache store, and the popup starts a +new conversation so the seeded dump stays until the reader selects a saved +thread. TEPP topic modeling of how many posts can connect, and how many +lineages form under temporal precedence, remains deferred. No real +organization or person names are used in this receipt. + +Current local changes close evidence-backed defects without weakening trust +boundaries: Global Ask now keeps question retrieval when a selected post is an +anchor, filters candidates before limits through the same reader-eligibility +and corporate-visibility boundary, bounds both chat request bodies at 4,000 +characters, and preserves formal Korean `-니다` endings. The reader can open +Ask from a post, see and clear the starting evidence, switch saved +conversations without a stale anchor, navigate browser history without stale +state, confirm CJK IME composition without accidental submission, and use a +native modal mobile navigation drawer. Reader-facing failures now share the +token-backed `ExceptionAlert` / `SummaryStatus` surface (ADR 0134): text +identifies the failure, next-action copy is present, recovery controls meet +`--size-control-min`, light/dark `--color-exception-*` tokens replace +color-only red paragraphs, and raw exception types, stacks, OIDC diagnostics, +and 5xx provider payloads stay hidden (ADR 0123 / CWE-209). The bounded Python +unit partition passed `750` tests with `11` skips; backend integration +contracts passed `21` tests and skipped `115` live-stack cases. After leftover criterion landing and catalog-unbound / dropped-channel / +confident-negative next-action copy (ADR 0049 / ADR 0135), +`cd frontend && pnpm run test` passed all `255` tests, +leftover landing ran twice, `pnpm run build-storybook` completed, lint +was clean, leftover/Null-channel pytest passed `4` focused cases, and +Playwright loaded the Running Lineage Queued Storybook scene twice with +kind-exact next-action text, a Refresh control, no Start reconstruction +control, and zero page errors. After the +exception-message UI (ADR 0134) and analysis-result next-action flow +(ADR 0135), an earlier `cd frontend && pnpm run test` passed all `238` tests, +`pnpm run build-storybook` completed, and Playwright loaded the running +lineage queued scene twice with kind-exact next-action text, a Refresh +control, no Start reconstruction control, and zero page errors. Pinned +Corepack frontend lint and the production build pass on the current source. The production chunk warning, skipped live-stack cases, +repository-wide coverage, exact-source authenticated browser run, and hosted +gates remain open. + +Subsequent source-reference research changes are not covered by those partition +totals. The focused research/security/KG partition passed `36` tests at 100% +branch coverage for `source_research.py` and `source_research_ingestion.py`; the +broader research, migration-replay, and shared HTTP-client partition passed +`69` tests. The source-research panel passed all `3` focused tests, and its +locale partition passed `28` tests. These checks do not prove a live PostgreSQL +migration, external SearXNG/orchestrator calls, or an authenticated API/browser +path. + +The current ADR 0137 change closes the per-post customer-identity gap in +source: `(source_system_code, source_customer_code)` now requires at least two +authorized eligible posts, contextual-orchestrator candidate resolution, a +versioned fast-mlsirm Judge result with persisted IRT categories, external +corroboration, and a unique catalog outcome before promotion. Migration `0137` +keeps the judgment, exact post evidence, stable binding, and preferred/former/ +alternate names in normalized tables. Promoted observations project as the +distinct `edge_customer_identity_observation` ontology/KG relation; source-post +authorization scope is not rewritten. The PostgreSQL importer automatically +reconciles only changed customer keys and reports unavailable providers without +rolling back imported source records. The identity/ingestion partition passed +`19` tests at 100% statement and branch coverage (`201` statements, `46` +branches); schema/replay checks passed `23`, focused UI/i18n checks passed `6`, +and the production frontend build passed. The exact-current authorized catalog +and corroborated-promotion API contracts passed against fresh migrated +PostgreSQL databases after Keycloak recovered. A live external +orchestrator/SearXNG/TEPP import, authenticated rendered review, hosted checks, +and independent review are not yet claimed. + +The current temporal-relation correction makes explicit chronology an +OWL-Time `time:before` edge from the earlier release/introduction milestone to +the later milestone. Post-summary contract v19 separates the focused +`RELATIONS:` extraction from the larger evidence response, requires every +explicit pair, and rejects replacing a named base-to-variant pair with another +nearby product enumeration. A persisted older summary now returns immediately +as explicitly stale instead of blocking the reader on sequential orchestrator +calls; durable regeneration remains the operator backfill path. + +Authorized, non-identifying runtime verification processed exactly one target +with no failure and persisted v19 as current. The authenticated API returned +one requested earlier-to-later edge, distinct temporal-entity endpoints, the +OWL-Time IRI/label, evidence, and confidence. The summary and focused Knowledge +Graph calls completed in approximately 0.23 and 0.19 seconds. The graph exposes +source, relation, target, evidence, and confidence without requiring SVG hover; +the served production asset contains the directed-graph name, visible direction +instruction, and evidence-table contract. Browser-rendered visual comparison +remains open and is not claimed. + +Two Compose bottlenecks found during this verification are fixed at their +shared boundaries. `backend/Dockerfile` now caches the locked Rust/Python +dependency environment before copying application source: the clean dependency +layer compiled in 4 minutes 47 seconds, an unchanged rebuild completed in 2.2 +seconds, and a later Python-source rebuild completed in 27 seconds without +recompiling `fast-mlsirm`. ADR 0144 and migration 0035 now skip legacy body +search indexes when migration 0036's normalized successors exist; a complete +existing-volume replay completed in 7 seconds with zero active or legacy index +builds. Focused backend/migration checks passed `102` tests with one optional +orchestrator integration skip, and frontend Knowledge Graph/i18n checks passed +`30`. The latest complete coverage-instrumented full Python suite, at +`e4ce49d1`, passed `987` tests with `17` skips; production Python source measured `87%` branch-aware +coverage across `8,071` statements and `2,506` branches, with `790` missed +statements and `397` partial branches. The current composition then passed +`90` focused Python tests with one optional integration skip. The temporal Python delta itself has +`7/7` changed executable statements and `2/2` changed branches covered. +The full frontend suite passed `354` tests, and frontend lint/build plus +`git diff --check` passed. The new official Vitest V8 production-source +measurement reports `95.73%` statements, `87.04%` branches, `94.57%` +functions, and `97.37%` lines; tests, Storybook scenes, and test setup are the +only excluded non-production files. Repository-wide 100% coverage and visual +browser acceptance remain separate open gates. + +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 + +Live queue at `2026-08-24T06:50:00+09:00` is 56 open PRs, all based on +protected `main` `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. Continuation +head is #490 `884edcd65e68`. Leftover-map family still forks `main` +independently: #533 length, #532 cosine, #531 inner product, #530 residual, #529 rank, +#527 observed/expected, #522 two-axis distance, #521 comparison strip, +#519 axis share, #518 complete-case, #485 criterion landing, #481 +interaction-map. Hosted Checks on the heads repaired this loop are queued, +not a stop. Independent leftover-map PRs should stack onto #426/#490 rather +than keep inheriting the unauthenticated AdminPanel TypeScript break. + +Ruleset `18156473` requires two approvals, an approval after the last push, +resolved conversations, dismissal of stale approvals, and seven central +workflows. Ruleset `21065108` prohibits force-pushes with no bypass. No +protected merge is claimed from this listing. + +```text +#533 ef38a8473bcf #532 53f84127cd2a #531 e359dcd28e5c +#530 2ca0974625e8 #529 54f3f69fb3f7 #527 df18ed69ad43 +#525 e4d6717c147c +#524 4b1691b4be7d #522 2ab96809c374 #521 d9d2207360ea +#519 29bff9270764 #518 3117823ffc34 #496 288125acb1e6 +#493 499c8b1bc4cd #490 884edcd65e68 #485 f17a116dd60d +#484 c5c9911c102c #482 c38c08d6f464 #481 329449790cc6 +#480 f18b421d8522 #479 f8bb4102719e #474 025bb3df4e5a +#468 228f13dd5e32 #463 3773d40c74df #455 dab57fcadb3c +#454 f30e2523e9c3 #453 98fcf052b883 #452 09e0ec034ca1 +#451 e84fa8d20c7d #450 c624e919d880 #449 0cc40bc75a80 +#448 3c1506a30101 #447 af6317237bc5 #446 8f9698993077 +#445 160ca908fce1 #444 4af7d4b79689 #443 11fc2af36960 +#442 8e6f24df1827 #441 5e59e7d1a0a3 #434 9f506a962f73 +#426 3b7c3e29d608 #422 c54b172e439c #421 2fc08835485d +#419 b51b97be0746 #418 ed99c40a22cc #417 c5c0929c68ab +#415 d8590f1f81db #405 0b1b1fcfed87 #394 cf9505b75948 +#393 4ddd3a83aaa7 #387 6bcd52f1d8b1 #383 ab5d4c272532 +#368 0f61d66ed1a8 #355 6fc22a9471bf #349 bef4a858b2f0 +#258 f0b5234db6d3 +``` + +Historical snapshot retained below. Earlier in-scope leftover/workspace heads +included leftover-map locale follow-up `#489` `7f0368ec7d04`, leftover +landing `#485` `c2102f932e06`, leftover map `#481` `d192e8f40fff`, Event +Lineage interval `#484` `0d8187a5d529`, channel evidence `#387` +`c34681fdc692`, org-chip `#482` `42f7c4e81289`, image-region `#405` +`0b1b1fcfed87`, Ask citation `#419` `b51b97be0746` / `#418` +`ed99c40a22cc`, workspace board `#258` `ea143748cdda`. The following +open-PR heads were refreshed at `2026-08-23T12:02:11Z`. Newer PRs in the +live listing above supersede that snapshot. + +Bounded gate evidence: PR #394's earlier Corepack/Undici pnpm-download failure +is superseded by terminal checks on unchanged head `cf9505b75948`; it still has +no independent approval. PRs #349/#355/#383/#417 have exact-head coverage +change requests, and #387/#405 remain blocked by older change-request decisions. +The earlier `2026-08-23T10:52:15Z` refresh of #258 +found head `bcfb67f9b88dd62af6b6886dac4b846b6cbd0ce4` green with all 88 +threads resolved, but it is now historical. Current head +`ea143748cdda9aa8be24f7ee8f282e56d6fc7adb` uses `math.isfinite` for +lineage-score validation and preserves early project-key validation; its `26` +focused project-history tests pass and all current threads are resolved. At +`2026-08-23T12:03:02Z`, Strix had failed closed after NVIDIA rate limiting +and a provider-less LiteLLM fallback; its completed report and SARIF contained +zero source findings. The active OpenCode change request is on historical head +`6dc040c6b3ea`; the current OpenCode workflow is green but does not provide a +current-head approval, and exact-head approvals remain zero. Active ruleset +`18156473` still requires two approvals and approval after the last push with +no bypass actor, so #258 remains blocked. The other +thread/check observations remain bounded to the earlier audit and are not +restated as current. PR #481's prior exact head `732f2b25f8ce` had one Strix +failure that explicitly reported provider/infrastructure errors; its two cited +source locations did not contain the claimed AWS credential or syntax error, +so no scanner-driven source patch was justified. Prior head +`7ff509e545fa8cdcba91acaeca25e46e40bab44e` also makes pair selection and +distance match the persisted two-axis map, preserves simultaneous +closest/farthest emphasis, exposes nested SVG buttons to assistive technology, +and applies migration 0104 in the real API fixture. Local evidence is backend +`661 passed, 114 skipped`, frontend `144 passed`, lint, TypeScript, and +production build success; all review threads are resolved. Hosted Strix later +failed closed after Nvidia returned 429, the first fallback ended without an +authoritative lifecycle report, and the `openai-direct` fallback reached +LiteLLM without a recognized provider prefix. The scan logged zero +vulnerabilities before those provider failures, but produced no authoritative +report; this is infrastructure evidence, not a source finding. Intermediate head +`94dc79ae6c7f40118c922c4e0042fad2b93daf85` additionally contains the merged +#488 criterion-node interaction. Current head +`d192e8f40fffd237525bf3b62583c36b2941a89a` removes redundant empty/rank guards +and adds shape, disconnected-observation, and overflow regressions without +inventing map coordinates. The leftover-map and period-report partition passes +`24` tests and all review threads are resolved. The exact-head full suite and +Strix are still running; qualifying approvals remain absent, so it is still +`BLOCKED`. +PR #482 head `42f7c4e812899131e255f117512e7a7081e7dec5` removes the unused VOC +alias query, preserves the direct Keyman companion-label read, keeps legal +suffixes distinct during companion matching, and includes the related-chip +caption in its accessible name. Its parent `116af49f403d` passed `25` focused +Python tests with 100% branch coverage for `organization_alias.py`; frontend +chip tests and lint passed. On the current child head, both focused related-chip +regressions, lint, and TypeScript no-emit checking pass. All review threads are +resolved. Hosted Strix failed after three Nvidia 429s and the same unrecognized +LiteLLM fallback provider; it reported zero vulnerabilities before failing +closed without an authoritative report. Exact-head approvals remain zero, so +it is still `BLOCKED`. PR #429 is also `BLOCKED` for +independent review; a later Strix run succeeded on the same exact head at +`2026-08-22T13:32:42Z`, superseding the earlier failed run. Auto-merge state is +intentionally not used as merge evidence. + +PR #468 head `228f13dd5e32b5b0ee72d5ba7cfcd26f17c7a1c4` has all review threads +resolved. Its affiliation lookup cardinality is already bounded by database +uniqueness constraints, so an application-side multi-row check would duplicate +the native invariant. Strix failed with the same provider chain as #481 and no +authoritative source finding; exact-head approvals remain zero. PR #484 head +`0d8187a5d5298489444e1734412be277ea584b5e` now normalizes aware `created_at` +values to their UTC day in both the shared interval helper and migration 0105 +backfill. The +09:00 near-midnight regression failed before the repair; the +interval, ingestion, replay, and live-schema partition now passes `29` tests. +All review threads are resolved; the full backend/frontend gates are green, +Strix failed closed through the same provider-infrastructure chain, and no +approval is claimed. PR #485 head +`c2102f932e0674175387726e0467f730c3c8af36` restores the existing OIDC return +URL flow and removes an unreachable unauthenticated AdminPanel render that made +the frontend TypeScript build fail, and now retains a regression for the +session-storage fallback. Production build, lint, the focused login and +leftover-pair landing tests, and the hosted frontend job pass on this head; all +review threads are resolved. The hosted full suite is also green; Strix failed +closed through the same provider-infrastructure chain, and exact-head approvals +remain zero. + +PR #486 head `841f02418a9f109cd8d58894470c6b3a5fe5db3f` strengthens the +authenticated modal test to require focus restoration to the same opener and +forces the site-map test to navigate to Calendar and verify the URL, persistent +navigation state, and destination heading. Its 120-second test budget covers +the bounded serial fixture waits while retaining shorter step limits. Lint and +TypeScript pass. It merged at exact head +`841f02418a9f109cd8d58894470c6b3a5fe5db3f` as merge commit +`f11a2cb546792622932011587fe6f6aa54c79948`, but only into the historical +`docs/customer-master-scope-adr` branch. The earlier Chromium run predates the +strengthened assertions, so an exact-head browser rerun and eventual +protected-main gate remain open; this merge is not protected-main evidence. + +PR #488 merged old exact head +`46434836e9b06453dabf6f3bfd72bbc19b3199cd` as +`94dc79ae6c7f40118c922c4e0042fad2b93daf85`, only into #481's feature branch. +That head makes only leftover-pair criterion nodes interactive; non-pair +criteria remain honest visual context. The locale repair was pushed after the +merge and therefore is not part of #488's merge evidence. PR #489 isolates that +single repair at `7f0368ec7d043a33197f6198b2b38a7560610fc5`: connector tooltips +use the existing i18n catalog and the displaced Event Lineage multi-locale +assertion is restored. Its exact three-file diff passes `154` frontend tests, +lint with only the existing Fast Refresh warnings, and forced TypeScript build; +both hosted test jobs are green, while qualifying independent approval remains +absent. Both branches remain stacked behind blocked #481, so neither merge is +protected-main evidence. + +The LineageWeave-specific central heartbeat is not yet deployed. Central +`.github` PR #1086 now has exact head +`aeb096a52c5f4c2647f05f54f0aa6b17200a350f` after a normal merge of protected +central `main`; it runs the existing repository-wide test suite and measures all +three modified scheduler modules at 100% statement and branch coverage. Local +exact-head evidence is `1426 passed, 1 skipped, 16 subtests`, `2159/2159` +statements, and `868/868` branches, with Actionlint, docstrings, compileall, and +diff checks green. All 19 review threads are resolved, but hosted checks are +running and historical change-request decisions have not been replaced by a +qualifying exact-head approval. The workflow inventory contains only +pull-request runs for this caller; no `schedule` run from protected central +`main` exists yet. The central generic scheduler on protected `main` does run +`*/30 * * * *` and `*/15 * * * *`, but that is not evidence that the proposed +minute-4 LineageWeave repair caller is operational. + +## 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; no-JS fallback fixed + in this worktree:** `i18n.ts` synchronizes `document.documentElement.lang` + after locale selection and `i18n.test.ts` covers the supported locales. + `frontend/index.html` now renders a visible `

+

+ LineageWeave requires JavaScript to run. Please enable JavaScript in + your browser and reload this page. +

+

+ LineageWeave는 JavaScript가 필요합니다. 브라우저에서 JavaScript를 + 활성화한 후 새로고침해 주세요. +

+
+
diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..08900ee6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,15 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run --no-file-parallelism --maxWorkers=1", + "test:coverage": "vitest run --no-file-parallelism --maxWorkers=1 --coverage", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -27,6 +28,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", + "@vitest/coverage-v8": "4.1.10", "jsdom": "^30.0.1", "oxlint": "^1.75.0", "playwright": "^1.62.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 485f53a80..83106da96 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -45,6 +45,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.4 version: 6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -65,7 +68,7 @@ importers: version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) packages: @@ -151,6 +154,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1010,6 +1017,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1082,6 +1098,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1261,6 +1280,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1269,6 +1292,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1294,6 +1320,21 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1410,6 +1451,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1632,6 +1680,10 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1977,6 +2029,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -2491,7 +2545,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10)': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -2501,7 +2555,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -2575,6 +2629,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -2658,6 +2726,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.14: {} @@ -2816,6 +2890,8 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + has-flag@4.0.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -2826,6 +2902,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + indent-string@4.0.0: {} is-core-module@2.16.2: @@ -2844,6 +2922,21 @@ snapshots: dependencies: is-inside-container: 1.0.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} jsdom@30.0.1: @@ -2943,6 +3036,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + mdn-data@2.27.1: {} min-indent@1.0.1: {} @@ -3215,6 +3318,10 @@ snapshots: strip-indent@4.1.1: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} @@ -3295,7 +3402,7 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) @@ -3319,6 +3426,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) jsdom: 30.0.1 transitivePeerDependencies: - msw diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..edd0c2407 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; @@ -84,6 +107,14 @@ gap: 0.75rem; } +.app-header-brand { + color: var(--text-h); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .app-header-title { font-size: 1.4rem; font-weight: 700; @@ -97,6 +128,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 +240,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 +361,49 @@ color: var(--color-text-heading); } +.mobile-drawer-backdrop { + position: fixed; + inset: 0; + width: 100%; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + padding: 0; + border: 0; + z-index: var(--z-drawer-backdrop); + background: rgba(0, 0, 0, 0.5); +} + +.mobile-drawer-backdrop::backdrop { + background: transparent; +} + +.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; @@ -141,8 +428,17 @@ color: var(--color-footer-text); } +.login-brand { + margin: 0; + color: var(--color-primary); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + /* GNB Navigation (§2.3.1) */ -.buyer-gnb { +.workspace-gnb { display: flex; align-items: center; height: var(--gnb-height); @@ -155,7 +451,7 @@ z-index: var(--z-gnb-pulldown); } -.buyer-gnb-item { +.workspace-gnb-item { display: flex; align-items: center; height: 100%; @@ -170,15 +466,15 @@ transition: color 0.15s ease; } -.buyer-gnb-item:hover { +.workspace-gnb-item:hover { color: var(--color-text-heading); } -.buyer-gnb-item[aria-current="page"] { +.workspace-gnb-item[aria-current="page"] { color: var(--color-primary); } -.buyer-gnb-item[aria-current="page"]::after { +.workspace-gnb-item[aria-current="page"]::after { content: ""; position: absolute; bottom: 0; @@ -188,101 +484,1318 @@ background-color: var(--gnb-active-indicator-color); } -.buyer-gnb-tools { - margin-left: auto; - display: flex; - align-items: center; +/* 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; } -/* 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; + 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-primary:hover { - background: var(--color-btn-primary-hover); +.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, +.ask-agent-error, +.admin-error { + color: var(--color-exception-heading); + background: var(--color-exception-background); + border: 1px solid var(--color-exception-border); + border-left: 4px solid var(--color-exception-accent); + border-radius: var(--radius-panel); + padding: var(--space-panel-block); +} + +.status-alert { + color: var(--color-exception-text); +} + +/* 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); +} + +/* Dashboard: the `/` news-portal landing page (ADR 0145). */ +.dashboard-surface { + width: min(100%, var(--layout-content-width)); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.dashboard-masthead { + padding-bottom: 1rem; + border-bottom: 2px solid var(--color-primary); +} + +.dashboard-masthead h2 { + margin: 0.15rem 0 0.35rem; + font-size: clamp(1.5rem, 2.2vw, 2rem); + letter-spacing: -0.04em; +} + +.dashboard-masthead p:last-child { + color: var(--text-muted); + max-width: 60ch; +} + +.dashboard-rail, +.dashboard-grid-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.dashboard-rail h3, +.dashboard-grid-section h3 { + margin: 0; + font-size: 1.1rem; +} + +.dashboard-project-list { + list-style: none; + margin: 0; + padding: 0.25rem 0; + display: flex; + gap: 0.75rem; + overflow-x: auto; + scroll-snap-type: x proximity; +} + +.dashboard-project-card { + flex: 0 0 auto; + scroll-snap-align: start; +} + +.dashboard-post-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 0.75rem; +} + +.dashboard-card-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + width: 100%; + min-width: 14rem; + min-height: var(--size-control-min); + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.dashboard-card-button:hover, +.dashboard-card-button:focus-visible { + border-color: var(--color-accent-info); + box-shadow: inset 0 0 0 1px var(--color-accent-info); +} + +.dashboard-card-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.dashboard-rank { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; + color: var(--color-primary); +} + +.dashboard-card-title { + font-weight: 600; + color: var(--text-h); +} + +.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"], +.ask-agent-history-item[aria-current="page"] { + 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-exception-text); + 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-exception-text); +} + +.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; + gap: 0.35rem; +} + +.source-lineage-presence-item { + color: var(--text); +} + +.source-lineage-presence-item.is-missing { + color: var(--text-muted); +} + +.post-card-badges { + max-width: 12rem; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; +} + +.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; +} + +.source-lineage-combination { + border-color: var(--color-accent-info); + background: var(--color-accent-info-background); + color: var(--text-h); + gap: 0.3rem; +} + +.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 +1813,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 +1940,55 @@ 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; + min-width: var(--size-control-min); + min-height: var(--size-control-min); +} + +.summary-status-processing { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.summary-status-unavailable { + border: 1px solid var(--color-exception-border); + border-left: 4px solid var(--color-exception-accent); + background: var(--color-exception-background); + border-radius: var(--radius-panel); + color: var(--color-exception-text); +} + +.summary-status-unavailable strong { + color: var(--color-exception-heading); +} + +.summary-status-unavailable span, +.summary-status-unavailable small { + color: var(--color-exception-text); +} + +.summary-status-inline { + margin: 0; + padding: 0.45rem 0.65rem; +} + .popup-live-body-warning { margin: 0.75rem 0 1rem; padding: 0.65rem 0.75rem; @@ -405,6 +2015,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; @@ -416,6 +2064,60 @@ opacity: 0.8; } +.source-research-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.source-research-heading p { + margin: 0; +} + +.source-research-list, +.source-research-citations { + list-style: none; + margin: 0; + padding: 0; +} + +.source-research-list > li { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); +} + +.source-research-list header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.source-research-status { + padding: 0.2rem 0.55rem; + border: 1px solid var(--border); + border-radius: var(--radius-chip); + font-size: 0.8rem; + font-weight: 700; +} + +.source-research-status.is-not_enough_information { + background: var(--color-accent-background); +} + +.source-research-evidence { + padding-left: 1rem; + border-left: 3px solid var(--color-accent-border); +} + +.source-research-citations li + li { + margin-top: 0.5rem; +} + .lineage-list { list-style: none; padding: 0; @@ -487,18 +2189,84 @@ margin: 0 0 1.25rem; } -.lineage-dag-group figcaption { - font-size: 0.85rem; - opacity: 0.8; - margin-bottom: 0.4rem; +.lineage-dag-header { + margin-bottom: 1rem; } -.lineage-dag svg { +.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-radius: 2px; + border-color: var(--color-accent-info); +} + +.lineage-dag-legend-branch { + width: 0.65rem; + height: 0.65rem; + border-radius: 1px; + border-color: var(--color-accent-orange); + background: var(--badge-actor-organization-bg); + transform: rotate(45deg); +} + +.lineage-dag-legend-current { + border-color: var(--text-h); + border-width: 3px; + box-shadow: 0 0 0 2px var(--surface), 0 0 0 3.5px var(--text-h); +} + +.lineage-dag-viewport { + overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.lineage-dag-group figcaption { + font-size: 0.95rem; + font-weight: 700; + opacity: 1; + margin-bottom: 0.4rem; + color: var(--text-h); +} + +.lineage-dag svg { + display: block; + min-width: 100%; +} + .lineage-dag-edge { stroke: var(--border); stroke-width: 1.5; @@ -509,18 +2277,21 @@ cursor: pointer; } -.lineage-dag-node circle { +.lineage-dag-node circle, +.lineage-dag-node .lineage-dag-mark { fill: var(--surface-muted); stroke: var(--border); stroke-width: 1.5; } -.lineage-dag-branch circle { +.lineage-dag-branch circle, +.lineage-dag-branch .lineage-dag-mark { fill: var(--badge-actor-organization-bg); stroke: var(--color-accent-orange); } -.lineage-dag-root circle { +.lineage-dag-root circle, +.lineage-dag-root .lineage-dag-mark { stroke: var(--color-accent-info); } @@ -529,18 +2300,53 @@ fill: var(--text-h); } -.lineage-dag-node:focus { - outline: none; +.lineage-dag-node:focus { + outline: none; +} + +.lineage-dag-node:focus circle, +.lineage-dag-node:hover circle, +.lineage-dag-node:focus .lineage-dag-mark, +.lineage-dag-node:hover .lineage-dag-mark { + stroke-width: 2.5; +} + +.lineage-dag-node[aria-current="true"] circle, +.lineage-dag-node[aria-current="true"] .lineage-dag-mark { + 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-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 +2404,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); @@ -728,6 +2692,37 @@ .chat-section { display: flex; flex-direction: column; + gap: 0.75rem; +} + +.chat-layout { + display: grid; + grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr); + gap: 0.75rem; + min-width: 0; +} + +.chat-section .ask-agent-history { + padding: 0.35rem 0.2rem 0.55rem; + border-right: 1px solid var(--border); + max-height: 22rem; + overflow: auto; +} + +.chat-section .ask-agent-history-header { + margin-top: 0; + padding: 0 0.2rem; +} + +.chat-section .ask-agent-history-list { + max-height: 12rem; +} + +.chat-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; } .chat-input-row { @@ -820,29 +2815,340 @@ .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); + } + + .chat-layout { + grid-template-columns: minmax(0, 1fr); + } + + .chat-section .ask-agent-history { + max-height: none; + 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..c2b273635 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -24,13 +24,30 @@ 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. + // This file exercises the authenticated Board workspace by default (`/` + // itself now lands on the Dashboard, ADR 0145) — tests that specifically + // cover the Dashboard live in Dashboard.test.tsx. + window.history.replaceState({}, "", "/?workspace=board"); }); afterEach(() => { vi.unstubAllGlobals(); + window.sessionStorage.clear(); + window.localStorage.clear(); }); describe("App, unauthenticated", () => { + it("shows the authentication loading state", () => { + mockAuth = { ...mockAuth, isLoading: true }; + + render(); + + expect(screen.getByText("Loading authentication state...")).toBeInTheDocument(); + }); + it("shows a login button that starts the real OIDC redirect", async () => { render(); const button = screen.getByRole("button", { name: /log in/i }); @@ -42,6 +59,58 @@ describe("App, unauthenticated", () => { }), ); }); + + it("remembers a same-origin post deep link before the OIDC redirect", async () => { + window.history.replaceState({}, "", "/?post=synthetic-post-ada"); + render(); + await userEvent.click(screen.getByRole("button", { name: /log in/i })); + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toBe( + "/?post=synthetic-post-ada", + ); + expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBe( + "/?post=synthetic-post-ada", + ); + expect(signinRedirect).toHaveBeenCalledWith({ + state: { returnUrl: "/?post=synthetic-post-ada" }, + }); + }); + + it("does not mount tenant admin settings before authentication", () => { + render(); + expect(screen.queryByRole("heading", { name: /admin settings/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/tenant brand name/i)).not.toBeInTheDocument(); + }); + + it("does not render raw OIDC error text and names a log-in next action", async () => { + mockAuth = { + ...mockAuth, + error: { message: "invalid_grant: AADSTS70000 TypeError at oidc-client" }, + }; + render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Sign-in could not be completed."); + expect(alert).toHaveTextContent("Log in again to open the workspace."); + expect(screen.queryByText(/invalid_grant|AADSTS70000|TypeError|oidc-client/i)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Log in" })); + expect(signinRedirect).toHaveBeenCalledTimes(1); + }); + + it("offers a new login when authentication returns no access token", async () => { + window.history.replaceState({}, "", "/"); + mockAuth = { + ...mockAuth, + isAuthenticated: true, + user: { profile: { preferred_username: "demo.analyst" } }, + }; + + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("Authenticated, but no access token was returned."); + await userEvent.click(screen.getByRole("button", { name: "Log in" })); + expect(signinRedirect).toHaveBeenCalledWith( + expect.objectContaining({ state: expect.objectContaining({ returnUrl: "/" }) }), + ); + }); }); function jsonResponse(body: unknown): Response { @@ -63,6 +132,11 @@ describe("App, authenticated", () => { function stubBackend(options?: { admin?: boolean; calendarCommitments?: unknown[]; + calendarEvents?: unknown[]; + calendarUnavailable?: boolean; + caldavAvailable?: boolean; + reportsUnavailable?: boolean; + reportRebuildUnavailable?: boolean; rankings?: { status?: "accepted" | "unavailable"; status_reason?: string | null; @@ -72,25 +146,81 @@ describe("App, authenticated", () => { fused_rank: number; }[]; }; + rankingsUnavailable?: boolean; + relatedUnavailable?: "all" | "entity" | "person" | "team"; chatUnavailable?: boolean; + chatNoCitations?: boolean; evidenceUnavailable?: boolean; + legacyChatCitations?: boolean; + postChatConversationUnavailable?: boolean; + postChatHistoryUnavailable?: boolean; + preferenceUnavailable?: boolean; searchUnavailable?: boolean; + askUnavailable?: boolean; + askConversationFailsAfterFirst?: boolean; + askConversationId?: boolean; + askHistory?: boolean; + askHistoryMoreUnavailable?: boolean; + askHistoryPages?: boolean; + askHistoryUnavailable?: boolean; + askOlderTurnsUnavailable?: boolean; + postAskHistory?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + analysisRunCreateStatus?: 409 | 500; + analysisRunOpenStatus?: 404 | 500; + analysisRunsUnavailable?: boolean; + analysisRunStartUnavailable?: boolean; runningLineageRun?: boolean; failedReportRun?: boolean; succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; pluralAffiliations?: boolean; + manyAffiliations?: boolean; + noAffiliations?: boolean; + postUnavailable?: boolean; + contentUnavailable?: boolean; + derivedUnavailable?: boolean; + bookmarkLoadUnavailable?: boolean; + postsUnavailable?: boolean; + rebuildUnavailable?: boolean; + focusedLineageUnavailable?: boolean; deferMe?: boolean; + deferPosts?: boolean; + directLineage?: boolean; + deriveNoCommitment?: boolean; + emptyLineage?: boolean; meFailed?: boolean; postBody?: string; + boardTotalCount?: number; + manyBoardPosts?: boolean; + vocTypeOptions?: { code: string; label: string }[]; + visibilityOptions?: { code: string; label: string }[]; + sourceDetailStateCode?: string; + sourceDetailStateOptions?: { code: string; label: string }[]; manyCustomerHints?: number; customerEntityHierarchy?: boolean; + emptyCustomerMaster?: boolean; + customerMasterUnavailable?: boolean; + customerResolveUnavailable?: boolean; + customerScopeFacets?: boolean; + customerRelatedPost?: boolean; + rrOrgWithMembers?: boolean; + groupedKeyEvents?: boolean; + semanticRelationships?: boolean; staleSummary?: boolean; contentAfterSummary?: boolean; - }): ReturnType & { releaseMe: () => void } { + contentProcessing?: boolean; + summaryPending?: boolean; + summaryUnavailable?: boolean; + activityUnavailable?: boolean; + ticketCreateUnavailable?: boolean; + ticketListUnavailable?: boolean; + ticketUpdateUnavailable?: boolean; + lineageIsolationReason?: "no_relation_found" | "no_comparison_group"; + bookmarkUnavailable?: boolean; + }): ReturnType & { releaseMe: () => void; releasePosts: () => void } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -115,13 +245,76 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + let askConversationRequests = 0; + let bookmarked = false; + 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); @@ -131,6 +324,9 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse({ brandName: "LineageWeave" })); } if (url.endsWith("/api/me/preferences") && method === "PATCH") { + if (options?.preferenceUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } const body = JSON.parse(String(init?.body)); return Promise.resolve(jsonResponse({ preferred_locale: body.preferred_locale })); } @@ -152,16 +348,41 @@ describe("App, authenticated", () => { { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, ] : [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], + account_affiliations: authorizedAffiliations, }); }); } + if (url.endsWith("/api/posts/post-1/bookmark")) { + if (method === "GET" && options?.bookmarkLoadUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + if (method === "POST") { + if (options?.bookmarkUnavailable) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "bookmark unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + } + bookmarked = Boolean(JSON.parse(String(init?.body)).bookmarked); + } + return Promise.resolve(jsonResponse({ post_id: "post-1", bookmarked })); + } if (url.endsWith("/api/lineage/rebuild") && method === "POST") { + if (options?.rebuildUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve(jsonResponse({ edge_count: 4 })); } if (url.endsWith("/api/posts/post-1/tickets") && method === "GET") { + if (options?.ticketListUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve(jsonResponse({ tickets })); } if (url.endsWith("/api/posts/post-1/tickets") && method === "POST") { + if (options?.ticketCreateUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } const body = JSON.parse(String(init?.body)); const ticket = { issue_ticket_id: `ticket-${nextTicketId++}`, @@ -185,6 +406,9 @@ describe("App, authenticated", () => { return Promise.resolve(new Response(JSON.stringify(ticket), { status: 201 })); } if (url.match(/\/api\/tickets\/ticket-\d+$/) && method === "PATCH") { + if (options?.ticketUpdateUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } const ticketId = url.split("/").pop(); const body = JSON.parse(String(init?.body)); const ticket = tickets.find((t) => t.issue_ticket_id === ticketId); @@ -200,6 +424,9 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse(ticket)); } if (url.endsWith("/api/posts/post-1/activity") && method === "GET") { + if (options?.activityUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve(jsonResponse({ events })); } if (url.endsWith("/api/posts/post-1/derive-commitment") && method === "POST") { @@ -213,6 +440,9 @@ describe("App, authenticated", () => { ), ); } + if (options?.deriveNoCommitment) { + return Promise.resolve(jsonResponse({ post_id: "post-1", has_commitment: false, ticket: null })); + } const ticket = { issue_ticket_id: `ticket-${nextTicketId++}`, post_id: "post-1", @@ -392,6 +622,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + if (options?.analysisRunOpenStatus) { + return Promise.resolve(new Response(null, { status: options.analysisRunOpenStatus })); + } return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-lineage", @@ -491,6 +724,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending/start") && method === "POST") { + if (options?.analysisRunStartUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-lineage-pending", @@ -603,6 +839,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs") && method === "POST") { + if (options?.analysisRunCreateStatus) { + return Promise.resolve(new Response(null, { status: options.analysisRunCreateStatus })); + } const payload = init?.body ? JSON.parse(String(init.body)) : {}; if (payload.run_kind_code === "analysis_run_tepp" || payload.run_kind_code === "analysis_run_report") { return Promise.resolve( @@ -644,6 +883,9 @@ describe("App, authenticated", () => { return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { + if (options?.analysisRunsUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ analysis_runs: [ @@ -734,8 +976,13 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/calendar")) { + if (options?.calendarUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ + events: options?.calendarEvents ?? [], + calendar_sources: { caldav_available: options?.caldavAvailable ?? false }, commitments: options?.calendarCommitments ?? [ { @@ -769,6 +1016,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/rankings")) { + if (options?.rankingsUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } const rankings = options?.rankings ?? { status: "unavailable" as const, status_reason: "rankweave_not_available", @@ -783,6 +1033,12 @@ describe("App, authenticated", () => { }), ); } + if (options?.reportsUnavailable && url.includes("/api/reports/") && method === "GET") { + return Promise.resolve(new Response(null, { status: 503 })); + } + if (options?.reportRebuildUnavailable && url.includes("/api/reports/") && method === "POST") { + return Promise.resolve(new Response(null, { status: 503 })); + } if (url.includes("/api/reports/compare/") && method === "GET") { return Promise.resolve( jsonResponse({ @@ -973,6 +1229,21 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse({ group_count: 1 })); } if (url.includes("/api/lineage") && method === "GET") { + if (options?.focusedLineageUnavailable && url.includes("post_id=")) { + return Promise.resolve(new Response(null, { status: 503 })); + } + if ((options?.lineageIsolationReason || options?.emptyLineage) && url.includes("post_id=")) { + return Promise.resolve( + jsonResponse({ + nodes: [], + edges: [], + truncated: false, + ...(options.lineageIsolationReason + ? { isolation_reason: options.lineageIsolationReason } + : {}), + }), + ); + } return Promise.resolve( jsonResponse({ nodes: [ @@ -1035,7 +1306,8 @@ describe("App, authenticated", () => { } const postsUrl = new URL(url, "https://backend.test"); if (postsUrl.pathname === "/api/posts") { - return Promise.resolve( + if (options?.postsUnavailable) return Promise.resolve(new Response(null, { status: 503 })); + return postsReady.then(() => jsonResponse( postsUrl.searchParams.get("search") ? [] @@ -1046,25 +1318,55 @@ 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", }, + ...(options?.manyBoardPosts + ? [ + { + post_id: "post-2", + post_title: "Earlier partner post", + voc_type_code: "vop", + voc_type_label: "Voice of Partner", + source_detail_state_code: "A", + visibility_code: "private", + visibility_label: "Private", + created_at: "2025-12-31T00:00:00Z", + }, + ] + : []), ], - total_count: 1, + total_count: options?.boardTotalCount ?? (options?.manyBoardPosts ? 2 : 1), 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" }, + ]), ], - visibility_options: [{ code: "public", label: "Public" }], + source_detail_state_options: options?.sourceDetailStateOptions ?? [], + visibility_options: options?.visibilityOptions ?? [{ code: "public", label: "Public" }], }, ), ); } const postOneUrl = new URL(url, "https://backend.test"); if (postOneUrl.pathname === "/api/posts/post-1") { + if (options?.postUnavailable) { + return Promise.resolve(new Response(JSON.stringify({ detail: "synthetic backend detail" }), { status: 503 })); + } const asOf = postOneUrl.searchParams.get("as_of"); return Promise.resolve( jsonResponse({ @@ -1073,8 +1375,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", @@ -1103,10 +1415,13 @@ describe("App, authenticated", () => { ); } if (postOneUrl.pathname === "/api/posts/post-1/content") { + if (options?.contentUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } contentRequests += 1; return Promise.resolve( jsonResponse({ - status: "ready", + status: options?.contentProcessing && contentRequests === 1 ? "processing" : "ready", images: [], units: options?.contentAfterSummary && contentRequests > 1 @@ -1142,6 +1457,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/evaluation")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ post_id: "post-1", @@ -1164,6 +1480,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 +1497,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,11 +1555,19 @@ 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", + affiliation_catalog_unresolved_reason_code: "reason_no_live_client", + }, { actor_name: "당사", responsibility: "출하 일정 확정", actor_type_code: "prov_organization", affiliated_organization_name: null, + catalog_unresolved_reason_code: "reason_not_corroborated", }, { actor_name: "설계팀", @@ -1212,10 +1588,50 @@ describe("App, authenticated", () => { extraction_method: "contextual_orchestrator_semantic", }, ], + ...(options?.semanticRelationships + ? { + semantic_relationships: [ + { + relation_ordinal: 0, + subject_name: "Design team", + subject_type: "prov:Agent", + predicate_code: "lw_responsible_for", + object_name: "Synthetic launch", + object_type: "lw:Project", + evidence_text: "The design team owns the synthetic launch.", + confidence: 0.91, + extraction_method: "contextual_orchestrator_semantic", + }, + { + relation_ordinal: 1, + subject_name: "Prototype Alpha", + subject_type: "prov:Entity", + predicate_code: "lw_precedes", + ontology_label: "Precedes", + object_name: "Prototype Beta", + object_type: "prov:Entity", + evidence_text: "Alpha was completed before Beta.", + confidence: 0.84, + extraction_method: null, + }, + { + relation_ordinal: 2, + subject_name: "Synthetic note", + subject_type: "prov:Entity", + predicate_code: "synthetic_relation", + object_name: "Synthetic record", + object_type: "prov:Entity", + evidence_text: "The source states this synthetic relation.", + confidence: 0.75, + }, + ], + } + : {}), }), ); } if (url.endsWith("/api/posts/post-1/keymen")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ keymen: [ @@ -1259,6 +1675,9 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse({ post_id: "post-1", rubric_version: "2026-08-13", responses: [] })); } if (url.endsWith("/api/keymen/person-priya/related")) { + if (options?.relatedUnavailable === "all" || options?.relatedUnavailable === "person") { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ person_id: "person-priya", @@ -1281,6 +1700,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/keymen/person-ada/related")) { + if (options?.relatedUnavailable === "all" || options?.relatedUnavailable === "person") { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ person_id: "person-ada", @@ -1342,6 +1764,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/teams/team-1/related")) { + if (options?.relatedUnavailable === "all" || options?.relatedUnavailable === "team") { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ team_id: "team-1", @@ -1360,6 +1785,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/corporate-entities/corp-1/related")) { + if (options?.relatedUnavailable === "all" || options?.relatedUnavailable === "entity") { + return Promise.resolve(new Response(null, { status: 503 })); + } return Promise.resolve( jsonResponse({ corporate_entity_id: "corp-1", @@ -1379,7 +1807,28 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/corporate-entities/corp-demo/related") && options?.customerRelatedPost) { + return Promise.resolve( + jsonResponse({ + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + related: [ + { + node_id: "post-2", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Linked post", + post_body_excerpt: "Linked body preview", + post_body_truncated: true, + relevance: 0.6, + }, + ], + }), + ); + } if (url.endsWith("/api/posts/post-1/affiliate-tree")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ trees: [ @@ -1429,6 +1878,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/voc-evidence")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ post_id: "post-1", @@ -1453,6 +1903,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/counterparties")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ counterparties: [ @@ -1488,11 +1939,16 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse({ verified: [] })); } if (url.endsWith("/api/posts/post-1/lineage")) { + if (options?.derivedUnavailable) return Promise.resolve(new Response(null, { status: 503 })); return Promise.resolve( jsonResponse({ post_id: "post-1", - direct: [], - indirect: [{ post_id: "post-2", post_title: "Linked post" }], + direct: options?.directLineage + ? [{ post_id: "post-2", post_title: "Linked post" }] + : [], + indirect: options?.lineageIsolationReason || options?.emptyLineage || options?.directLineage + ? [] + : [{ post_id: "post-2", post_title: "Linked post" }], }), ); } @@ -1505,7 +1961,9 @@ describe("App, authenticated", () => { question_text: "What happened between these events?", answer_text: "The seeded follow-up after the site visit.", cited_post_ids: ["post-2"], - cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], + ...(options?.legacyChatCitations + ? {} + : { cited_posts: [{ post_id: "post-2", post_title: "Linked post" }] }), }, { question_text: "Who is involved?", @@ -1535,20 +1993,185 @@ describe("App, authenticated", () => { ), ); } + let conversationId = "conversation-post-new"; + try { + const body: unknown = JSON.parse(String(init?.body)); + if ( + body && + typeof body === "object" && + "conversation_id" in body && + typeof body.conversation_id === "string" && + body.conversation_id + ) { + conversationId = body.conversation_id; + } + } catch { + conversationId = "conversation-post-new"; + } return Promise.resolve( jsonResponse({ post_id: "post-1", + conversation_id: conversationId, answer_text: "Here is what happened, drawing on the linked post.", - cited_post_ids: ["post-2"], - cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], + cited_post_ids: options?.chatNoCitations ? [] : ["post-2"], + ...(options?.legacyChatCitations || options?.chatNoCitations + ? {} + : { cited_posts: [{ post_id: "post-2", post_title: "Linked post" }] }), source_post_ids: ["post-1", "post-2"], }), ); } + if (url.endsWith("/api/posts/post-1/chat/conversations") && method === "GET") { + if (options?.postChatHistoryUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + if (options?.postAskHistory) { + return Promise.resolve( + jsonResponse({ + conversations: [ + { + conversation_id: "conversation-post-1", + title: "Saved post question", + updated_at: "2026-08-21T00:00:00Z", + turn_count: 1, + }, + ], + }), + ); + } + return Promise.resolve(jsonResponse({ conversations: [] })); + } + if (url.endsWith("/api/posts/post-1/chat/conversations/conversation-post-1") && method === "GET") { + if (options?.postChatConversationUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + return Promise.resolve( + jsonResponse({ + conversation_id: "conversation-post-1", + title: "Saved post question", + exchanges: [ + { + turn_id: "turn-1", + question_text: "Which site visit was saved?", + answer_text: "The saved post answer stays grounded in the linked source.", + cited_post_ids: ["post-2"], + cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], + source_post_ids: ["post-1", "post-2"], + }, + ], + }), + ); + } + if (url.includes("/chat/conversations") && method === "GET") { + return Promise.resolve(jsonResponse({ conversations: [] })); + } + if (url.endsWith("/api/ask/conversations") && method === "GET") { + if (options?.askHistoryUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + 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") { + if (options?.askHistoryMoreUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + 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") { + if (options?.askOlderTurnsUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + 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") { + askConversationRequests += 1; + if (options?.askConversationFailsAfterFirst && askConversationRequests > 1) { + return Promise.resolve(new Response(null, { status: 503 })); + } + 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({ + ...(options?.askConversationId ? { conversation_id: "conversation-live" } : {}), answer_text: "The cited project is supported by the stored semantic evidence.", + ...(options?.askConversationId ? { next_action: "Read the cited source next." } : {}), cited_post_ids: ["post-2"], cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], cited_post_evidence: [ @@ -1557,6 +2180,7 @@ describe("App, authenticated", () => { facts: [ { kind: "semantic_project", text: "project: Semantic project | evidence: Body evidence" }, { kind: "semantic_keyman", text: "Keyman mention: Ada West | context: account lead" }, + { kind: "semantic_event", text: "event: Quote revised | evidence: Customer request" }, ], }, ], @@ -1564,38 +2188,104 @@ describe("App, authenticated", () => { }), ); } - if (url.endsWith("/api/customer-master") && method === "GET") { + const customerMasterUrl = new URL(url, "https://backend.test"); + if (customerMasterUrl.pathname === "/api/customer-master" && method === "GET") { + if (options?.customerMasterUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } + const requestedCustomerHint = customerMasterUrl.searchParams.get("hint_code"); return Promise.resolve( jsonResponse({ - corporate_entities: options?.customerEntityHierarchy + corporate_entities: options?.emptyCustomerMaster + ? [] + : 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-observed-hierarchy", + corporate_entity_code: "OBSERVED-HIERARCHY-01", + entity_name: "Observed Hierarchy Corp", + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: null, + scope_facets: ["observed_hierarchy"], + }, + { + 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: ["scope_unclassified"], + }, + ] + : 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"], + name_history: [ + { + entity_name: "Demo Industries", + name_role_code: "entity_name_former", + observed_from: "2024-01-01T00:00:00Z", + observed_to: "2026-01-01T00:00:00Z", + }, + ], + }, + ], keymen: [ { person_id: "person-1", @@ -1608,14 +2298,18 @@ describe("App, authenticated", () => { ], source_customer_hints: options?.manyCustomerHints ? Array.from({ length: options.manyCustomerHints }, (_, index) => ({ + source_system_code: "synthetic-crm", customer_code: `CUST-${index}`, customer_name: resolvedHintCode === `CUST-${index}` ? "Southfield Utilities" : null, post_count: options.manyCustomerHints! - index, related_posts: [], - resolution_status: resolvedHintCode === `CUST-${index}` ? "resolved" : "hint_only", + resolution_status: resolvedHintCode === `CUST-${index}` ? "customer_identity_promoted" : "hint_only", + corporate_entity_id: resolvedHintCode === `CUST-${index}` ? "corp-southfield" : null, + resolved_entity_name: resolvedHintCode === `CUST-${index}` ? "Southfield Utilities" : null, + customer_identity_judgment_id: resolvedHintCode === `CUST-${index}` ? "judgment-southfield" : null, hint_trust: "normal", provenance: "source_post.source_customer_code", - })) + })).filter((hint) => !requestedCustomerHint || hint.customer_code === requestedCustomerHint) : [], source_author_hints: [], relationship_network: [ @@ -1643,6 +2337,9 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/customer-master/resolve-hint") && method === "POST") { + if (options?.customerResolveUnavailable) { + return Promise.resolve(new Response(null, { status: 503 })); + } const body = JSON.parse(String(init?.body)); resolvedHintCode = body.hint_code; return Promise.resolve( @@ -1651,13 +2348,24 @@ describe("App, authenticated", () => { entity_name: "Southfield Utilities", linked_post_count: 3, verification_evidence_url: "https://example.org/southfield", + customer_identity_judgment_id: "judgment-southfield", + resolution_status: "customer_identity_promoted", + cached: false, }), ); } + if (url.includes("/source-research") && method === "GET") { + const postId = new URL(url, "https://backend.test").pathname.split("/")[3] ?? "post-1"; + return Promise.resolve(jsonResponse({ post_id: postId, research: [] })); + } + if (url.includes("/source-research") && method === "POST") { + const postId = new URL(url, "https://backend.test").pathname.split("/")[3] ?? "post-1"; + return Promise.resolve(jsonResponse({ post_id: postId, researched_count: 0 })); + } 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,29 +2376,459 @@ 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("Semantic event", { exact: true })).toBeInTheDocument(); expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument(); expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /Linked post.*Open source/ })); + expect(await screen.findByRole("dialog", { name: "Linked post" })).toBeInTheDocument(); }); - 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, - // so the panel showed raw codes ("company", "our_side") whenever - // a Keyman had no last_known_job_title -- confirm the human labels - // render and the raw codes never leak into visible text. - stubBackend(); + it("adds a live Ask conversation and its next action to history", async () => { + stubBackend({ askConversationId: true }); render(); - expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); - expect(await screen.findByText("Demo Corp")).toBeInTheDocument(); - expect(screen.getByText("DEMO-CORP-01 · Company")).toBeInTheDocument(); - expect(screen.getByText("Ada West")).toBeInTheDocument(); - expect(screen.getByText("Our side")).toBeInTheDocument(); - expect(screen.queryByText("company", { exact: true })).not.toBeInTheDocument(); - expect(screen.queryByText("our_side", { exact: true })).not.toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByText("Read the cited source next.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Which project\?/ })).toHaveAttribute("aria-current", "page"); + }); + + it("renders the conversation empty state and submits an Ask Agent question with Enter", async () => { + const fetchMock = 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(); + fireEvent.click(screen.getByRole("button", { name: "Who is involved?" })); + expect(input).toHaveValue("Who is involved?"); + fireEvent.change(input, { target: { value: "작성 중" } }); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect( + fetchMock.mock.calls.some( + ([url, init]) => String(url).endsWith("/api/ask") && init?.method === "POST", + ), + ).toBe(false); + fireEvent.change(input, { target: { value: "" } }); + 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("opens Ask from a post and can clear the starting evidence", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "Ask about this lineage" })); + expect(screen.getByRole("status")).toHaveTextContent("Starting evidence: Public post"); + + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "What preceded it?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + await waitFor(() => { + const askCall = fetchMock.mock.calls.find( + ([input, init]) => String(input).endsWith("/api/ask") && init?.method === "POST", + ); + expect(JSON.parse(String(askCall?.[1]?.body))).toMatchObject({ anchor_post_id: "post-1" }); + }); + + await userEvent.click(screen.getByRole("button", { name: "Use all authorized evidence" })); + expect(screen.queryByText(/Starting evidence:/)).not.toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "What else is related?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + await waitFor(() => { + const askCalls = fetchMock.mock.calls.filter( + ([input, init]) => String(input).endsWith("/api/ask") && init?.method === "POST", + ); + expect(askCalls).toHaveLength(2); + expect(JSON.parse(String(askCalls[1][1]?.body))).not.toHaveProperty("anchor_post_id"); + }); + }); + + it("fails closed when the selected source post is unavailable", async () => { + stubBackend({ postUnavailable: true }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Post details" }); + expect(await within(dialog).findByRole("alert")).toBeInTheDocument(); + expect(dialog).not.toHaveTextContent("synthetic backend detail"); + expect(within(dialog).queryByText("The full body text.")).not.toBeInTheDocument(); + }); + + it("keeps the source readable when optional post evidence is unavailable", async () => { + stubBackend({ contentUnavailable: true, derivedUnavailable: true, bookmarkLoadUnavailable: true }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + expect(await within(dialog).findByText("The full body text.")).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Bookmark" })).toBeDisabled(); + expect( + within(dialog).getByText("Related posts is temporarily unavailable. Saved evidence is still available."), + ).toBeInTheDocument(); + expect(within(dialog).queryByText("Loading related posts...")).not.toBeInTheDocument(); + expect(within(dialog).queryByText("Loading lineage...")).not.toBeInTheDocument(); + }); + + it("shares, prints, and toggles a post bookmark from the popup actions", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const browserNavigator = Object.create(navigator); + Object.defineProperty(browserNavigator, "clipboard", { value: { writeText } }); + vi.stubGlobal("navigator", browserNavigator); + const print = vi.fn(); + vi.stubGlobal("print", print); + const fetchMock = stubBackend(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Share" })); + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + expect(new URL(String(writeText.mock.calls[0][0])).searchParams.get("post")).toBe("post-1"); + expect(within(dialog).getByText("Permanent link copied.", { selector: ".post-action-status" })).toBeVisible(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Print" })); + expect(print).toHaveBeenCalledOnce(); + + const bookmark = within(dialog).getByRole("button", { name: "Bookmark" }); + await waitFor(() => expect(bookmark).toBeEnabled()); + fireEvent.click(bookmark); + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Bookmarked" })).toBeEnabled()); + fireEvent.click(within(dialog).getByRole("button", { name: "Bookmarked" })); + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Bookmark" })).toBeEnabled()); + const bookmarkBodies = fetchMock.mock.calls + .filter(([input, init]) => String(input).endsWith("/api/posts/post-1/bookmark") && init?.method === "POST") + .map(([, init]) => JSON.parse(String(init?.body)).bookmarked); + expect(bookmarkBodies).toEqual([true, false]); + }); + + it("uses the native share sheet when it is available", async () => { + const share = vi.fn().mockResolvedValue(undefined); + const browserNavigator = Object.create(navigator); + Object.defineProperty(browserNavigator, "share", { value: share }); + vi.stubGlobal("navigator", browserNavigator); + stubBackend(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Share" })); + + await waitFor(() => + expect(share).toHaveBeenCalledWith( + expect.objectContaining({ title: "Public post", url: expect.stringContaining("post=post-1") }), + ), + ); + expect(dialog.querySelector(".post-action-status")).not.toBeInTheDocument(); + }); + + it("keeps share cancellation quiet and reports share or bookmark failures", async () => { + const share = vi + .fn() + .mockRejectedValueOnce(new DOMException("cancelled", "AbortError")) + .mockRejectedValueOnce(new Error("share failed")); + const browserNavigator = Object.create(navigator); + Object.defineProperty(browserNavigator, "share", { value: share }); + Object.defineProperty(browserNavigator, "clipboard", { value: undefined }); + vi.stubGlobal("navigator", browserNavigator); + stubBackend({ bookmarkUnavailable: true }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Share" })); + await waitFor(() => expect(share).toHaveBeenCalledOnce()); + expect(dialog.querySelector(".post-action-status")).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Share" })); + expect(await within(dialog).findByText("Share unavailable.", { selector: ".post-action-status" })).toBeVisible(); + + const bookmark = within(dialog).getByRole("button", { name: "Bookmark" }); + await waitFor(() => expect(bookmark).toBeEnabled()); + fireEvent.click(bookmark); + await waitFor(() => + expect(within(dialog).getByText("Bookmark unavailable.", { selector: ".post-action-status" })).toBeVisible(), + ); + expect(bookmark).toHaveAttribute("aria-pressed", "false"); + }); + + it("explains when neither native share nor clipboard is available", async () => { + const browserNavigator = Object.create(navigator); + Object.defineProperty(browserNavigator, "share", { value: undefined }); + Object.defineProperty(browserNavigator, "clipboard", { value: undefined }); + vi.stubGlobal("navigator", browserNavigator); + stubBackend(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Share" })); + + expect(await within(dialog).findByText("Share unavailable.", { selector: ".post-action-status" })).toBeVisible(); + }); + + it("clears a post anchor before continuing a saved conversation", async () => { + const fetchMock = stubBackend({ askHistory: true }); + window.history.replaceState({}, "", "/?workspace=ask&post=post-1"); + render(); + + expect(await screen.findByText("Starting evidence: post-1")).toBeInTheDocument(); + expect(screen.getByText("Start with a question about the evidence")).toBeInTheDocument(); + await userEvent.click(await screen.findByRole("button", { name: /Saved project question/ })); + expect(screen.queryByText(/Starting evidence:/)).not.toBeInTheDocument(); + + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Continue saved context"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + await waitFor(() => { + const askCall = fetchMock.mock.calls.find( + ([input, init]) => String(input).endsWith("/api/ask") && init?.method === "POST", + ); + expect(JSON.parse(String(askCall?.[1]?.body))).not.toHaveProperty("anchor_post_id"); + }); + }, 10_000); + + 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-current", "page"); + expect(savedConversation).not.toHaveAttribute("aria-pressed"); + + 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("restores saved per-post Ask history and can start a new conversation", async () => { + stubBackend({ postAskHistory: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await waitFor(() => + expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(), + ); + const savedConversation = await screen.findByRole("button", { name: /Saved post question/ }); + expect(savedConversation).not.toHaveAttribute("aria-current"); + expect(savedConversation).not.toHaveAttribute("aria-pressed"); + + const popup = document.querySelector(".popup-panel") as HTMLElement; + const askInput = within(popup).getByPlaceholderText(/what happened/i); + await userEvent.type(askInput, "What preceded the site visit?"); + await userEvent.click(within(popup).getByRole("button", { name: /^ask$/i })); + expect( + await screen.findByText("Here is what happened, drawing on the linked post."), + ).toBeInTheDocument(); + const liveThread = screen.getByRole("button", { name: /What preceded the site visit/ }); + expect(liveThread).toHaveAttribute("aria-current", "page"); + expect(screen.queryByText("The seeded follow-up after the site visit.")).not.toBeInTheDocument(); + + await userEvent.click(within(popup).getByRole("button", { name: "New conversation" })); + expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(); + expect(screen.queryByText("Here is what happened, drawing on the linked post.")).not.toBeInTheDocument(); + expect( + [...popup.querySelectorAll(".chat-question")].map((node) => node.textContent), + ).not.toContain("What preceded the site visit?"); + expect(liveThread).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("button", { name: /What preceded the site visit/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Saved post question/ })).toBeInTheDocument(); + + await userEvent.click(savedConversation); + expect( + await screen.findByText("The saved post answer stays grounded in the linked source."), + ).toBeInTheDocument(); + expect(savedConversation).toHaveAttribute("aria-current", "page"); + expect(screen.queryByText("The seeded follow-up after the site visit.")).not.toBeInTheDocument(); + + await userEvent.click(within(popup).getByRole("button", { name: "New conversation" })); + expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(); + expect(savedConversation).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("button", { name: /Saved post question/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /What preceded the site visit/ })).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("retries an unavailable Ask conversation registry", async () => { + const fetchMock = stubBackend({ askHistoryUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + + expect(await screen.findAllByText("Conversation history could not be loaded.")).toHaveLength(2); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/ask/conversations")).length; + await userEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/ask/conversations"))) + .toHaveLength(attempts + 1), + ); + }); + + it("keeps saved Ask history when selecting its conversation fails", async () => { + stubBackend({ askConversationFailsAfterFirst: true, askHistory: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + + const saved = await screen.findByRole("button", { name: /Saved project question/ }); + await userEvent.click(screen.getByRole("button", { name: "New conversation" })); + await userEvent.click(saved); + + expect(await screen.findByText("Conversation history could not be loaded.")).toBeInTheDocument(); + expect(saved).toBeInTheDocument(); + }); + + 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("retries failed older Ask conversation pages", async () => { + const fetchMock = stubBackend({ askHistory: true, askHistoryMoreUnavailable: true, askHistoryPages: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + await screen.findByText("Which project was saved?", { exact: true }); + + 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); + const retry = await screen.findByRole("button", { name: "Retry loading history" }); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).includes("before_updated_at")).length; + await userEvent.click(retry); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("before_updated_at"))) + .toHaveLength(attempts + 1), + ); + }); + + it("retries failed older Ask turns", async () => { + const fetchMock = stubBackend({ askHistory: true, askHistoryPages: true, askOlderTurnsUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + await screen.findByText("Which project was saved?", { exact: true }); + + 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); + const retry = await screen.findByRole("button", { name: "Retry loading older questions" }); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).includes("before_turn=2")).length; + await userEvent.click(retry); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("before_turn=2"))) + .toHaveLength(attempts + 1), + ); + }); + + 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, + // so the panel showed raw codes ("company", "our_side") whenever + // a Keyman had no last_known_job_title -- confirm the human labels + // render and the raw codes never leak into visible text. + stubBackend(); + 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("Demo Corp")).toBeInTheDocument(); + expect(screen.getByText("DEMO-CORP-01 · Company")).toBeInTheDocument(); + expect(screen.getByText("Ada West")).toBeInTheDocument(); + expect(screen.getByText("Our side")).toBeInTheDocument(); + expect(screen.queryByText("company", { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByText("our_side", { exact: true })).not.toBeInTheDocument(); + }); + + it("shows governed former names when a customer entity is expanded", async () => { + const fetchMock = stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + await userEvent.click((await screen.findByText("Demo Corp")).closest("button")!); + + expect(screen.getByText("Former name: Demo Industries")).toBeInTheDocument(); + await waitFor(() => + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/corporate-entities/corp-demo/related")), + ).toHaveLength(1), + ); + await userEvent.click(screen.getByText("Demo Corp").closest("button")!); + expect(screen.queryByText("Former name: Demo Industries")).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Demo Corp").closest("button")!); + expect(screen.getByText("Former name: Demo Industries")).toBeInTheDocument(); + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/corporate-entities/corp-demo/related")), + ).toHaveLength(1); + }); + + it("opens a linked post from an expanded customer entity", async () => { + stubBackend({ customerRelatedPost: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + await userEvent.click((await screen.findByText("Demo Corp")).closest("button")!); + + const relatedPost = await screen.findByRole("button", { name: "Open related post: Linked post" }); + expect(relatedPost).toHaveTextContent("Linked body preview ..."); + await userEvent.click(relatedPost); + expect(await screen.findByRole("dialog", { name: "Linked post" })).toBeInTheDocument(); }); it("nests a corporate entity under its parent instead of a flat list", async () => { @@ -1715,6 +2853,93 @@ 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("Observed Hierarchy Corp")).toBeInTheDocument(); + expect(screen.getByText("Unclassified Scope Corp")).toBeInTheDocument(); + expect(screen.getByText("Observed hierarchy")).toBeInTheDocument(); + expect(screen.getByText("Scope not classified")).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.queryByText("Observed Hierarchy 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("renders explicit semantic relationships with direction, evidence, and provenance", async () => { + stubBackend({ semanticRelationships: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const relationships = await screen.findByRole("heading", { name: "Explicit semantic relationships" }); + const list = relationships.nextElementSibling as HTMLUListElement; + expect(within(list).getByText("Responsible for")).toBeInTheDocument(); + expect(within(list).getByText("Precedes")).toBeInTheDocument(); + expect(within(list).getByText("synthetic_relation")).toBeInTheDocument(); + expect(within(list).getByText(/Alpha was completed before Beta\. · Confidence: 84%/)).toBeInTheDocument(); + + const temporalRelation = within(list).getByText("Prototype Alpha").closest("li")!; + await userEvent.click(within(temporalRelation).getByText("Evidence provenance")); + expect(within(temporalRelation).getByText("Extraction source: Recorded extraction")).toBeInTheDocument(); + }); + 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, @@ -1751,6 +2976,37 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Resolve" })); expect(await screen.findByText("Southfield Utilities")).toBeInTheDocument(); + expect(screen.getByText("Managed customer")).toBeInTheDocument(); + }); + + it("restores customer hint resolution after a failed corroboration", async () => { + stubBackend({ admin: true, customerResolveUnavailable: true, manyCustomerHints: 1 }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + + await userEvent.click(await screen.findByRole("button", { name: "Resolve" })); + + expect( + await screen.findByText("This hint could not be resolved to a corroborated organization name."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resolve" })).toBeEnabled(); + }); + + it("shows a customer-master load failure", async () => { + stubBackend({ customerMasterUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + expect(await screen.findByText("Customer master could not be loaded.")).toBeInTheDocument(); + }); + + it("shows when no customer entities are connected", async () => { + stubBackend({ emptyCustomerMaster: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + + expect( + await screen.findByText("No customer entities are connected to this account."), + ).toBeInTheDocument(); }); it("hides the resolve action from an account without post_admin", async () => { @@ -1782,6 +3038,31 @@ 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("shows a no-match state for an observed customer code", async () => { + stubBackend({ manyCustomerHints: 2 }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + + await userEvent.type(screen.getByRole("searchbox", { name: "Find source customer code" }), "CUST-99"); + await userEvent.click(screen.getByRole("button", { name: "Find" })); + + expect(await screen.findByText("No source customer evidence matches CUST-99.")).toBeInTheDocument(); + }); + it("searches the board from a semantic project mention", async () => { stubBackend(); render(); @@ -1806,12 +3087,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 +3104,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 +3118,193 @@ describe("App, authenticated", () => { expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); }); + it("derives missing board facets and applies every client-side sort and filter", async () => { + stubBackend({ + manyBoardPosts: true, + sourceDetailStateCode: "D", + sourceDetailStateOptions: [], + visibilityOptions: [], + vocTypeOptions: [], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + const titles = () => + within(board).getAllByRole("button", { name: /View post:/ }).map((button) => button.getAttribute("aria-label")); + + await userEvent.selectOptions(within(board).getByLabelText("Sort posts"), "title"); + expect(titles()).toEqual(["View post: Earlier partner post", "View post: Public post"]); + await userEvent.selectOptions(within(board).getByLabelText("Sort posts"), "oldest"); + expect(titles()[0]).toContain("Earlier partner post"); + await userEvent.selectOptions(within(board).getByLabelText("Sort posts"), "newest"); + expect(titles()[0]).toContain("Public post"); + + const voc = within(board).getByRole("checkbox", { name: "VOC — Voice of Customer" }); + await userEvent.click(voc); + expect(within(board).queryByRole("button", { name: "View post: Earlier partner post" })).not.toBeInTheDocument(); + await userEvent.click(voc); + + const approved = within(board).getByRole("checkbox", { name: "A — Approved" }); + await userEvent.click(approved); + expect(within(board).queryByRole("button", { name: "View post: Public post" })).not.toBeInTheDocument(); + await userEvent.click(approved); + + await userEvent.selectOptions(within(board).getByLabelText("Filter by visibility"), "private"); + expect(within(board).queryByRole("button", { name: "View post: Public post" })).not.toBeInTheDocument(); + }); + + it("navigates compact board pagination without losing the current-page state", async () => { + const fetchMock = stubBackend({ boardTotalCount: 400 }); + render(); + + const pages = await screen.findByRole("navigation", { name: "Board pages" }); + expect(within(pages).getByRole("button", { name: "Page 1" })).toHaveAttribute("aria-current", "page"); + expect(within(pages).getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(within(pages).getByText("...")).toBeInTheDocument(); + + await userEvent.click(within(pages).getByRole("button", { name: "Page 8" })); + await waitFor(() => + expect(within(pages).getByRole("button", { name: "Page 8" })).toHaveAttribute("aria-current", "page"), + ); + expect(within(pages).getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("offset=350"))).toBe(true); + + await userEvent.click(within(pages).getByRole("button", { name: "Previous page" })); + await waitFor(() => + expect(within(pages).getByRole("button", { name: "Page 7" })).toHaveAttribute("aria-current", "page"), + ); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("offset=300"))).toBe(true); + + await userEvent.click(within(pages).getByRole("button", { name: "Page 1" })); + await waitFor(() => + expect(within(pages).getByRole("button", { name: "Page 1" })).toHaveAttribute("aria-current", "page"), + ); + await userEvent.click(within(pages).getByRole("button", { name: "Next page" })); + await waitFor(() => + expect(within(pages).getByRole("button", { name: "Page 2" })).toHaveAttribute("aria-current", "page"), + ); + }); + + 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"); + expect(requestedPaths).not.toContain("/api/posts/post-1/source-research"); + }); + + 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 +3344,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); @@ -1920,6 +3391,21 @@ describe("App, authenticated", () => { }), ); }); + + fireEvent.change(language, { target: { value: "unsupported" } }); + expect(document.documentElement.lang).toBe("ja"); + }); + + it("keeps the selected language when preference persistence is unavailable", async () => { + stubBackend({ preferenceUnavailable: true }); + render(); + + const language = await screen.findByRole("combobox", { + name: /language|언어|言語|语言|ngôn ngữ/i, + }); + await userEvent.selectOptions(language, "ja"); + + await waitFor(() => expect(document.documentElement.lang).toBe("ja")); }); it("rebuilds lineage when the account has post_admin", async () => { @@ -1935,6 +3421,18 @@ describe("App, authenticated", () => { ); }); + it("reports a lineage rebuild failure and restores the action", async () => { + stubBackend({ admin: true, rebuildUnavailable: true }); + render(); + await userEvent.click(await screen.findByText("Advanced review tools")); + const rebuild = await screen.findByRole("button", { name: /rebuild lineage/i }); + + await userEvent.click(rebuild); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(rebuild).toBeEnabled(); + }); + it("shows the advanced-review section to post_admin without the test-only prop", async () => { stubBackend({ admin: true }); render(); @@ -1968,9 +3466,35 @@ 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"); + // ADR 0141: an unresolved affiliation shows the specific reason instead + // of the generic "Not linked to catalog" label when the reason is known. + expect(screen.getByText("(No live enrichment service configured)")).toBeInTheDocument(); + // ADR 0141: an unresolved primary actor (organization/person) also gets + // a specific reason note, where it previously showed nothing at all. + expect(screen.getByText("(Checked, not independently corroborated)")).toBeInTheDocument(); const relatedPosts = screen.getByRole("heading", { name: "Related posts", level: 3 }).closest( ".related-posts-section", ); @@ -1979,7 +3503,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 +3522,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 +3542,94 @@ describe("App, authenticated", () => { expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); + it.each([ + [ + "no_relation_found" as const, + "Compared against other posts in its group; none were found related.", + ], + [ + "no_comparison_group" as const, + "No other posts share this record's group yet, so nothing was available to compare it against.", + ], + ])( + "shows the specific isolation reason %s instead of the generic empty-lineage message", + async (isolationReason, expectedMessage) => { + stubBackend({ lineageIsolationReason: isolationReason }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const popup = await screen.findByRole("dialog", { name: "Public post" }); + await waitFor(() => expect(within(popup).getByText(expectedMessage)).toBeInTheDocument()); + expect(within(popup).queryByText("No linked posts yet.")).not.toBeInTheDocument(); + }, + ); + + it("keeps the generic empty-lineage copy for historical graph responses", async () => { + stubBackend({ emptyLineage: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const popup = await screen.findByRole("dialog", { name: "Public post" }); + expect(await within(popup).findByText("No linked posts yet.")).toBeInTheDocument(); + expect( + within(popup).getByText("No linked posts have been established for this record."), + ).toBeInTheDocument(); + }); + + it("labels a direct related post", async () => { + stubBackend({ directLineage: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const relatedPosts = screen.getByRole("heading", { name: "Related posts", level: 3 }).closest( + ".related-posts-section", + ); + expect(relatedPosts).not.toBeNull(); + expect(within(relatedPosts as HTMLElement).getByText("Direct relation")).toBeInTheDocument(); + }); + + 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,8 +3652,31 @@ 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" })); + + const processing = await screen.findByText("Summary is being prepared."); + expect(processing.closest('[role="status"]')).toBeInTheDocument(); + expect(screen.queryByText("No summary is available for this record yet.")).not.toBeInTheDocument(); + }, 15_000); + + 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" })); + const summaryHeading = await screen.findByText("Summary could not be generated."); + expect(summaryHeading.closest('[role="alert"]')).toBeInTheDocument(); + expect(screen.getAllByRole("alert")).toHaveLength(1); + 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 }); + stubBackend({ contentAfterSummary: true, contentProcessing: true }); render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); @@ -2075,6 +3718,18 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: /ask seeded question: what is the next commitment/i })).toBeInTheDocument(); }); + it("keeps linked records readable when the focused graph is unavailable", async () => { + stubBackend({ focusedLineageUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect( + await screen.findByText("The linked records are listed above. The graph is not available for this view."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open related post: Linked post" })).toBeInTheDocument(); + }); + it("asks a chat question and slides in the evidence panel for a cited source on click", async () => { stubBackend(); render(); @@ -2089,35 +3744,111 @@ describe("App, authenticated", () => { expect(screen.getByText("Here is what happened, drawing on the linked post.")).toBeInTheDocument(), ); - // The evidence panel is not shown until a citation is clicked. - expect(screen.queryByText("The evidence panel should show exactly this text.")).not.toBeInTheDocument(); + // The evidence panel is not shown until a citation is clicked. + expect(screen.queryByText("The evidence panel should show exactly this text.")).not.toBeInTheDocument(); + + const evidenceChips = screen.getAllByRole("button", { name: "Open evidence: Linked post" }); + await userEvent.click(evidenceChips[evidenceChips.length - 1]); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole("button", { name: "Close evidence panel" })); + expect(screen.queryByText("The evidence panel should show exactly this text.")).not.toBeInTheDocument(); + }); + + it("stops loading and gives the reader a next action when cited evidence is unavailable", async () => { + const fetchMock = stubBackend({ evidenceUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.type(await screen.findByPlaceholderText(/what happened/i), "What happened?"); + await userEvent.click(screen.getByRole("button", { name: /^ask$/i })); + const evidenceChips = await screen.findAllByRole("button", { name: "Open evidence: Linked post" }); + await userEvent.click(evidenceChips[evidenceChips.length - 1]); + + const alertTitle = await screen.findByText( + "Source evidence is unavailable. Continue with the saved answer.", + ); + const alert = alertTitle.closest('[role="alert"]'); + expect(alert).toHaveTextContent("Source evidence is unavailable. Continue with the saved answer."); + expect(alert).toHaveTextContent("Retry opening this source, or keep reading the saved answer."); + expect(screen.getByRole("button", { name: "Retry evidence" })).toBeInTheDocument(); + expect(screen.queryByText("Loading source post...")).not.toBeInTheDocument(); + const attempts = fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/posts/post-2")).length; + await userEvent.click(screen.getByRole("button", { name: "Retry evidence" })); + await waitFor(() => + expect( + fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/posts/post-2")), + ).toHaveLength(attempts + 1), + ); + }, 15_000); + + it("shows post chat history failures without hiding saved evidence", async () => { + stubBackend({ postChatHistoryUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByText("Conversation history could not be loaded.")).toBeInTheDocument(); + expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(); + }); + + it("keeps seeded post chat visible when a saved conversation cannot be loaded", async () => { + stubBackend({ postAskHistory: true, postChatConversationUnavailable: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: /Saved post question/ })); + + expect(await screen.findByText("Conversation history could not be loaded.")).toBeInTheDocument(); + expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(); + }); + + it("renders legacy citation identifiers and ignores an empty Enter ask", async () => { + const fetchMock = stubBackend({ legacyChatCitations: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + const input = await screen.findByPlaceholderText(/what happened/i); + fireEvent.keyDown(input, { key: "Enter" }); + expect( + fetchMock.mock.calls.filter( + ([url, init]) => String(url).endsWith("/api/posts/post-1/chat") && init?.method === "POST", + ), + ).toHaveLength(0); + expect(screen.getByRole("button", { name: "Open evidence: post-2" })).toBeInTheDocument(); + }); + + it("submits a saved question from the seeded suggestion chips", async () => { + const fetchMock = stubBackend(); + render(); - const evidenceChips = screen.getAllByRole("button", { name: "Open evidence: Linked post" }); - await userEvent.click(evidenceChips[evidenceChips.length - 1]); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "Ask seeded question: Who is involved?" })); - await waitFor(() => - expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), - ); + await waitFor(() => { + const call = fetchMock.mock.calls.find( + ([url, init]) => String(url).endsWith("/api/posts/post-1/chat") && init?.method === "POST", + ); + expect(JSON.parse(String(call?.[1]?.body))).toMatchObject({ question: "Who is involved?" }); + }); }); - it("stops loading and gives the buyer a next action when cited evidence is unavailable", async () => { - stubBackend({ evidenceUnavailable: true }); + it("renders a grounded chat answer without inventing source chips", async () => { + stubBackend({ chatNoCitations: true }); render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); - await userEvent.type(await screen.findByPlaceholderText(/what happened/i), "What happened?"); + await userEvent.type(screen.getByPlaceholderText(/what happened/i), "Summarize this record"); await userEvent.click(screen.getByRole("button", { name: /^ask$/i })); - const evidenceChips = await screen.findAllByRole("button", { name: "Open evidence: Linked post" }); - await userEvent.click(evidenceChips[evidenceChips.length - 1]); - expect( - await screen.findByText("Source evidence is unavailable. Continue with the saved answer."), - ).toBeInTheDocument(); - expect(screen.queryByText("Loading source post...")).not.toBeInTheDocument(); + const answer = await screen.findByText("Here is what happened, drawing on the linked post."); + expect(answer.closest(".chat-answer")?.querySelector(".chat-citations")).toBeNull(); }); it("shows a clear empty state when chat is 503 without an orchestrator", async () => { - stubBackend({ chatUnavailable: true }); + const fetchMock = stubBackend({ chatUnavailable: true }); render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); @@ -2140,6 +3871,15 @@ describe("App, authenticated", () => { expect( screen.getByText("The next commitment is Send Northridge Grid the revised quote, due 2026-01-12."), ).toBeInTheDocument(); + const postAttempts = fetchMock.mock.calls.filter( + ([url, init]) => String(url).endsWith("/api/posts/post-1/chat") && init?.method === "POST", + ).length; + await userEvent.click(screen.getAllByRole("button", { name: /ask seeded question/i })[0]); + expect( + fetchMock.mock.calls.filter( + ([url, init]) => String(url).endsWith("/api/posts/post-1/chat") && init?.method === "POST", + ), + ).toHaveLength(postAttempts); }); it("shows a clear empty state when evaluate is 503 without an orchestrator", async () => { @@ -2147,11 +3887,19 @@ describe("App, authenticated", () => { render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByText("Constructive stance: 2")).toBeInTheDocument(); + expect(screen.getByText("Sales-lead specificity: 3")).toBeInTheDocument(); await userEvent.click(await screen.findByRole("button", { name: /evaluate post/i })); await waitFor(() => expect(screen.getByText("Evaluation is temporarily unavailable. Saved evidence is still available.")).toBeInTheDocument(), ); + expect(screen.getByText(/This analysis channel is unavailable/)).toBeInTheDocument(); + expect(screen.getByText(/A missing signal is not a negative fact/)).toBeInTheDocument(); + expect(screen.getByText("Constructive stance: 2")).toBeInTheDocument(); + expect(screen.getByText("Sales-lead specificity: 3")).toBeInTheDocument(); + expect(document.getElementById("post-quality-criterion-general_sentiment_positive")).not.toBeNull(); + expect(document.getElementById("post-quality-criterion-sales_lead_specificity")).not.toBeNull(); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /evaluate post/i })).not.toBeInTheDocument(); }); @@ -2323,6 +4071,26 @@ describe("App, authenticated", () => { ); }); + it.each([ + ["person" as const, "Ada West", null], + ["entity" as const, "Demo Corp", "Related nodes for Demo Corp"], + ["team" as const, "설계팀", "Related nodes for 설계팀"], + ])("fails closed when a %s related-node lookup is unavailable", async (kind, name, nestedAction) => { + stubBackend({ relatedUnavailable: kind }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + if (nestedAction) { + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); + await userEvent.click(await screen.findByRole("button", { name: nestedAction })); + } else { + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); + } + + const panel = await screen.findByText(`Related to ${name}`); + expect(panel.closest(".related-keymen")).toHaveTextContent("No related nodes in the visible graph."); + }); + it("shows the VOC excerpt under its counterparty, not a detached list", async () => { stubBackend(); render(); @@ -2493,6 +4261,46 @@ describe("App, authenticated", () => { expect(screen.getByText("due 2026-03-15")).toBeInTheDocument(); }); + it("fails closed when tickets cannot be loaded and ignores an empty Enter", async () => { + const fetchMock = stubBackend({ ticketListUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByText("No tickets yet.")).toBeInTheDocument(); + fireEvent.keyDown(screen.getByPlaceholderText(/new ticket title/i), { key: "Enter" }); + expect( + fetchMock.mock.calls.filter( + ([url, init]) => String(url).endsWith("/api/posts/post-1/tickets") && init?.method === "POST", + ), + ).toHaveLength(0); + }); + + it("restores ticket creation after a request failure", async () => { + stubBackend({ ticketCreateUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + await userEvent.type(screen.getByPlaceholderText(/new ticket title/i), "Synthetic ticket"); + await userEvent.click(screen.getByRole("button", { name: /create ticket/i })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create ticket/i })).toBeEnabled(); + }); + + it("keeps a ticket's saved status when an update fails", async () => { + stubBackend({ ticketUpdateUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.type(screen.getByPlaceholderText(/new ticket title/i), "Synthetic ticket"); + await userEvent.click(screen.getByRole("button", { name: /create ticket/i })); + + const status = await screen.findByLabelText(/status for synthetic ticket/i); + await userEvent.selectOptions(status, "closed"); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(status).toHaveValue("open"); + }); + it("shows real ticket mutations on the activity feed after a refresh", async () => { stubBackend(); render(); @@ -2525,6 +4333,21 @@ describe("App, authenticated", () => { expect(screen.queryByText("ticket_status_changed")).not.toBeInTheDocument(); }); + it("retries an unavailable activity feed", async () => { + const fetchMock = stubBackend({ activityUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const heading = await screen.findByRole("heading", { name: "Activity" }); + const section = heading.closest("section")!; + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/posts/post-1/activity")).length; + await userEvent.click(within(section).getAllByRole("button", { name: "Refresh" })[0]); + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/posts/post-1/activity"))) + .toHaveLength(attempts + 1), + ); + }); + it("hides derive commitment for accounts without post_admin", async () => { stubBackend(); render(); @@ -2549,7 +4372,17 @@ describe("App, authenticated", () => { expect(screen.getByText("due 2026-01-09")).toBeInTheDocument(); }); - it("tells the buyer how to populate an empty calendar", async () => { + it("explains when no commitment can be derived", async () => { + stubBackend({ admin: true, deriveNoCommitment: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + await userEvent.click(await screen.findByRole("button", { name: /derive commitment/i })); + + expect(await screen.findByText("No customer commitment found in this post.")).toBeInTheDocument(); + }); + + it("tells the reader how to populate an empty calendar", async () => { stubBackend({ calendarCommitments: [] }); render(); @@ -2560,6 +4393,50 @@ describe("App, authenticated", () => { ); }); + it("renders CalDAV availability and events", async () => { + stubBackend({ + calendarCommitments: [], + calendarEvents: [ + { + event_id: "event-synthetic", + summary: "Synthetic design review", + starts_at: "2026-01-15T09:00:00Z", + }, + ], + caldavAvailable: true, + }); + render(); + + expect(await screen.findByText("Synthetic design review")).toBeInTheDocument(); + expect(screen.getByText("2026-01-15T09:00:00Z")).toBeInTheDocument(); + }); + + it("opens a commitment from the Calendar workspace", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Calendar" })); + await userEvent.click(await screen.findByRole("button", { name: /open commitment for: public post/i })); + + expect(await screen.findByRole("dialog", { name: "Public post" })).toBeInTheDocument(); + expect(new URL(window.location.href).searchParams.get("workspace")).toBe("board"); + }); + + it("retries calendar loading failures", async () => { + const fetchMock = stubBackend({ calendarUnavailable: true }); + render(); + + const calendarAlert = (await screen.findAllByRole("alert"))[0]; + const retry = within(calendarAlert).getByRole("button", { name: "Retry" }); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/calendar")).length; + await userEvent.click(retry); + + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/calendar"))) + .toHaveLength(attempts + 1), + ); + }); + it("names RankWeave unavailability on home rankings instead of inventing a fused score", async () => { stubBackend(); render(); @@ -2568,6 +4445,28 @@ describe("App, authenticated", () => { expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); }); + it("names an accepted empty RankWeave result", async () => { + stubBackend({ rankings: { status: "accepted", rankings: [] } }); + render(); + + expect(await screen.findByText("No fused rankings from RankWeave.")).toBeInTheDocument(); + }); + + it("retries ranking loading failures", async () => { + const fetchMock = stubBackend({ rankingsUnavailable: true }); + render(); + + const rankings = await screen.findByRole("region", { name: "Rankings" }); + const retry = within(rankings).getByRole("button", { name: "Retry" }); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/rankings")).length; + await userEvent.click(retry); + + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/rankings"))) + .toHaveLength(attempts + 1), + ); + }); + it("opens an accepted ranking hit without inventing a fused score", async () => { stubBackend({ rankings: { @@ -2793,12 +4692,13 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Running · Demo Corp", }); expect(lineageButton).toHaveTextContent( - "Refresh this run. Start already queued the work on the durable outbox.", + "Refresh this run. Reconstruction is already queued on the durable outbox.", ); await userEvent.click(lineageButton); - expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh this run" })).toBeInTheDocument(); expect( - screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."), + screen.getAllByText("Refresh this run. Reconstruction is already queued on the durable outbox."), ).not.toHaveLength(0); }); @@ -2953,7 +4853,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( @@ -3103,8 +5005,8 @@ describe("App, authenticated", () => { await userEvent.click(reportButton); expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "Open period report 2026-W02" }), - ).not.toBeInTheDocument(); + screen.getByRole("button", { name: "Open period report 2026-W02" }), + ).toBeInTheDocument(); expect( await screen.findByText( "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", @@ -3224,6 +5126,80 @@ describe("App, authenticated", () => { ); }); + it("retries the analysis-run registry after a load failure", async () => { + const fetchMock = stubBackend({ analysisRunsUnavailable: true }); + render(); + + const retry = await screen.findByRole("button", { name: "Retry" }); + const attempts = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/analysis-runs")).length; + await userEvent.click(retry); + + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/analysis-runs"))) + .toHaveLength(attempts + 1), + ); + }); + + it("explains an analysis-run idempotency conflict", async () => { + stubBackend({ analysisRunCreateStatus: 409 }); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + + expect( + await screen.findByText( + "This request key already names a different reconstruction. Request again to start a new run.", + ), + ).toBeInTheDocument(); + }); + + it("shows a generic analysis-run request failure", async () => { + stubBackend({ analysisRunCreateStatus: 500 }); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Request a lineage reconstruction" })).toBeEnabled(); + }); + + it.each([ + [404 as const, "This analysis run is not visible."], + [500 as const, null], + ])("handles an analysis-run detail failure %s", async (status, expected) => { + stubBackend({ analysisRunOpenStatus: status }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + + if (expected) { + expect(await screen.findByText(expected)).toBeInTheDocument(); + } else { + expect(await screen.findByRole("alert")).toBeInTheDocument(); + } + }); + + it("restores the start action after reconstruction fails", async () => { + stubBackend({ analysisRunStartUnavailable: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Start reconstruction" })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeEnabled(); + }); + it("lets a multi-affiliation operator choose which corp to reconstruct", async () => { const fetchMock = stubBackend({ pluralAffiliations: true }); render(); @@ -3357,13 +5333,15 @@ describe("App, authenticated", () => { }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closestPair).toHaveTextContent( - "Open this post to read the criterion it sat closest to after main effects.", + "Open Public post, then read Post quality criterion sales-lead.", ); + expect(closestPair).not.toHaveTextContent(/sat closest to after main effects/); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Open this post to read the criterion it sat farthest from after main effects.", + "Open Specification revision requested, then read Post quality criterion negative.", ); + expect(farthestPair).not.toHaveTextContent(/sat farthest from after main effects/); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); expect(closestPair.compareDocumentPosition(memberButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); @@ -3423,6 +5401,15 @@ describe("App, authenticated", () => { await screen.findByRole("button", { name: /open leftover closest pair: public post/i }), ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + const criterion = await waitFor(() => { + const landed = document.getElementById("post-quality-criterion-sales_lead_specificity"); + expect(landed).not.toBeNull(); + return landed as HTMLElement; + }); + expect(criterion).toHaveAttribute("aria-current", "true"); + expect(criterion).toHaveTextContent(/Sales-lead specificity: 3/); + await waitFor(() => expect(criterion).toHaveFocus()); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => { @@ -3501,12 +5488,273 @@ describe("App, authenticated", () => { ); }); - it("keeps advanced review tools out of the buyer board", async () => { + it("shows an explicit period-report load failure", async () => { + stubBackend({ reportsUnavailable: true }); + render(); + + const heading = await screen.findByRole("heading", { name: "Period reports" }); + expect(await within(heading.closest("section")!).findByRole("alert")).toBeInTheDocument(); + }); + + it("reports a period-report rebuild failure and restores the action", async () => { + stubBackend({ admin: true, reportRebuildUnavailable: true }); + render(); + const rebuild = await screen.findByRole("button", { name: "Rebuild report" }); + + await userEvent.click(rebuild); + + const heading = screen.getByRole("heading", { name: "Period reports" }); + expect(await within(heading.closest("section")!).findByRole("alert")).toBeInTheDocument(); + expect(rebuild).toBeEnabled(); + }); + + 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("dialog", { name: "Workspace navigation" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close Workspace navigation" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getByRole("button", { name: "Close" })).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "Workspace navigation" })).not.toBeInTheDocument(); + expect(mobileMenu).toHaveFocus(); + await userEvent.click(mobileMenu); + 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.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "Workspace navigation" })).not.toBeInTheDocument(); + await userEvent.click( + within(appHeader as HTMLElement).getByRole("button", { name: "Search" }), + ); + await waitFor(() => + expect(screen.getByRole("searchbox", { name: "Search semantic evidence" })).toHaveFocus(), + ); + }); + + it("logs out through the OIDC client", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Log out" })); + + expect(signoutRedirect).toHaveBeenCalledOnce(); + }); + + it("opens Board operations from the Admin workspace", async () => { + stubBackend({ admin: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Admin" })); + await userEvent.click(await screen.findByRole("button", { name: "Open post operations" })); + + const board = await screen.findByRole("region", { name: "Board" }); + const advanced = await within(board).findByText("Advanced review tools"); + expect(advanced.closest("details")).toHaveAttribute("open"); + expect(new URL(window.location.href).searchParams.get("workspace")).toBe("board"); + }); + + 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=ask&post=post-1"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(await screen.findByText("Starting evidence: post-1")).toBeInTheDocument(); + + window.history.replaceState({}, "", "/?workspace=ask"); + window.dispatchEvent(new PopStateEvent("popstate")); + await waitFor(() => + expect(screen.queryByText(/Starting evidence:/)).not.toBeInTheDocument(), + ); + + 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("heading", { name: "Important posts and projects" })).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("retries a failed Board load without leaving the workspace", async () => { + const fetchMock = stubBackend({ postsUnavailable: true }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + const alert = await within(board).findByRole("alert"); + const attempts = fetchMock.mock.calls.filter(([url]) => new URL(String(url)).pathname === "/api/posts").length; + await userEvent.click(within(alert).getByRole("button", { name: "Retry" })); + + await waitFor(() => + expect(fetchMock.mock.calls.filter(([url]) => new URL(String(url)).pathname === "/api/posts")) + .toHaveLength(attempts + 1), + ); + expect(screen.getByRole("region", { name: "Board" })).toBeInTheDocument(); + }); + + 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..d4a25e503 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,14 @@ -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, + fetchPostChatConversation, + fetchPostChatConversations, BackendError, createAnalysisRun, startAnalysisRun, @@ -28,6 +32,7 @@ import { fetchPostCounterparties, fetchPostEvaluation, fetchPostKeymen, + fetchPostKnowledgeGraph, fetchPostLineage, fetchPostFiveW1H, fetchPostSummary, @@ -48,7 +53,11 @@ import { updateTicketStatus, verifyPostRelations, type ActivityEvent, + type AccountAffiliation, type AskAgentResponse, + type AskConversationCursor, + type AskConversationSummary, + type CurrentUser, type AffiliateNode, type AnalysisRun, type CalendarResponse, @@ -57,14 +66,18 @@ import { type CorporateEntityRef, type CustomerMasterEntity, type CustomerMasterResponse, + type CustomerMasterScopeFacet, type Counterparty, type EvaluationResponse, type IssueTicket, type LineageGraph, + type KnowledgeGraph, type Keyman, type SourceAuthorContext, + type SourceCustomerHint, type PostAiSummary, type PostFiveW1H, + type PostKeyEvent, type PostDetail, type PostContentUnit, type PostImageContent, @@ -77,21 +90,56 @@ import { type PostSortOrder, type RankingList, type PersonRoleHistoryEntry, + type PostRoleResponsibility, + type PostSemanticRelationship, type RelatedNode, type RelatedNodeType, + type TenantConfig, type VocEvidence, fetchTenantConfig, } 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 { LeftoverPairButton } from "./components/LeftoverPairButton"; +import { AnalysisRunNextAction } from "./components/AnalysisRunNextAction"; +import { ExceptionAlert, SummaryStatus } from "./components/SummaryStatus"; +import { + analysisRunCanRequestTeppRetry, + analysisRunCaption, + analysisRunCorpusHint, + analysisRunEmptyPostsHint, + analysisRunNextAction, + analysisRunReportGrouping, + analysisRunReportGroupingKey, + analysisRunReportPeriod, +} from "./analysisRunGuidance"; +import { + analysisEvidenceDiagnosis, + gluedRoleRelationshipNextAction, +} from "./analysisEvidenceDiagnosis"; +import { leftoverCriterionLabel, postQualityCriterionElementId } from "./leftoverPairGuidance"; +import { productExceptionCopy } from "./productExceptionCopy"; +import { SourceResearchPanel } from "./components/SourceResearchPanel"; +import { isGenericTeamActor } from "./components/roleEvidenceUtils"; +import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; +import { Dashboard } from "./components/Dashboard"; +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, @@ -105,10 +153,7 @@ import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { - if (err instanceof BackendError && err.status === 503) { - return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; - } - return String(err); + return productExceptionCopy(err, action).title; } function LanguageSwitcher({ accessToken }: { accessToken?: string }) { @@ -136,21 +181,92 @@ 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)."); } - return String(err); + return productExceptionCopy(err, t("Verification")).title; } -const CRITERION_SHORT_LABEL: Record = { - general_sentiment_positive: "constructive", - general_sentiment_negative: "negative", - sales_lead_specificity: "sales-lead", -}; - function criterionShortLabel(itemCode: string): string { - return CRITERION_SHORT_LABEL[itemCode] ?? itemCode; + return leftoverCriterionLabel(itemCode); } // This popup's layout follows the textual product brief (Korean summary, @@ -171,6 +287,7 @@ function EvidencePanel({ }) { const [post, setPost] = useState(null); const [postError, setPostError] = useState(false); + const [evidenceRetry, setEvidenceRetry] = useState(0); useEffect(() => { let current = true; @@ -186,7 +303,7 @@ function EvidencePanel({ return () => { current = false; }; - }, [postId, accessToken]); + }, [postId, accessToken, evidenceRetry]); return (
@@ -194,9 +311,12 @@ function EvidencePanel({

{t("Evidence")}

{!post && !postError &&

{t("Loading source post...")}

} {postError && ( -

- {t("Source evidence is unavailable. Continue with the saved answer.")} -

+ setEvidenceRetry((value) => value + 1)} + /> )} {post && ( <> @@ -238,7 +358,7 @@ function ChatCitations({ ); } -function ChatPanel({ +export function ChatPanel({ postId, accessToken, nameFirstAsk, @@ -248,40 +368,133 @@ function ChatPanel({ nameFirstAsk?: boolean; }) { const [question, setQuestion] = useState(""); - const [exchanges, setExchanges] = useState([]); + const [seededExchanges, setSeededExchanges] = useState([]); + const [conversationExchanges, setConversationExchanges] = useState([]); + const [conversations, setConversations] = useState([]); + const [conversationId, setConversationId] = useState(null); + const [historySelected, setHistorySelected] = useState(false); + const [historyLoading, setHistoryLoading] = useState(true); + const [historyError, setHistoryError] = useState(null); const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [evidencePostId, setEvidencePostId] = useState(null); const [seededOnly, setSeededOnly] = useState(false); + const historyRequestIdRef = useRef(0); + + const exchanges = historySelected ? conversationExchanges : seededExchanges; + const suggestionExchanges = seededExchanges; useEffect(() => { - setExchanges([]); + const requestId = ++historyRequestIdRef.current; + setQuestion(""); + setSeededExchanges([]); + setConversationExchanges([]); + setConversations([]); + setConversationId(null); + setHistorySelected(false); + setHistoryError(null); + setHistoryLoading(true); setAnswer(null); setError(null); setSeededOnly(false); setEvidencePostId(null); fetchPostChat(accessToken, postId) - .then((history) => setExchanges(history.exchanges)) - .catch(() => setExchanges([])); + .then((history) => { + if (requestId !== historyRequestIdRef.current) return; + setSeededExchanges(history.exchanges); + }) + .catch(() => { + if (requestId !== historyRequestIdRef.current) return; + setSeededExchanges([]); + }); + fetchPostChatConversations(accessToken, postId) + .then((page) => { + if (requestId !== historyRequestIdRef.current) return; + setConversations(page.conversations); + }) + .catch(() => { + if (requestId !== historyRequestIdRef.current) return; + setHistoryError(t("Conversation history could not be loaded.")); + }) + .finally(() => { + if (requestId === historyRequestIdRef.current) setHistoryLoading(false); + }); }, [postId, accessToken]); + async function selectConversation(nextConversationId: string) { + if (loading) return; + setHistoryLoading(true); + setHistoryError(null); + try { + const conversation = await fetchPostChatConversation(accessToken, postId, nextConversationId); + setConversationId(conversation.conversation_id); + setConversationExchanges( + conversation.exchanges.map((exchange) => ({ + question_text: exchange.question_text, + answer_text: exchange.answer_text, + cited_post_ids: exchange.cited_post_ids, + cited_posts: exchange.cited_posts, + })), + ); + setHistorySelected(true); + setAnswer(null); + setError(null); + setEvidencePostId(null); + } catch { + setHistoryError(t("Conversation history could not be loaded.")); + } finally { + setHistoryLoading(false); + } + } + + function startNewConversation() { + if (loading) return; + setConversationId(null); + setHistorySelected(false); + setConversationExchanges([]); + setQuestion(""); + setAnswer(null); + setError(null); + setEvidencePostId(null); + setHistoryError(null); + } + async function handleAsk(asked = question) { if (!asked.trim()) return; setLoading(true); setError(null); try { - const result = await askPostChat(accessToken, postId, asked); + const result = await askPostChat(accessToken, postId, asked, conversationId); + const next: ChatExchange = { + question_text: asked.trim(), + answer_text: result.answer_text, + cited_post_ids: result.cited_post_ids, + cited_posts: result.cited_posts, + }; + const appendToSelected = historySelected; setAnswer(result); - setExchanges((prev) => { - const next: ChatExchange = { - question_text: asked.trim(), - answer_text: result.answer_text, - cited_post_ids: result.cited_post_ids, - cited_posts: result.cited_posts, - }; - return [...prev.filter((row) => row.question_text !== next.question_text), next]; - }); + setQuestion(""); + if (result.conversation_id) { + setConversationId(result.conversation_id); + setConversations((current) => [ + { + conversation_id: result.conversation_id!, + title: + current.find((item) => item.conversation_id === result.conversation_id)?.title ?? + asked.trim().slice(0, 80), + updated_at: new Date().toISOString(), + turn_count: + (current.find((item) => item.conversation_id === result.conversation_id)?.turn_count ?? 0) + 1, + }, + ...current.filter((item) => item.conversation_id !== result.conversation_id), + ]); + } + setConversationExchanges((prev) => [ + ...(appendToSelected ? prev : []).filter((row) => row.question_text !== next.question_text), + next, + ]); + setHistorySelected(true); } catch (err) { setError(orchestratorUnavailableMessage(err, "Chat")); if (err instanceof BackendError && err.status === 503) { @@ -344,75 +557,136 @@ function ChatPanel({ {landedEvidenceNextAction(firstCitedTitle)}

) : null} - {!seededOnly && ( -
- setQuestion(event.target.value)} - onKeyDown={(event) => event.key === "Enter" && handleAsk()} - placeholder={t("What happened between these events?")} - /> - -
- )} - {seededOnly && exchanges.length > 0 && ( -

- {t("Interactive questions are unavailable right now; saved evidence remains available.")} -

- )} - {exchanges.length > 0 && ( -
- {exchanges.map((exchange) => ( +
+ +
+ {!seededOnly && ( +
+ 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?")} + /> + +
+ )} + {seededOnly && suggestionExchanges.length > 0 && ( +

+ {t("Interactive questions are unavailable right now; saved evidence remains available.")} +

+ )} + {suggestionExchanges.length > 0 && ( +
+ {suggestionExchanges.map((exchange) => ( + + ))} +
+ )} + {error && } + {historyError && conversations.length > 0 ? ( + + ) : null} + {exchanges + .filter( + (exchange) => + !(nameFirstAsk && exchange.question_text === exchanges[0]?.question_text), + ) + .map((exchange) => ( +
+

{exchange.question_text}

+

{exchange.answer_text}

+ +
))} + {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( +
+

{answer.answer_text}

+ +
+ )}
- )} - {error &&

{error}

} - {exchanges - .filter( - (exchange) => - !(nameFirstAsk && exchange.question_text === exchanges[0]?.question_text), - ) - .map((exchange) => ( -
-

{exchange.question_text}

-

{exchange.answer_text}

- -
- ))} - {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( -
-

{answer.answer_text}

- -
- )} +
{!nameFirstAsk && evidencePostId ? ( void; + onSelectPost: (postId: string) => void; currentNextAction?: string | null; }) { + if (lineageUnavailable) return null; if (!lineage) return

{t("Loading lineage...")}

; if (!graph) return

{t("Loading lineage...")}

; - const scoped = graph ? subgraphForPost(graph, postId) : { nodes: [], edges: [] }; + const scoped = subgraphForPost(graph, postId); const hasLinks = lineage.direct.length > 0 || lineage.indirect.length > 0; if (scoped.nodes.length === 0) { return (

{hasLinks ? t("The linked records are listed above. The graph is not available for this view.") - : t("No linked posts yet.")} + : lineageIsolationMessage(graph.isolation_reason)}

); } return ( <> - {scoped.nodes.length > 0 && onSelectPost && ( + {scoped.nodes.length > 0 && ( )} {scoped.nodes.length > 0 && currentNextAction ? ( @@ -499,16 +792,26 @@ function EventLineageSection({ } function summaryFetchError(err: unknown): string { - return err instanceof BackendError ? err.message : String(err); + return productExceptionCopy(err, t("Summary")).title; } function RelatedPostsSection({ lineage, + error, onSelectPost, }: { lineage: PostLineage | null; - onSelectPost?: (postId: string) => void; + error?: string | null; + onSelectPost: (postId: string) => void; }) { + if (error) { + return ( +
+ + +
+ ); + } if (!lineage) { return (
@@ -556,7 +859,7 @@ function RelatedPostsSection({ ); - return onSelectPost ? ( + return ( - ) : ( -
- {cardContent} -
); })()} @@ -586,19 +885,17 @@ function AffiliateTreeNode({ onSelectEntity, }: { node: AffiliateNode; - onSelectPerson?: (personId: string, personName: string) => void; - onSelectEntity?: (entityId: string, entityName: string) => void; + onSelectPerson: (personId: string, personName: string) => void; + onSelectEntity: (entityId: string, entityName: string) => void; }) { return (
  • - {node.resolved && node.entity_id && onSelectEntity ? ( + {node.resolved && node.entity_id ? ( @@ -616,17 +913,13 @@ function AffiliateTreeNode({ {node.people.map((person, index) => ( {index > 0 ? ", " : null} - {onSelectPerson ? ( - - ) : ( - `${person.person_name} (${person.person_side_label ?? person.person_side_code})` - )} + ))} @@ -782,6 +1075,11 @@ const CHAT_EVIDENCE_KIND_LABELS: Record = { semantic_project: "Semantic project", semantic_role: "Semantic role", semantic_keyman: "Semantic Keyman", + semantic_event: "Semantic event", + semantic_event_clue: "Semantic event clue", + semantic_quantitative: "Semantic quantitative evidence", + semantic_source_fact: "Semantic source-grounded fact", + semantic_relation: "Semantic relationship", }; function chatEvidenceKindLabel(kind: string): string { @@ -854,7 +1152,7 @@ function KeymanPanel({ sourceAuthorContext?: SourceAuthorContext | null; canExtract: boolean; onExtracted: () => void; - onSelectPost?: (postId: string) => void; + onSelectPost: (postId: string) => void; focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; focusTeam?: { teamId: string; teamName: string } | null; @@ -1068,7 +1366,7 @@ function KeymanPanel({

    {t("No related nodes in the visible graph.")}

    ) : ( <> - {relatedPosts.length > 0 && onSelectPost ? ( + {relatedPosts.length > 0 ? (

    {t("Evidence trail")}

    {t("Related posts")}
    @@ -1167,7 +1465,7 @@ function KeymanPanel({
  • )} - {error &&

    {error}

    } + {error && } {sourceAuthorContext ? (
    {t("Source author evidence")} · {t("Hint only")} @@ -1294,12 +1592,16 @@ function EvaluationPanel({ responses, canExtract, onEvaluated, + focusCriterionCode, + channelDropped = false, }: { postId: string; accessToken: string; responses: EvaluationResponse[] | null; canExtract: boolean; onEvaluated: (rows: EvaluationResponse[]) => void; + focusCriterionCode?: string; + channelDropped?: boolean; }) { const [evaluating, setEvaluating] = useState(false); const [error, setError] = useState(null); @@ -1310,6 +1612,23 @@ function EvaluationPanel({ setError(null); }, [postId]); + const channelUnavailable = orchestratorOff || channelDropped; + const hasSavedScores = responses !== null && responses.length > 0; + const droppedDiagnosis = channelUnavailable + ? analysisEvidenceDiagnosis("dropped_channel") + : null; + + useEffect(() => { + if (!focusCriterionCode || responses === null) { + return; + } + const target = + document.getElementById(postQualityCriterionElementId(focusCriterionCode)) ?? + document.getElementById("post-quality-evaluation"); + target?.focus(); + target?.scrollIntoView?.({ block: "nearest" }); + }, [focusCriterionCode, responses]); + async function handleEvaluate() { setEvaluating(true); setError(null); @@ -1329,8 +1648,10 @@ function EvaluationPanel({ return (
    -

    {t("Post quality (IRT)")}

    - {canExtract && !orchestratorOff && ( +

    + {t("Post quality (IRT)")} +

    + {canExtract && !channelUnavailable && (
    {t("Evidence operations")}
    )}
    - {error &&

    {error}

    } + {error && } + {droppedDiagnosis ? ( +

    + {t(droppedDiagnosis.title)}. {t(droppedDiagnosis.nextAction)} +

    + ) : null} {responses === null ? (

    {t("Loading evaluation...")}

    - ) : responses.length === 0 ? ( -

    {t("Not yet evaluated.")}

    - ) : ( + ) : hasSavedScores ? (
      - {responses.map((row) => ( -
    • - {row.criterion_label ?? row.criterion_code}: {row.response_category} -
    • - ))} + {responses.map((row) => { + const negative = + row.criterion_code === "general_sentiment_negative" && row.response_category >= 2 + ? analysisEvidenceDiagnosis("confident_negative") + : null; + return ( +
    • + {row.criterion_label ?? row.criterion_code}: {row.response_category} + {negative ? ( + + {" "} + {t(negative.nextAction)} + + ) : null} +
    • + ); + })}
    + ) : channelUnavailable ? null : ( +

    {t("Not yet evaluated.")}

    )}
    ); @@ -1371,8 +1714,8 @@ function CounterpartyPanel({ counterparties: Counterparty[]; canExtract: boolean; onVerified: () => void; - onSelectEntity?: (entityId: string, entityName: string) => void; - onSelectPost?: (postId: string) => void; + onSelectEntity: (entityId: string, entityName: string) => void; + onSelectPost: (postId: string) => void; }) { const [verifying, setVerifying] = useState(false); const [error, setError] = useState(null); @@ -1413,17 +1756,15 @@ function CounterpartyPanel({
    )} - {error &&

    {error}

    } + {error && }
      {counterparties.map((c) => (
    • - {c.corporate_entity_id && onSelectEntity ? ( + {c.corporate_entity_id ? ( @@ -1437,7 +1778,7 @@ function CounterpartyPanel({ evidenceUrl={c.verification_evidence_url} ariaLabel={tf("Counterparty verification: {name}", { name: c.counterparty_entity_name })} /> - {c.verification_evidence_post_id && onSelectPost ? ( + {c.verification_evidence_post_id ? ( - {error &&

      {error}

      } + {error && ( + + )} {events === null ? (

      {t("Loading activity...")}

      ) : events.length === 0 ? ( @@ -1663,6 +2011,147 @@ function ActivityPanel({ postId, accessToken }: { postId: string; accessToken: s ); } +const SEMANTIC_RELATION_LABELS: Record = { + org_member_of: "Organization member of", + org_unit_of: "Organization unit of", + org_suborganization_of: "Sub-organization of", + lw_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); +} + +// ADR 0141: translate a closed catalog_unresolved_reason_code into the +// specific, honest reason a reader can act on, instead of one flat +// "Not linked to catalog" label for every cause. Returns null (render +// nothing) for a historical row written before the reason was tracked. +function catalogUnresolvedReasonLabel( + reasonCode: string | null | undefined, + translate: (key: string) => string, +): string | null { + switch (reasonCode) { + case "reason_tied_candidates": + return translate("Multiple equally likely matches"); + case "reason_no_live_client": + return translate("No live enrichment service configured"); + case "reason_not_corroborated": + return translate("Checked, not independently corroborated"); + case "reason_no_catalog_entry": + return translate("No matching catalog entry yet"); + default: + return null; + } +} + +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, @@ -1671,7 +2160,9 @@ function PostDetailPopup({ liveBodyWarning, knowledgeCutoff, focusEventLineage, + focusCriterionCode, onClose, + onAskPost, onSelectPost, onSearch, }: { @@ -1682,9 +2173,11 @@ function PostDetailPopup({ liveBodyWarning?: string | null; knowledgeCutoff?: string | null; focusEventLineage?: boolean; + focusCriterionCode?: string; onClose: () => void; - onSelectPost?: (postId: string) => void; - onSearch?: (query: string) => void; + onAskPost: (postId: string, postTitle: string) => void; + onSelectPost: (postId: string) => void; + onSearch: (query: string) => void; }) { const [post, setPost] = useState(null); const [imageContent, setImageContent] = useState([]); @@ -1695,21 +2188,79 @@ 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 [lineageError, setLineageError] = useState(null); + const [knowledgeGraph, setKnowledgeGraph] = useState(null); const [affiliateTrees, setAffiliateTrees] = useState(null); const [vocEvidence, setVocEvidence] = useState(null); const [evaluation, setEvaluation] = useState(null); + const [evaluationDropped, setEvaluationDropped] = useState(false); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); 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 +2278,7 @@ function PostDetailPopup({ } function reloadCounterparties() { + if (isWritingSourceDetailState(post?.source_detail_state_code)) return; fetchPostCounterparties(accessToken, postId) .then((r) => setCounterparties(r.counterparties)) .catch(() => setCounterparties([])); @@ -1741,27 +2293,91 @@ 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); + setLineageError(null); + setKnowledgeGraph(null); setAffiliateTrees(null); setVocEvidence(null); setEvaluation(null); + setEvaluationDropped(false); setFocusPerson(null); setFocusEntity(null); setFocusTeam(null); 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); + setEvaluationDropped(false); + }) + .catch((err) => { + setEvaluation([]); + setEvaluationDropped(err instanceof BackendError && err.status === 503); + }); + 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((value) => { + setLineage(value); + setLineageError(null); + }) + .catch((err) => { + setLineage(null); + setLineageError(productExceptionCopy(err, t("Related posts")).title); + }); + 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(productExceptionCopy(err, "This post").title)); 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 +2391,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 +2409,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 +2432,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 +2490,22 @@ function PostDetailPopup({ return (
      -
      event.stopPropagation()}> +
      event.stopPropagation()} + > - {error &&

      {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} ·{" "} @@ -1910,6 +2526,11 @@ function PostDetailPopup({ > {bookmarked ? t("Bookmarked") : t("Bookmark")} + {!isWritingSourceDetailState(post.source_detail_state_code) ? ( + + ) : null}

      {postActionStatus && (

      @@ -1929,193 +2550,27 @@ 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}
      - - ) : null} - {post.source_detail_state_code ? ( - <> -
      {t("Source detail state")}
      -
      {post.source_detail_state_code}
      - - ) : 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}
      - - ) : null} - {post.source_author_name ? ( - <> -
      {t("Source author name")}
      -
      {post.source_author_name}
      - - ) : null} - {post.source_company_code ? ( - <> -
      {t("Source company code")}
      -
      {post.source_company_code}
      - - ) : null} - {post.source_company_name ? ( - <> -
      {t("Source company name")}
      -
      {post.source_company_name}
      - - ) : null} - {post.source_process_unit_name ? ( - <> -
      {t("Source process unit name")}
      -
      {post.source_process_unit_name}
      - - ) : null} - {post.source_process_unit_code ? ( - <> -
      {t("Source business unit")}
      -
      {post.source_process_unit_code}
      - - ) : null} - {post.source_sales_pool_code ? ( - <> -
      {t("Source sales pool")}
      -
      {post.source_sales_pool_code}
      - - ) : null} - {post.source_sales_pool_name ? ( - <> -
      {t("Source sales pool name")}
      -
      {post.source_sales_pool_name}
      - - ) : null} - {post.source_customer_code ? ( - <> -
      {t("Source customer code")}
      -
      {post.source_customer_code}
      - - ) : null} - {post.source_customer_name ? ( - <> -
      {t("Source customer name")}
      -
      {post.source_customer_name}
      - - ) : null} - {post.source_project_code ? ( - <> -
      {t("Source project code")}
      -
      {post.source_project_code}
      - - ) : null} - {post.source_project_name ? ( - <> -
      {t("Source project name")}
      -
      {post.source_project_name}
      - - ) : null} - {post.source_system_code ? ( - <> -
      {t("Source system")}
      -
      {post.source_system_code}
      - - ) : null} - {post.source_record_key ? ( - <> -
      {t("Source record key")}
      -
      {post.source_record_key}
      - - ) : null} -
      -

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

      -
      - )} - - - - {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 ? ( + {isWritingSourceDetailState(post.source_detail_state_code) ? ( + + ) : !summary && (summaryLoading || contentStatus === "processing") ? ( + + ) : summary ? ( <> {summary.summary_status === "stale" ? (

      @@ -2130,12 +2585,66 @@ function PostDetailPopup({ <>

      {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} -
      • - ))} + {(() => { + 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)} +
      • + ); + }); + })()}
      )} @@ -2143,20 +2652,25 @@ function PostDetailPopup({ <>

      {t("R&R")}

        - {summary.roles_and_responsibilities.map((rr, i) => { + {(() => { + 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" - : isPerson - ? "Person" - : "Organization", + : 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 = ( @@ -2192,7 +2706,7 @@ function PostDetailPopup({ {rr.actor_name} ); - } else if (catalogType === NODE_TEAM && catalogId) { + } else if (catalogType === NODE_TEAM && catalogId && !genericTeam) { actorName = (
      - )} - {summary.major_event_actions && summary.major_event_actions.length > 0 && ( + )} + {summary.semantic_relationships && summary.semantic_relationships.length > 0 && ( + <> +

      {t("Explicit semantic relationships")}

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

      {t("Major event actions")}

      +
        + {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}
      + + ) : null} + {post.source_author_name ? ( + <> +
      {t("Source author name")}
      +
      {post.source_author_name}
      + + ) : null} + {post.source_company_code ? ( + <> +
      {t("Source company code")}
      +
      {post.source_company_code}
      + + ) : null} + {post.source_company_name ? ( + <> +
      {t("Source company name")}
      +
      {post.source_company_name}
      + + ) : null} + {post.source_process_unit_name ? ( + <> +
      {t("Source process unit name")}
      +
      {post.source_process_unit_name}
      + + ) : null} + {post.source_process_unit_code ? ( + <> +
      {t("Source business unit")}
      +
      {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")}
      +
      {post.source_sales_pool_code}
      + + ) : null} + {post.source_sales_pool_name ? ( + <> +
      {t("Source sales pool name")}
      +
      {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("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} - -
      • - ))} -
      +
      {t("Source sales order")}
      +
      {post.source_sales_order_code}
      - )} - - ) : summaryError ? ( -

      {summaryError}

      + ) : 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")}
      +
      {post.source_customer_code}
      + + ) : null} + {post.source_customer_name ? ( + <> +
      {t("Source customer name")}
      +
      {post.source_customer_name}
      + + ) : null} + {post.source_project_code ? ( + <> +
      {t("Source project code")}
      +
      {post.source_project_code}
      + + ) : null} + {post.source_project_name ? ( + <> +
      {t("Source project name")}
      +
      {post.source_project_name}
      + + ) : null} + {post.source_system_code ? ( + <> +
      {t("Source system")}
      +
      {post.source_system_code}
      + + ) : null} + {post.source_record_key ? ( + <> +
      {t("Source record key")}
      +
      {post.source_record_key}
      + + ) : 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} +
      + )} +
      + +
      +

      {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.")} +

      )}
      + {!isWritingSourceDetailState(post.source_detail_state_code) ? ( + + ) : null} + {!focusEventLineage && ( setEvaluation(rows)} + onEvaluated={(rows) => { + setEvaluation(rows); + setEvaluationDropped(false); + }} + focusCriterionCode={focusCriterionCode} + channelDropped={evaluationDropped} /> )} @@ -2289,7 +3228,7 @@ function PostDetailPopup({ }} /> - +

      @@ -2297,6 +3236,7 @@ function PostDetailPopup({

      + {knowledgeGraph ? ( +
      + +
      + ) : null} + {focusEventLineage && ( setEvaluation(rows)} + onEvaluated={(rows) => { + setEvaluation(rows); + setEvaluationDropped(false); + }} + focusCriterionCode={focusCriterionCode} + channelDropped={evaluationDropped} /> {keymen?.[0] ? (

      @@ -2412,121 +3363,6 @@ function PostDetailPopup({ ); } -function analysisRunCaption(run: AnalysisRun): string { - return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] - .filter(Boolean) - .join(" · "); -} - -/** - * Next action for a pending or failed run on the home list and detail. - * - * The machine `failure_code` stays on detail history (ADR 0014). Copy - * is pinned to registered kinds so a pending TEPP row is not mistaken - * for reconstruction, and a failed lineage row is not mistaken for a - * missing TEPP transport. - */ -function analysisRunNextAction(run: AnalysisRun): string | null { - switch (run.status_code) { - case "analysis_status_pending": - switch (run.run_kind_code) { - case "analysis_run_lineage": - return "Open this run, then start reconstruction. Reconstruction has not started yet."; - case "analysis_run_tepp": - return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; - case "analysis_run_report": - return "Open this run to confirm which posts the period report will use. The report has not been built yet."; - default: { - const unexpected: never = run.run_kind_code; - return unexpected; - } - } - case "analysis_status_failed": - switch (run.run_kind_code) { - case "analysis_run_tepp": - return "Open this run to see why it failed, then connect the measurement service and re-run."; - case "analysis_run_lineage": - return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; - case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; - default: { - const unexpected: never = run.run_kind_code; - return unexpected; - } - } - case "analysis_status_running": - return "Refresh this run. Start already queued the work on the durable outbox."; - case "analysis_status_succeeded": - case "analysis_status_cancelled": - case null: - return null; - default: { - const unexpected: never = run.status_code; - return unexpected; - } - } -} - -/** - * Empty-corpus copy that tells the operator what to do next. - */ -function analysisRunEmptyPostsHint(run: AnalysisRun): string { - switch (run.run_kind_code) { - case "analysis_run_tepp": - return ( - "No posts were available at this cutoff for TEPP to measure. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); - case "analysis_run_lineage": - return ( - "No posts were available at this cutoff for reconstruction. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); - case "analysis_run_report": - return ( - "No posts were available at this cutoff for the period report. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); - default: { - const unexpected: never = run.run_kind_code; - return unexpected; - } - } -} - -/** - * Corpus copy for a TEPP run that already has cutoff posts. - * - * Those titles are the measurement bag, not a reconstruction result. - * Pending or running must not claim a calibrated measurement. - */ -function analysisRunCorpusHint(run: AnalysisRun): string | null { - if (run.run_kind_code !== "analysis_run_tepp") return null; - switch (run.status_code) { - case "analysis_status_failed": - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); - case "analysis_status_succeeded": - return "These posts are the cutoff corpus this TEPP run measured."; - case "analysis_status_pending": - case "analysis_status_running": - return "These posts are the cutoff corpus TEPP will measure once this run finishes."; - case "analysis_status_cancelled": - return ( - "These posts are the cutoff corpus this TEPP run would have measured. " + - "The run was cancelled before a calibrated result." - ); - case null: - return "These posts are the cutoff corpus attached to this TEPP run."; - default: { - const unexpected: never = run.status_code; - return unexpected; - } - } -} - /** Git-style prefix. The full digest stays on `title` for verification. */ const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; @@ -2538,6 +3374,12 @@ type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + /** Land leftover clicks on this Post quality criterion (ADR 0049 / 0135). */ + focusCriterionCode?: string; + /** Set when re-entering a post from a popstate (browser back/forward) so + * the handler doesn't push a duplicate history entry for a navigation + * the browser already performed. */ + fromPopState?: boolean; }; /** @@ -2621,75 +3463,6 @@ function AnalysisRunReproducibilityDigests({ ); } -/** - * Start is for a Pending lineage or TEPP row after Request. - * - * Period-report keeps its own rebuild path. TEPP start goes through - * tepp_client and must not be labeled reconstruction. - */ -function analysisRunCanStart(run: AnalysisRun): boolean { - return ( - (run.run_kind_code === "analysis_run_lineage" || run.run_kind_code === "analysis_run_tepp") && - (run.status_code === "analysis_status_pending" || - run.status_code === "analysis_status_running") - ); -} - -function analysisRunStartLabel(run: AnalysisRun): string { - return run.run_kind_code === "analysis_run_tepp" - ? "Start TEPP measurement" - : "Start reconstruction"; -} - -/** Failed TEPP is terminal. Create cannot invent a Pending TEPP row. */ -function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean { - return run.run_kind_code === "analysis_run_tepp" && run.status_code === "analysis_status_failed"; -} - -const REPORT_PERIOD_KEY = /^\d{4}-W\d{2}$/; - -/** - * Period code stored on a succeeded report run's scope key. - * - * That key is a week label, not a theta. Missing or malformed keys - * stay closed so we do not invent a period. - */ -/** - * Report grouping that matches the run's authorized scope. - * - * A corporate-entity run must not leave the panel on business unit (PU). - */ -function analysisRunReportGrouping(run: AnalysisRun): string | null { - switch (run.scope_kind_code) { - case "analysis_scope_corporate_entity": - return "corporate_entity"; - case "analysis_scope_process_unit": - return "process_unit"; - case "analysis_scope_thread_group": - return "thread_group"; - default: - return null; - } -} - -function analysisRunReportGroupingKey(run: AnalysisRun): string | undefined { - return run.scope_grouping_key || undefined; -} - -function analysisRunReportPeriod(run: AnalysisRun): string | null { - if (run.run_kind_code !== "analysis_run_report") { - return null; - } - if (run.status_code !== "analysis_status_succeeded") { - return null; - } - const key = run.scope_key; - if (!key || !REPORT_PERIOD_KEY.test(key)) { - return null; - } - return key; -} - /** * Open options for a reconstructed parent or child. * @@ -2724,9 +3497,9 @@ function AnalysisRunsPanel({ entitiesLoadError, }: { accessToken: string; - currentReportPeriod?: string; + currentReportPeriod: string; onSelectPost: (postId: string, options?: SelectPostOptions) => void; - onSelectReportPeriod?: ( + onSelectReportPeriod: ( periodCode: string, groupingKind?: string, groupingKey?: string, @@ -2754,7 +3527,7 @@ function AnalysisRunsPanel({ useEffect(() => { fetchAnalysisRuns(accessToken) .then((payload) => setRuns(payload.analysis_runs)) - .catch((err) => setError(String(err))); + .catch((err) => setError(productExceptionCopy(err, t("Analysis runs")).title)); }, [accessToken]); useEffect(() => { @@ -2798,7 +3571,7 @@ function AnalysisRunsPanel({ "This request key already names a different reconstruction. Request again to start a new run.", ); } else { - setError(err instanceof BackendError ? err.message : String(err)); + setError(productExceptionCopy(err, t("Analysis runs")).title); } } finally { setRequesting(false); @@ -2815,7 +3588,7 @@ function AnalysisRunsPanel({ setRuns(listed.analysis_runs); setSelected(started); } catch (err) { - setError(err instanceof BackendError ? err.message : String(err)); + setError(productExceptionCopy(err, t("Analysis runs")).title); } finally { setStarting(false); } @@ -2831,15 +3604,27 @@ function AnalysisRunsPanel({ setError("This analysis run is not visible."); return; } - setError(String(err)); + setError(productExceptionCopy(err, t("Analysis runs")).title); } } - if (error && runs === null) return

      {error}

      ; + if (error && runs === null) { + return ( + { + setError(null); + fetchAnalysisRuns(accessToken) + .then((payload) => setRuns(payload.analysis_runs)) + .catch((err) => setError(productExceptionCopy(err, t("Analysis runs")).title)); + }} + /> + ); + } if (runs === null) return

      Loading analysis runs...

      ; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; - const selectedNextAction = selected ? analysisRunNextAction(selected) : null; return (
      @@ -2864,7 +3649,7 @@ function AnalysisRunsPanel({ {requestLabel}
      - {(error || entitiesLoadError) &&

      {error ?? entitiesLoadError}

      } + {(error || entitiesLoadError) && } {runs.length === 0 ? (

      No analysis runs visible to this account yet. Request a lineage @@ -2901,7 +3686,12 @@ function AnalysisRunsPanel({ {selected && (

      {analysisRunCaption(selected)}

      - {selectedNextAction &&

      {selectedNextAction}

      } + void handleStartReconstruction()} + onRefresh={() => void handleOpen(selected.analysis_run_id)} + />

      Cutoff {selected.knowledge_cutoff.slice(0, 10)} {" · "} @@ -2912,27 +3702,13 @@ function AnalysisRunsPanel({ configurationSha256={selected.configuration_sha256} reconstructionResultSha256={selected.reconstruction_result_sha256} /> - {analysisRunCanStart(selected) && ( - - )} {analysisRunCanRequestTeppRetry(selected) && (

      Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.

      )} - {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( + {analysisRunReportPeriod(selected) && (
      - {error &&

      {error}

      } + {error && ( + { + setError(null); + fetchRankings(accessToken) + .then(setRanking) + .catch((err) => setError(productExceptionCopy(err, t("Rankings")).title)); + }} + /> + )} {ranking === null && !error &&

      Loading rankings...

      } {ranking && ranking.status === "unavailable" && (

      Rankings · RankWeave not available

      @@ -3128,10 +3915,23 @@ function CalendarPanel({ useEffect(() => { fetchCalendar(accessToken) .then(setCalendar) - .catch((err) => setError(String(err))); + .catch((err) => setError(productExceptionCopy(err, t("Calendar")).title)); }, [accessToken]); - if (error) return

      {error}

      ; + if (error) { + return ( + { + setError(null); + fetchCalendar(accessToken) + .then(setCalendar) + .catch((err) => setError(productExceptionCopy(err, t("Calendar")).title)); + }} + /> + ); + } if (calendar === null) return

      {t("Loading calendar...")}

      ; const events = calendar.events ?? []; @@ -3299,7 +4099,7 @@ function ReportsPanel({ setIndex(periods); setComparison(compared); }) - .catch((err) => setError(String(err))); + .catch((err) => setError(productExceptionCopy(err, t("Period reports")).title)); }, [accessToken, grouping, period]); useEffect(() => { @@ -3328,7 +4128,7 @@ function ReportsPanel({ setIndex(periods); setComparison(compared); } catch (err) { - setError(String(err)); + setError(productExceptionCopy(err, t("Period reports")).title); } finally { setRebuilding(false); } @@ -3391,33 +4191,18 @@ function ReportsPanel({ )} {report.leftover_pairs && report.leftover_pairs.length > 0 && (
        - {report.leftover_pairs.map((pair) => { - const kindLabel = - pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover"; - const nextAction = - pair.pair_kind === "farthest" - ? "Open this post to read the criterion it sat farthest from after main effects." - : "Open this post to read the criterion it sat closest to after main effects."; - const criterion = criterionShortLabel(pair.criterion_code); - return ( -
      • - -
      • - ); - })} + {report.leftover_pairs.map((pair) => ( +
      • + +
      • + ))}
      )} {report.members.length > 0 && ( @@ -3560,7 +4345,7 @@ function ReportsPanel({ ))}
    )} - {error &&

    {error}

    } + {error && } {!openedGroupingLabel && reportList} ); @@ -3569,16 +4354,80 @@ function ReportsPanel({ 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, + showLabPanels, + postIdToOpen, onPostOpened, + onAskPost, + focusSearchRequest, + onSearchFocusHandled, + globalSearchRequest, + onGlobalSearchHandled, + adminTool, + onAdminToolHandled, }: { accessToken: string; - showLabPanels?: boolean; - postIdToOpen?: string | null; - onPostOpened?: () => void; + showLabPanels: boolean; + postIdToOpen: string | null; + onPostOpened: () => void; + onAskPost: (postId: string, postTitle: string) => 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); @@ -3596,6 +4445,7 @@ function PostList({ const [openedGroupingLabel, setOpenedGroupingLabel] = useState(null); const [landOnComparison, setLandOnComparison] = useState(false); const [openedFromReportMember, setOpenedFromReportMember] = useState(false); + const [openedFocusCriterionCode, setOpenedFocusCriterionCode] = useState(null); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3605,10 +4455,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,19 +4542,44 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + setOpenedFocusCriterionCode(options?.focusCriterionCode ?? null); + 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(() => { if (!postIdToOpen) return; selectPost(postIdToOpen); - onPostOpened?.(); + 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); setOpenedCutoffIso(null); setOpenedFromReportMember(false); + setOpenedFocusCriterionCode(null); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -3688,6 +4607,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,15 +4615,16 @@ 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) { if (requestId !== postsRequest.current) return; - setError(String(err)); + setError(productExceptionCopy(err, t("Board")).title); } finally { if (requestId === postsRequest.current) setLoadingPage(false); } - }, [accessToken, searchQuery, sortOrder, typeFilter, visibilityFilter]); + }, [accessToken, searchQuery, sortOrder, typeFilter, sourceDetailStateFilter, visibilityFilter]); useEffect(() => { void loadPostPage(1); @@ -3749,7 +4670,7 @@ function PostList({ await rebuildLineage(accessToken); setGraph(await fetchLineageGraph(accessToken)); } catch (err) { - setRebuildError(String(err)); + setRebuildError(productExceptionCopy(err, t("Event Lineage")).title); } finally { setRebuilding(false); } @@ -3772,11 +4693,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 +4722,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 @@ -3816,9 +4758,11 @@ function PostList({ )} {error ? ( -

    - {error} -

    + void loadPostPage(currentPage)} + /> ) : !posts ? (

    {t("Loading posts...")}

    ) : ( @@ -3835,73 +4779,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 +4899,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 +5032,7 @@ function PostList({ )} {(showLabPanels || canRebuild) && ( -
    +
    {t("Advanced review tools")} {canRebuild && (
    @@ -4025,33 +5042,37 @@ function PostList({ {rebuilding ? t("Rebuilding...") : t("Rebuild lineage")} - {rebuildError &&

    {rebuildError}

    } + {rebuildError && }
    )} - - - +
    +
    + +
    +
    + +
    )} {selectedPostId && ( @@ -4065,7 +5086,9 @@ function PostList({ } knowledgeCutoff={openedAfterCutoff ? openedCutoffIso : null} focusEventLineage={openedFromReportMember} + focusCriterionCode={openedFocusCriterionCode ?? undefined} onClose={closeSelectedPost} + onAskPost={onAskPost} onSelectPost={selectPost} onSearch={searchBoard} /> @@ -4109,6 +5132,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, @@ -4130,6 +5168,9 @@ function CustomerEntityTreeRow({ const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( (related) => related.node_type_code === NODE_POST, ); + const priorNames = (entity.name_history ?? []).filter( + (name) => name.name_role_code !== "entity_name_preferred", + ); return (
  • {expandedEntityId === entity.corporate_entity_id ? (
    + {priorNames.length > 0 ? ( +
      + {priorNames.map((name) => ( +
    • + {t(name.name_role_code === "entity_name_former" ? "Former name" : "Alternate name")}: {name.entity_name} +
    • + ))} +
    + ) : null} {relatedLoading === entity.corporate_entity_id ?

    {t("Loading related posts...")}

    : null} {relatedLoading !== entity.corporate_entity_id && relatedPosts.length === 0 ? (

    {t("No linked posts yet.")}

    @@ -4216,6 +5271,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") || facets.includes("observed_hierarchy")) buckets.push("observed"); + if (buckets.length === 0) buckets.push("unclassified"); + return buckets; +} + function CustomerMasterPanel({ accessToken, onOpenPost, @@ -4224,12 +5303,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,21 +5335,23 @@ 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); void loadMaster(); }, [loadMaster]); - async function handleResolveHint(hintCode: string) { - setResolvingHint(hintCode); + async function handleResolveHint(hint: SourceCustomerHint) { + if (!hint.customer_code) return; + const hintKey = `${hint.source_system_code ?? ""}:${hint.customer_code}`; + setResolvingHint(hintKey); setResolveError(null); try { - await resolveCustomerHint(accessToken, hintCode); + await resolveCustomerHint(accessToken, hint.customer_code, hint.source_system_code); await loadMaster(); } catch { setResolveError(t("This hint could not be resolved to a corroborated organization name.")); @@ -4274,6 +5360,11 @@ function CustomerMasterPanel({ } } + function handleHintSearch(event: FormEvent) { + event.preventDefault(); + setSearchedHintCode(hintCodeInput.trim()); + } + async function toggleEntity(entityId: string) { if (expandedEntityId === entityId) { setExpandedEntityId(null); @@ -4292,19 +5383,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.")}

    - {error ?

    {error}

    : null} +

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

    + {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 +5485,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 && ( @@ -4355,21 +5496,22 @@ function CustomerMasterPanel({ })}

        )} - {resolveError ?

        {resolveError}

        : null} + {resolveError ? : null}
          {master.source_customer_hints.slice(0, HINT_RENDER_LIMIT).map((hint) => ( -
        • - {hint.customer_name ?? hint.customer_code ?? t("Unresolved source identifier")} +
        • + {hint.resolved_entity_name ?? hint.customer_name ?? hint.customer_code ?? t("Unresolved source identifier")} + {hint.source_system_code ? {t("Source system")}: {hint.source_system_code} : null} {hint.customer_name && hint.customer_code ? {hint.customer_code} : null} - {t("Unresolved source identifier")} + {t(hint.resolution_status === "customer_identity_promoted" ? "Managed customer" : "Hint only")} {t(hint.hint_trust === "low" ? "Weak source hint" : "Source hint")} {hint.post_count} {t("posts")} - {canResolveHints && hint.customer_code ? ( + {canResolveHints && hint.customer_code && hint.resolution_status !== "customer_identity_promoted" ? ( ) : null} {hint.related_posts.length > 0 ? ( @@ -4466,116 +5608,660 @@ 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, + anchorPostId, + anchorPostTitle, + onClearAnchor, }: { accessToken: string; onOpenPost: (postId: string) => void; + anchorPostId?: string | null; + anchorPostTitle?: string | null; + onClearAnchor?: () => 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 initialAnchorPostIdRef = useRef(anchorPostId); + const previousAnchorPostIdRef = useRef(anchorPostId); + + 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); + if (initialAnchorPostIdRef.current) { + setConversationId(null); + setExchanges([]); + return; + } + 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 (anchorPostId && anchorPostId !== previousAnchorPostIdRef.current) { + setConversationId(null); + setExchanges([]); + setQuestion(""); + setOlderTurnCursor(null); + setOlderTurnsError(false); + } + previousAnchorPostIdRef.current = anchorPostId; + }, [anchorPostId]); + + 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; + onClearAnchor?.(); + } 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, anchorPostId); + 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} -