From e6017d3b10f735eb71ebab4ccc4d138a9b9ddb4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:22:52 +0000 Subject: [PATCH 1/8] feat(ontology): search assertion-backed occupational constructs Authorized reviewers can type an official O*NET catalog label in the ontology explorer and open the earliest visible supporting record (ADR 0256). Hidden posts, withdrawn truth, and catalog rows without visible evidence stay omitted. No scores or person traits. --- AGENTS.md | 7 + ARCHITECTURE.md | 2 + ...0-occupational-construct-catalog-search.md | 9 + backend/app/occupational_construct_search.py | 286 ++++++++++++++++++ ...upational-construct-ontology-navigation.md | 3 +- ...6-occupational-construct-catalog-search.md | 72 +++++ docs/adr/README.md | 4 +- .../OCCUPATIONAL_CONSTRUCT_REFERENCES.md | 4 +- docs/product-requirements.md | 6 +- frontend/src/App.css | 32 ++ frontend/src/api.ts | 38 +++ ...pationalConstructCatalogSearch.stories.tsx | 76 +++++ ...ccupationalConstructCatalogSearch.test.tsx | 105 +++++++ .../OccupationalConstructCatalogSearch.tsx | 180 +++++++++++ .../src/components/OntologyExplorer.test.tsx | 19 +- frontend/src/components/OntologyExplorer.tsx | 6 + frontend/src/occupationalConstructI18n.ts | 78 +++++ lineageweave/__init__.py | 2 +- ..._occupational_construct_catalog_search.sql | 11 + ..._occupational_construct_catalog_search.sql | 3 + ...t_occupational_construct_catalog_schema.py | 10 + tests/test_occupational_construct_search.py | 201 ++++++++++++ 22 files changed, 1145 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.d/2.20.0-occupational-construct-catalog-search.md create mode 100644 backend/app/occupational_construct_search.py create mode 100644 docs/adr/0256-occupational-construct-catalog-search.md create mode 100644 frontend/src/components/OccupationalConstructCatalogSearch.stories.tsx create mode 100644 frontend/src/components/OccupationalConstructCatalogSearch.test.tsx create mode 100644 frontend/src/components/OccupationalConstructCatalogSearch.tsx create mode 100644 migrations/0242_occupational_construct_catalog_search.sql create mode 100644 migrations/rollback/0242_occupational_construct_catalog_search.sql create mode 100644 tests/test_occupational_construct_search.py diff --git a/AGENTS.md b/AGENTS.md index e9d097cc1..49c073e22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,6 +292,13 @@ and are not a leftover score. Complete-case coverage (ADR 0168) persists to `report_leftover_map_coverage` and captions the pair list with how many scored posts entered the map. +Authorized occupational construct catalog search (ADR 0256) matches official +O*NET preferred labels or descriptions only when a source-eligible, ABAC-visible +Post supports that construct. Hidden Posts, withdrawn truth, and conflicting +truth statuses omit the hit. Clicking a hit opens that Post. Do not return +catalog rows as a vocabulary oracle, scores, or person traits. Continuation +is a construct-IRI keyset; never OFFSET. + Global Ask relative-time filters (ADR 0150 / 0202) bind to `source_post.event_occurred_at` and fall back to `created_at` only when the event instant is missing. Cited evidence names **Time diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75683aa63..e9f0576db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,8 @@ flowchart LR | `backend/app/occupation_rating_ingestion.py` | Projects authenticated occupation-rating evidence plus persisted source and represented-occupation catalogs (ADR 0258, ADR 0260, ADR 0261) | | `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source, filters stored occupation titles without ranking, and reads exact Dashboard evidence while preserving absence, uncertainty, and warning semantics (ADR 0259–0262) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | +| `occupational_construct_catalog.py` | Official O*NET 31.0 construct catalog sync (ADR 0250); no ratings or invented IRIs | +| `backend/app/occupational_construct_search.py` | Authorized catalog-label search over assertion-backed constructs (ADR 0256); hidden Posts never mint a hit | | `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | | `period_report.py` | Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) | | `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo | diff --git a/CHANGELOG.d/2.20.0-occupational-construct-catalog-search.md b/CHANGELOG.d/2.20.0-occupational-construct-catalog-search.md new file mode 100644 index 000000000..1e145fc10 --- /dev/null +++ b/CHANGELOG.d/2.20.0-occupational-construct-catalog-search.md @@ -0,0 +1,9 @@ +# 2.20.0 — Authorized occupational construct catalog search + +- Reviewers can search official O*NET cognitive-ability, work-style, and + work-activity labels from the ontology explorer and open the earliest + visible supporting record (ADR 0256). +- Hits require source-eligible, ABAC-visible assertion evidence. Hidden + Posts, withdrawn truth, and conflicting truth statuses stay omitted. +- Continuation uses a construct-IRI keyset. OFFSET, scores, person traits, + and catalog-only oracles remain unavailable. diff --git a/backend/app/occupational_construct_search.py b/backend/app/occupational_construct_search.py new file mode 100644 index 000000000..d759d17e6 --- /dev/null +++ b/backend/app/occupational_construct_search.py @@ -0,0 +1,286 @@ +"""Search assertion-backed occupational constructs under ADR 0256.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Callable, Mapping + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + +SEARCHABLE_FAMILIES = frozenset( + {"cognitive_ability", "work_style", "work_activity"} +) +MIN_QUERY_CHARS = 2 +MAX_QUERY_CHARS = 80 +DEFAULT_SEARCH_LIMIT = 20 +HARD_SEARCH_LIMIT = 50 +CANDIDATE_ROW_LIMIT = 200 +CONSTRUCT_IRI_PREFIX = "https://data.onetcenter.org/element/" +WITHDRAWN_TRUTH_STATUSES = frozenset({"truth_rejected", "truth_superseded"}) + + +class OccupationalConstructSearchError(ValueError): + """Fail-closed catalog-search input or cursor.""" + + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class OccupationalConstructSearchHit: + """One visible catalog match and the Post a reviewer should open next.""" + + construct_id: str + construct_iri: str + construct_family_code: str + preferred_label: str + vocabulary_version: str + supporting_post_id: str + supporting_post_title: str + evidence_text: str + truth_status_code: str + + +@dataclass(frozen=True) +class OccupationalConstructSearchPage: + """One keyset page of authorized catalog matches.""" + + query: str + family_code: str | None + hits: tuple[OccupationalConstructSearchHit, ...] + next_cursor: str | None + + +def like_contains_pattern(query: str) -> str: + """Return a LIKE pattern that treats the query as a literal substring.""" + escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +def normalize_construct_search_query(raw: str) -> str: + """Trim and bound a catalog-search query or fail closed.""" + query = raw.strip() + if len(query) < MIN_QUERY_CHARS: + raise OccupationalConstructSearchError( + "query_too_short", + "Type two or more characters of a catalog label, then search.", + ) + if len(query) > MAX_QUERY_CHARS: + raise OccupationalConstructSearchError( + "query_too_long", + "Shorten the catalog label before searching.", + ) + return query + + +def normalize_construct_search_family(raw: str | None) -> str | None: + """Admit only synchronized O*NET construct families.""" + if raw is None or raw.strip() == "": + return None + family = raw.strip() + if family not in SEARCHABLE_FAMILIES: + raise OccupationalConstructSearchError( + "unknown_family", + "Search only cognitive ability, work style, or work activity.", + ) + return family + + +def normalize_construct_search_cursor(raw: str | None) -> str | None: + """Accept only an official O*NET element IRI as a keyset cursor.""" + if raw is None or raw.strip() == "": + return None + cursor = raw.strip() + suffix = cursor.removeprefix(CONSTRUCT_IRI_PREFIX) + if not cursor.startswith(CONSTRUCT_IRI_PREFIX) or not suffix or "/" in suffix: + raise OccupationalConstructSearchError( + "invalid_cursor", + "Resume search from the last returned catalog IRI.", + ) + return cursor + + +def normalize_construct_search_limit(raw: int | None) -> int: + """Bound the visible page size without using OFFSET.""" + limit = DEFAULT_SEARCH_LIMIT if raw is None else raw + if limit < 1 or limit > HARD_SEARCH_LIMIT: + raise OccupationalConstructSearchError( + "invalid_limit", + f"Request between 1 and {HARD_SEARCH_LIMIT} catalog matches.", + ) + return limit + + +def search_page_to_payload(page: OccupationalConstructSearchPage) -> dict[str, object]: + """JSON object for GET /api/occupational-constructs/search.""" + return { + "query": page.query, + "family_code": page.family_code, + "next_cursor": page.next_cursor, + "hits": [ + { + "construct_id": hit.construct_id, + "construct_iri": hit.construct_iri, + "construct_family_code": hit.construct_family_code, + "preferred_label": hit.preferred_label, + "vocabulary_version": hit.vocabulary_version, + "supporting_post_id": hit.supporting_post_id, + "supporting_post_title": hit.supporting_post_title, + "evidence_text": hit.evidence_text, + "truth_status_code": hit.truth_status_code, + } + for hit in page.hits + ], + } + + +def _row_mapping(row: Any) -> Mapping[str, Any]: + """Accept asyncpg records and test dictionaries.""" + if isinstance(row, Mapping): + return row + return {key: row[key] for key in row.keys()} + + +def _collapse_visible_hits( + rows: list[Any], + can_see_post: Callable[[Any], bool], + *, + limit: int, +) -> tuple[list[OccupationalConstructSearchHit], bool]: + """Keep one earliest visible Post per construct; drop conflicts and withdrawn truth.""" + grouped: dict[str, list[Mapping[str, Any]]] = {} + order: list[str] = [] + for row in rows: + if not can_see_post(row): + continue + mapping = _row_mapping(row) + construct_id = str(mapping["construct_id"]) + if construct_id not in grouped: + grouped[construct_id] = [] + order.append(construct_id) + grouped[construct_id].append(mapping) + + hits: list[OccupationalConstructSearchHit] = [] + for construct_id in order: + visible_rows = grouped[construct_id] + truth_statuses = {str(item["truth_status_code"]) for item in visible_rows} + if len(truth_statuses) != 1: + continue + truth_status = next(iter(truth_statuses)) + if truth_status in WITHDRAWN_TRUTH_STATUSES: + continue + chosen = min( + visible_rows, + key=lambda item: (item["available_at"], str(item["post_id"])), + ) + hits.append( + OccupationalConstructSearchHit( + construct_id=str(chosen["construct_id"]), + construct_iri=str(chosen["construct_iri"]), + construct_family_code=str(chosen["construct_family_code"]), + preferred_label=str(chosen["preferred_label"]), + vocabulary_version=str(chosen["version_label"]), + supporting_post_id=str(chosen["post_id"]), + supporting_post_title=str(chosen["post_title"]), + evidence_text=str(chosen["evidence_text"]), + truth_status_code=truth_status, + ) + ) + if len(hits) == limit + 1: + break + truncated = len(hits) > limit + return hits[:limit], truncated + + +async def search_visible_occupational_constructs( + conn: Any, + *, + query: str, + can_see_post: Callable[[Any], bool], + family_code: str | None = None, + knowledge_cutoff: datetime | None = None, + cursor: str | None = None, + limit: int | None = None, +) -> OccupationalConstructSearchPage: + """Return assertion-backed catalog matches the account may already read.""" + normalized_query = normalize_construct_search_query(query) + normalized_family = normalize_construct_search_family(family_code) + normalized_cursor = normalize_construct_search_cursor(cursor) + page_size = normalize_construct_search_limit(limit) + eligibility = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") + sql = """ + select construct.construct_id, + construct.construct_iri, + construct.construct_family_code, + construct.preferred_label, + vocabulary.version_label, + post.post_id, + post.post_title, + post.visibility_code, + post.corporate_entity_id, + post.process_unit_id, + assertion.evidence_text, + assertion.truth_status_code, + greatest(post.created_at, assertion.generated_at) as available_at + from occupational_construct construct + join occupational_construct_vocabulary vocabulary + on vocabulary.vocabulary_id = construct.vocabulary_id + join post_occupational_construct_assertion assertion + on assertion.construct_id = construct.construct_id + join source_post post on post.post_id = assertion.post_id + join post_occupational_construct_extraction extraction + on extraction.post_id = assertion.post_id + join post_content_ingestion_job job + on job.post_id = assertion.post_id + and job.source_body_sha256 = extraction.source_body_sha256 + where ( + construct.preferred_label ilike $1 escape E'\\' + or coalesce(construct.construct_description, '') ilike $1 escape E'\\' + ) + and ($2::text is null or construct.construct_family_code = $2) + and ($3::text is null or construct.construct_iri > $3) + and {eligibility} + and ($4::timestamptz is null + or greatest(post.created_at, assertion.generated_at) <= $4) + order by construct.construct_iri, + greatest(post.created_at, assertion.generated_at), + post.post_id + limit $5 + """.replace("{eligibility}", eligibility) + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + sql, + like_contains_pattern(normalized_query), + normalized_family, + normalized_cursor, + knowledge_cutoff, + CANDIDATE_ROW_LIMIT, + ) + hits, extra_visible = _collapse_visible_hits(rows, can_see_post, limit=page_size) + sql_exhausted = len(rows) == CANDIDATE_ROW_LIMIT + next_cursor = None + if extra_visible and hits: + next_cursor = hits[-1].construct_iri + elif sql_exhausted and rows: + next_cursor = str(_row_mapping(rows[-1])["construct_iri"]) + if hits and next_cursor == hits[-1].construct_iri: + next_cursor = hits[-1].construct_iri + return OccupationalConstructSearchPage( + query=normalized_query, + family_code=normalized_family, + hits=tuple(hits), + next_cursor=next_cursor, + ) + + +def occupational_construct_search_http_status(exc: OccupationalConstructSearchError) -> int: + """Map search-input failures to HTTP 422.""" + del exc + return 422 + + +def occupational_construct_search_error_detail(exc: OccupationalConstructSearchError) -> str: + """Return the buyer-facing search failure text.""" + return exc.detail diff --git a/docs/adr/0255-occupational-construct-ontology-navigation.md b/docs/adr/0255-occupational-construct-ontology-navigation.md index c2aee35db..87cdfca0e 100644 --- a/docs/adr/0255-occupational-construct-ontology-navigation.md +++ b/docs/adr/0255-occupational-construct-ontology-navigation.md @@ -40,7 +40,8 @@ Posts would also turn the endpoint into an unauthorized vocabulary oracle. occupational concepts without exposing hidden Posts or catalog membership. - Multiple evidence units collapse only when their truth semantics agree. - Catalog search remains a separate increment; this decision exposes only - assertion-backed nodes in an already-authorized neighborhood. + assertion-backed nodes in an already-authorized neighborhood. Authorized + label search is [ADR 0256](0256-occupational-construct-catalog-search.md). ## Verification diff --git a/docs/adr/0256-occupational-construct-catalog-search.md b/docs/adr/0256-occupational-construct-catalog-search.md new file mode 100644 index 000000000..88bbaba74 --- /dev/null +++ b/docs/adr/0256-occupational-construct-catalog-search.md @@ -0,0 +1,72 @@ +# ADR 0256: Authorized occupational construct catalog search + +**Status:** Accepted +**Date:** 2026-08-27 +**Extends:** [ADR 0184](0184-ontology-provenance-explorer.md), [ADR 0248](0248-occupational-construct-evidence-boundary.md), [ADR 0250](0250-official-occupational-construct-catalog-sync.md), [ADR 0255](0255-occupational-construct-ontology-navigation.md) + +## Context + +ADR 0255 projects assertion-backed occupational constructs into the bounded +ontology neighborhood. Reviewers can walk from a visible Post to a versioned +O*NET concept, but they cannot start from a catalog label. A raw catalog +lookup would become a vocabulary oracle: it would disclose official membership +and descriptions even when the reviewer has no supporting Post. + +PRD-FR-2B therefore left catalog search unavailable until this increment. + +## Decision + +1. `GET /api/occupational-constructs/search` matches the official preferred + label or description of a synchronized O*NET 31.0 construct. The query is + a case-insensitive exact-substring filter. LIKE metacharacters in the + query are escaped so `%` and `_` stay literal. Fuzzy ranking, scores, and + person/job inference stay unavailable. +2. A hit is admitted only when at least one source-eligible Post that passes + the existing ABAC callback supports that construct. Hidden Posts never + create a hit, fill a cursor, or change visible labels. Constructs with no + visible support are omitted; missing and unauthorized catalog rows share + the same empty page. +3. Conflicting truth statuses on the visible supporting Posts omit that + construct, matching ADR 0255. `truth_rejected` and `truth_superseded` do + not create a search hit. +4. Each hit names one supporting Post: the earliest visible availability + instant (`greatest(post.created_at, assertion.generated_at)`), then Post + id. The payload carries construct id/IRI/family/label, catalog version, + that Post id and title, the verbatim evidence span, and the agreed truth + status. It does not dump the official description, hidden totals, unit + ids, or extraction method. +5. Continuation is a keyset on `construct_iri`. `OFFSET` is forbidden. The + opaque cursor is the last returned official IRI; a tampered or non-O*NET + cursor fails closed. Default page size is 20; the hard maximum is 50. +6. Optional `family` admits only `cognitive_ability`, `work_style`, and + `work_activity`. Affective and performance families remain unavailable + until an authoritative vocabulary is accepted. Optional `knowledge_cutoff` + uses the same availability clock as ADR 0255. +7. The explorer hosts the search. It is not a new GNB destination. Customer + copy tells the reviewer to type a catalog label and open the supporting + record. Clicking a hit opens that Post. + +## Consequences + +- Reviewers can find Oral Comprehension (or another official label) across + records they may already read, then open the cited Post. +- Catalog membership without visible evidence stays undisclosed. +- Occupation ratings, DPT crosswalks, and person traits remain out of scope. + +## Verification + +- Search tests cover substring escaping, family and cursor validation, hidden + Post omission, truth-conflict omission, cutoff, pagination, and payload + shape. +- Schema tests require replay-safe label/description indexes. +- Frontend tests cover short-query guidance, no-match and error states, + click-to-open, family filter, and localized next actions. Storybook adds + populated, empty, no-match, and loading scenes. + +## References + +See +[`docs/doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md). + +Open Worldwide Application Security Project. (2023). *API1:2023 broken object +level authorization*. https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ diff --git a/docs/adr/README.md b/docs/adr/README.md index de21b1028..1e48700c6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,9 +28,7 @@ decision from them. | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) | -| [`OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md) | [0248](0248-occupational-construct-evidence-boundary.md), [0250](0250-official-occupational-construct-catalog-sync.md), [0253](0253-catalog-bound-occupational-construct-extraction.md), [0255](0255-occupational-construct-ontology-navigation.md) | - -| [`OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md) | [0248](0248-occupational-construct-evidence-boundary.md) | +| [`OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md) | [0248](0248-occupational-construct-evidence-boundary.md), [0250](0250-official-occupational-construct-catalog-sync.md), [0253](0253-catalog-bound-occupational-construct-extraction.md), [0255](0255-occupational-construct-ontology-navigation.md), [0256](0256-occupational-construct-catalog-search.md) | | [`IOPSY_TAXONOMY_REFERENCES.md`](../doctoring/IOPSY_TAXONOMY_REFERENCES.md) | [0251](0251-fja-iopsy-cognitive-affective-behavioral-ontology.md) | | [`OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md) | [0248](0248-occupational-construct-evidence-boundary.md) | diff --git a/docs/doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md b/docs/doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md index 7f7aba651..625f39bf7 100644 --- a/docs/doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md +++ b/docs/doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md @@ -14,7 +14,9 @@ source actually supports and which tempting inferences remain prohibited. - The O*NET 31.0 Content Model Reference publishes 3,006 hierarchy elements. ADR 0250 admits only the source-defined cognitive-ability (`1.A.1`), work- style (`1.D`), and work-activity (`4.A`) roots and descendants; it preserves - blank descriptions as unavailable and stores no occupation rating. + blank descriptions as unavailable and stores no occupation rating. ADR 0256 + searches those official labels only through ABAC-visible supporting Posts; + a catalog row without visible evidence is not a hit. - The O*NET Content Model separates worker characteristics and requirements from occupational requirements. It does not make FJA worker functions equivalent to abilities, dispositions, or affect. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 177836d50..5c4300639 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -108,8 +108,10 @@ and a digest-bound run record distinguishes a supported empty result from an unavailable provider. ADR 0254 adds the authorized Post-detail evidence review surface and honest complete, processing, and unavailable states. ADR 0255 projects assertion-backed constructs into the existing ABAC-filtered ontology -neighborhood without duplicating graph storage or promoting truth. Catalog -search remains unavailable. +neighborhood without duplicating graph storage or promoting truth. ADR 0256 +adds authorized catalog-label search: reviewers type an official O*NET label +and open the earliest visible supporting Post. Constructs without visible +evidence stay undisclosed. Occupation ratings remain unavailable. ### PRD-FR-2C — FJA I/O-Psychology cognitive, affective & behavioral semantic layer diff --git a/frontend/src/App.css b/frontend/src/App.css index b9a7b8df8..78e4d09a9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1222,6 +1222,38 @@ margin: 0.75rem 0; } +.occupational-construct-catalog-search { + display: flex; + flex-direction: column; + gap: var(--space-control-gap, 0.75rem); + margin: 0.75rem 0 1rem; + padding: var(--space-panel-block, 0.75rem); + border: 1px solid var(--color-table-border, var(--border)); + border-radius: var(--radius-panel, 0.5rem); +} + +.occupational-construct-catalog-search-form { + display: flex; + flex-wrap: wrap; + gap: var(--space-control-gap, 0.75rem); + align-items: flex-end; +} + +.occupational-construct-catalog-search-form .ontology-search { + flex: 1 1 12rem; + margin: 0; +} + +.occupational-construct-catalog-search-form button, +.occupational-construct-catalog-search .post-list-item { + min-height: var(--size-control-min, 44px); +} + +.occupational-construct-catalog-search q { + display: block; + overflow-wrap: anywhere; +} + .ontology-graph { max-width: 100%; overflow: visible; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 92893276e..04e09cb2b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1079,6 +1079,44 @@ export function fetchWorkerFunctionConstructCatalog( accessToken: string, ): Promise { return backendFetch("/api/ontology/worker-function-constructs", accessToken); +xport interface OccupationalConstructSearchHit { + construct_id: string; + construct_iri: string; + construct_family_code: string; + preferred_label: string; + vocabulary_version: string; + supporting_post_id: string; + supporting_post_title: string; + evidence_text: string; + truth_status_code: string; +} + +export interface OccupationalConstructSearchPage { + query: string; + family_code: string | null; + next_cursor: string | null; + hits: OccupationalConstructSearchHit[]; +} + +export interface OccupationalConstructSearchQuery { + query: string; + family?: string; + knowledgeCutoff?: string; + cursor?: string; + limit?: number; +} + +export function fetchOccupationalConstructSearch( + accessToken: string, + query: OccupationalConstructSearchQuery, +): Promise { + const params = new URLSearchParams({ q: query.query }); + if (query.family) params.set("family", query.family); + if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); + if (query.cursor) params.set("cursor", query.cursor); + if (query.limit != null) params.set("limit", String(query.limit)); + return backendFetch(`/api/occupational-constructs/search?${params.toString()}`, accessToken); +(feat(ontology): search assertion-backed occupational constructs) } export function extractPostKeymen( diff --git a/frontend/src/components/OccupationalConstructCatalogSearch.stories.tsx b/frontend/src/components/OccupationalConstructCatalogSearch.stories.tsx new file mode 100644 index 000000000..a18687a39 --- /dev/null +++ b/frontend/src/components/OccupationalConstructCatalogSearch.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { OccupationalConstructSearchPage } from "../api"; +import { OccupationalConstructCatalogSearch } from "./OccupationalConstructCatalogSearch"; + +const populated: OccupationalConstructSearchPage = { + query: "Oral", + family_code: "cognitive_ability", + next_cursor: null, + hits: [ + { + construct_id: "99999999-9999-9999-9999-999999999999", + construct_iri: "https://data.onetcenter.org/element/1.A.1.a.1", + construct_family_code: "cognitive_ability", + preferred_label: "Oral Comprehension", + vocabulary_version: "31.0", + supporting_post_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + supporting_post_title: "Synthetic briefing", + evidence_text: "reviewed the written procedure", + truth_status_code: "truth_inferred", + }, + ], +}; + +const meta = { + title: "Evidence/OccupationalConstructCatalogSearch", + component: OccupationalConstructCatalogSearch, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Idle: Story = {}; + +export const Populated: Story = { + args: { + page: populated, + status: "ready", + }, +}; + +export const NoMatches: Story = { + args: { + page: { query: "Oral", family_code: null, next_cursor: null, hits: [] }, + status: "empty", + }, +}; + +export const Loading: Story = { + args: { + status: "loading", + }, +}; + +export const Unavailable: Story = { + args: { + status: "error", + }, +}; + +export const NarrowViewport: Story = { + args: { + page: populated, + status: "ready", + }, + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/src/components/OccupationalConstructCatalogSearch.test.tsx b/frontend/src/components/OccupationalConstructCatalogSearch.test.tsx new file mode 100644 index 000000000..bb6aac1f6 --- /dev/null +++ b/frontend/src/components/OccupationalConstructCatalogSearch.test.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BackendError, fetchOccupationalConstructSearch } from "../api"; +import { setLocale } from "../i18n"; +import { OccupationalConstructCatalogSearch } from "./OccupationalConstructCatalogSearch"; + +vi.mock("../api", async (importActual) => { + const actual = await importActual(); + return { ...actual, fetchOccupationalConstructSearch: vi.fn() }; +}); + +const HIT = { + construct_id: "99999999-9999-9999-9999-999999999999", + construct_iri: "https://data.onetcenter.org/element/1.A.1.a.1", + construct_family_code: "cognitive_ability", + preferred_label: "Oral Comprehension", + vocabulary_version: "31.0", + supporting_post_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + supporting_post_title: "Synthetic briefing", + evidence_text: "reviewed the written procedure", + truth_status_code: "truth_inferred", +}; + +describe("OccupationalConstructCatalogSearch", () => { + afterEach(() => { + setLocale("en"); + vi.mocked(fetchOccupationalConstructSearch).mockReset(); + }); + + it("does not search until two letters are submitted", async () => { + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("Catalog label"), "O"); + await user.click(screen.getByRole("button", { name: "Find matching records" })); + expect(fetchOccupationalConstructSearch).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent( + "Type two or more letters of a catalog label, then open the supporting record.", + ); + }); + + it("opens the supporting record from a visible catalog match", async () => { + const user = userEvent.setup(); + const onSelectPost = vi.fn(); + vi.mocked(fetchOccupationalConstructSearch).mockResolvedValue({ + query: "Oral", + family_code: null, + next_cursor: null, + hits: [HIT], + }); + render( + , + ); + await user.type(screen.getByLabelText("Catalog label"), "Oral"); + await user.selectOptions(screen.getByLabelText("Work-evidence family"), "cognitive_ability"); + await user.click(screen.getByRole("button", { name: "Find matching records" })); + expect(fetchOccupationalConstructSearch).toHaveBeenCalledWith("token", { + query: "Oral", + family: "cognitive_ability", + knowledgeCutoff: undefined, + }); + await user.click( + screen.getByRole("button", { name: "Open supporting record: Oral Comprehension · Synthetic briefing" }), + ); + expect(onSelectPost).toHaveBeenCalledWith(HIT.supporting_post_id); + expect(screen.getByText("Open the supporting record")).toBeVisible(); + expect(screen.queryByText(/score/i)).not.toBeInTheDocument(); + }); + + it("keeps empty and error states honest", async () => { + const user = userEvent.setup(); + vi.mocked(fetchOccupationalConstructSearch).mockResolvedValueOnce({ + query: "Oral", + family_code: null, + next_cursor: null, + hits: [], + }); + const { rerender } = render(); + await user.type(screen.getByLabelText("Catalog label"), "Oral"); + await user.click(screen.getByRole("button", { name: "Find matching records" })); + expect(screen.getByRole("status")).toHaveTextContent( + "No visible work evidence matches. Open a record with work evidence next.", + ); + + vi.mocked(fetchOccupationalConstructSearch).mockRejectedValueOnce( + new BackendError("/api/occupational-constructs/search", 500), + ); + rerender(); + await user.click(screen.getByRole("button", { name: "Find matching records" })); + expect(screen.getByRole("status")).toHaveTextContent( + "Work-evidence search is unavailable. Open a visible record next.", + ); + }); + + it("localizes the next action", () => { + setLocale("ko"); + render( + , + ); + expect(screen.getByText("뒷받침하는 기록 열기")).toBeVisible(); + }); +}); diff --git a/frontend/src/components/OccupationalConstructCatalogSearch.tsx b/frontend/src/components/OccupationalConstructCatalogSearch.tsx new file mode 100644 index 000000000..30a53fc6c --- /dev/null +++ b/frontend/src/components/OccupationalConstructCatalogSearch.tsx @@ -0,0 +1,180 @@ +import { FormEvent, useState } from "react"; +import { + BackendError, + fetchOccupationalConstructSearch, + type OccupationalConstructSearchHit, + type OccupationalConstructSearchPage, +} from "../api"; +import { + occupationalConstructFormat, + occupationalConstructText as text, + type OccupationalConstructCopyKey, +} from "../occupationalConstructI18n"; + +export type OccupationalConstructCatalogSearchStatus = + | "idle" + | "loading" + | "ready" + | "empty" + | "error"; + +const FAMILY_OPTIONS: { value: string; label: OccupationalConstructCopyKey }[] = [ + { value: "", label: "All families" }, + { value: "cognitive_ability", label: "Cognitive ability" }, + { value: "work_style", label: "Work style" }, + { value: "work_activity", label: "Work activity" }, +]; + +const FAMILY_BADGE: Record = { + cognitive_ability: "Cognitive ability", + work_style: "Work style", + work_activity: "Work activity", +}; + +export type OccupationalConstructCatalogSearchProps = { + accessToken?: string; + knowledgeCutoff?: string; + page?: OccupationalConstructSearchPage | null; + status?: OccupationalConstructCatalogSearchStatus; + onSelectPost?: (postId: string) => void; +}; + +/** + * Find assertion-backed catalog labels, then open the supporting record. + */ +export function OccupationalConstructCatalogSearch({ + accessToken, + knowledgeCutoff, + page: provided, + status: providedStatus, + onSelectPost, +}: OccupationalConstructCatalogSearchProps) { + const [query, setQuery] = useState(provided?.query ?? ""); + const [family, setFamily] = useState(provided?.family_code ?? ""); + const [page, setPage] = useState(provided ?? null); + const [status, setStatus] = useState( + providedStatus ?? (provided ? (provided.hits.length ? "ready" : "empty") : "idle"), + ); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length < 2) { + setPage(null); + setStatus("idle"); + return; + } + if (!accessToken) { + setPage(null); + setStatus("error"); + return; + } + setStatus("loading"); + try { + const result = await fetchOccupationalConstructSearch(accessToken, { + query: trimmed, + family: family || undefined, + knowledgeCutoff, + }); + setPage(result); + setStatus(result.hits.length ? "ready" : "empty"); + } catch (error: unknown) { + setPage(null); + if (error instanceof BackendError && error.status === 422) { + setStatus("idle"); + return; + } + setStatus("error"); + } + } + + return ( +
+

