diff --git a/.gitignore b/.gitignore index 54a94e390..64e82866d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __pycache__/ .codegraph/ .env .coverage + +# Local agent worktree scratch (never commit) +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index e9d097cc1..5daf15b45 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 0257) 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..2f23dd6d0 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 0257); 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..c1d658a59 --- /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 0257). +- 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/CLAUDE.md b/CLAUDE.md index a8ab9c3bb..7cc24d47d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ cutoff. Global Ask optional `knowledge_cutoff` uses the same cover Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity (ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs -(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233), the text-channel embedding swap and cosine +(ADR 0048–0164 / 0182 / 0185 / 0201 / 0233), occupational construct catalog search +(ADR 0257), the text-channel embedding swap and cosine clamp (ADR 0190), per-edge channel-score persistence (ADR 0195), migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before diff --git a/backend/app/main.py b/backend/app/main.py index 6481d2378..69b8d1944 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -148,6 +148,13 @@ from backend.app.occupational_construct_ingestion import ( load_occupational_construct_assertions, ) +from backend.app.occupational_construct_search import ( + OccupationalConstructSearchError, + occupational_construct_search_error_detail, + occupational_construct_search_http_status, + search_page_to_payload, + search_visible_occupational_constructs, +) from backend.app.post_content_worker import run_post_content_worker from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL, source_post_visible from backend.app.post_evaluation_ingestion import ( @@ -2545,6 +2552,43 @@ async def read_rating_source_occupations( ) +@app.get("/api/occupational-constructs/search") +async def search_occupational_constructs( + q: str = Query(..., min_length=1), + family: str | None = Query(None), + knowledge_cutoff: str | None = Query(None), + cursor: str | None = Query(None), + limit: int | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Assertion-backed catalog matches the reviewer may already open.""" + _require_post_read(account) + cutoff_clock = None + if knowledge_cutoff: + try: + cutoff_clock = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + try: + async with pool.acquire() as conn: + page = await search_visible_occupational_constructs( + conn, + query=q, + family_code=family, + knowledge_cutoff=cutoff_clock, + cursor=cursor, + limit=limit, + can_see_post=lambda row: _can_see_post(account, row), + ) + except OccupationalConstructSearchError as exc: + raise HTTPException( + occupational_construct_search_http_status(exc), + occupational_construct_search_error_detail(exc), + ) from None + return search_page_to_payload(page) + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/occupational_construct_search.py b/backend/app/occupational_construct_search.py new file mode 100644 index 000000000..e281aa411 --- /dev/null +++ b/backend/app/occupational_construct_search.py @@ -0,0 +1,302 @@ +"""Search assertion-backed occupational constructs under ADR 0257.""" + +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_CONSTRUCT_LIMIT = 200 +PER_CONSTRUCT_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] + if int(visible_rows[0]["construct_row_count"]) > PER_CONSTRUCT_ROW_LIMIT: + continue + 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 = """ + with matching_rows as ( + 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, + dense_rank() over (order by construct.construct_iri) as construct_rank, + row_number() over ( + partition by construct.construct_id + order by greatest(post.created_at, assertion.generated_at), post.post_id + ) as construct_row_number, + count(*) over (partition by construct.construct_id) as construct_row_count + 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 construct.construct_family_code in ( + 'cognitive_ability', 'work_style', 'work_activity' + ) + 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) + ) + select * from matching_rows + where construct_rank <= $5 + and construct_row_number <= $6 + order by construct_iri, available_at, post_id + """.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_CONSTRUCT_LIMIT, + PER_CONSTRUCT_ROW_LIMIT + 1, + ) + hits, extra_visible = _collapse_visible_hits(rows, can_see_post, limit=page_size) + candidate_constructs = { + str(_row_mapping(row)["construct_iri"]) for row in rows + } + sql_exhausted = len(candidate_constructs) == CANDIDATE_CONSTRUCT_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"]) + 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..2f459212f 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 0265](0265-occupational-construct-catalog-search.md). ## Verification diff --git a/docs/adr/0265-occupational-construct-catalog-search.md b/docs/adr/0265-occupational-construct-catalog-search.md new file mode 100644 index 000000000..20c6d6408 --- /dev/null +++ b/docs/adr/0265-occupational-construct-catalog-search.md @@ -0,0 +1,72 @@ +# ADR 0265: 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..f10df7664 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), [0265](0265-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..a0b2f3688 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 0257 + 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..a8b741521 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 0257 +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..427c52f37 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1081,6 +1081,45 @@ export function fetchWorkerFunctionConstructCatalog( return backendFetch("/api/ontology/worker-function-constructs", accessToken); } +export 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); +} + export function extractPostKeymen( accessToken: string, postId: string, 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..9062e78fd --- /dev/null +++ b/frontend/src/components/OccupationalConstructCatalogSearch.test.tsx @@ -0,0 +1,134 @@ +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(); + }); + + it("continues from next_cursor and retains earlier matches", async () => { + const user = userEvent.setup(); + vi.mocked(fetchOccupationalConstructSearch) + .mockResolvedValueOnce({ + query: "Oral", + family_code: null, + next_cursor: HIT.construct_iri, + hits: [HIT], + }) + .mockResolvedValueOnce({ + query: "Oral", + family_code: null, + next_cursor: null, + hits: [{ ...HIT, construct_id: "second", preferred_label: "Written Comprehension" }], + }); + render(); + await user.type(screen.getByLabelText("Catalog label"), "Oral"); + await user.click(screen.getByRole("button", { name: "Find matching records" })); + await user.click(screen.getByRole("button", { name: "Show more matching records" })); + expect(fetchOccupationalConstructSearch).toHaveBeenLastCalledWith("token", { + query: "Oral", + family: undefined, + knowledgeCutoff: undefined, + cursor: HIT.construct_iri, + }); + expect(screen.getByText(/Oral Comprehension/)).toBeVisible(); + expect(screen.getByText(/Written Comprehension/)).toBeVisible(); + }); +}); diff --git a/frontend/src/components/OccupationalConstructCatalogSearch.tsx b/frontend/src/components/OccupationalConstructCatalogSearch.tsx new file mode 100644 index 000000000..553e76467 --- /dev/null +++ b/frontend/src/components/OccupationalConstructCatalogSearch.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import type { FormEvent } 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"); + } + } + + async function onMore() { + if (!accessToken || !page?.next_cursor) return; + setStatus("loading"); + try { + const result = await fetchOccupationalConstructSearch(accessToken, { + query: page.query, + family: page.family_code || undefined, + knowledgeCutoff, + cursor: page.next_cursor, + }); + setPage({ ...result, hits: [...page.hits, ...result.hits] }); + setStatus("ready"); + } catch { + setStatus("error"); + } + } + + return ( +
+

{text("Find work evidence")}

+
+ + + +
+ {statusMessage(status)} + {status === "ready" && page && page.hits.length > 0 ? ( + <> +
    + {page.hits.map((hit) => ( + + ))} +
+ {page.next_cursor ? ( + + ) : null} + + ) : 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({ +