{text("Find work evidence")}

+
+ + + +
+ {statusMessage(status)} + {status === "ready" && page && page.hits.length > 0 ? ( +
    + {page.hits.map((hit) => ( + + ))} +
+ ) : null} +
+ ); +} + +function statusMessage(status: OccupationalConstructCatalogSearchStatus) { + const messages: Record = { + idle: text("Type two or more letters of a catalog label, then open the supporting record."), + loading: text("Finding work evidence..."), + ready: "", + empty: text("No visible work evidence matches. Open a record with work evidence next."), + error: text("Work-evidence search is unavailable. Open a visible record next."), + }; + const message = messages[status]; + if (!message) return null; + return ( +

+ {message} +

+ ); +} + +function CatalogHitItem({ + hit, + onSelectPost, +}: { + hit: OccupationalConstructSearchHit; + onSelectPost?: (postId: string) => void; +}) { + const familyLabel = text(FAMILY_BADGE[hit.construct_family_code] ?? "Work evidence"); + return ( +
  • + +
  • + ); +} diff --git a/frontend/src/components/OntologyExplorer.test.tsx b/frontend/src/components/OntologyExplorer.test.tsx index 6115bf46c..7820d383c 100644 --- a/frontend/src/components/OntologyExplorer.test.tsx +++ b/frontend/src/components/OntologyExplorer.test.tsx @@ -8,7 +8,7 @@ import { filterNeighborhood } from "../ontologyLayout"; vi.mock("../api", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, fetchOntologyNeighborhood: vi.fn() }; + return { ...actual, fetchOntologyNeighborhood: vi.fn(), fetchOccupationalConstructSearch: vi.fn() }; }); const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; @@ -549,4 +549,21 @@ describe("OntologyExplorer", () => { screen.queryByText("Select a work-evidence node to review the records that support it."), ).not.toBeInTheDocument(); }); + + it("hosts authorized catalog search without a second destination", () => { + render( + , + ); + expect(screen.getByRole("heading", { name: "Find work evidence" })).toBeVisible(); + expect( + screen.getByText( + "Type two or more letters of a catalog label, then open the supporting record.", + ), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Find matching records" })).toBeVisible(); + }); }); diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx index 0cb642c6c..fb05b0144 100644 --- a/frontend/src/components/OntologyExplorer.tsx +++ b/frontend/src/components/OntologyExplorer.tsx @@ -9,6 +9,7 @@ import { import { t, tf } from "../i18n"; import { ontologyExplorerText } from "../ontologyExplorerI18n"; import { occupationalConstructText } from "../occupationalConstructI18n"; +import { OccupationalConstructCatalogSearch } from "./OccupationalConstructCatalogSearch"; import { accumulateNeighborhoodPages, filterNeighborhood, @@ -229,6 +230,11 @@ export function OntologyExplorer({ +