From f19a663b87445fc8a2350cfaff3877dd7df6e798 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:22:31 +0000 Subject: [PATCH 1/5] feat(ontology): catalog observed occupations by published title Let authenticated users select an occupation that actually has observations in the chosen imported source, showing the published title instead of requiring a typed O*NET-SOC code (ADR 0261). --- ARCHITECTURE.md | 4 +- CHANGELOG.d/2.21.0-occupation-catalog.md | 6 + CHANGELOG.md | 4 + backend/app/main.py | 23 ++++ backend/app/occupation_rating_ingestion.py | 73 +++++++++++- ...61-occupation-rating-occupation-catalog.md | 46 ++++++++ docs/adr/README.md | 1 + docs/product-requirements.md | 22 +++- docs/product-technical-gap-baseline.md | 2 +- docs/storybook-inventory.md | 2 +- frontend/src/App.css | 2 +- frontend/src/api.test.ts | 19 +++ frontend/src/api.ts | 21 ++++ .../OccupationRatingProfile.stories.tsx | 97 ++++++++++++---- .../OccupationRatingProfile.test.tsx | 101 +++++++++------- .../components/OccupationRatingProfile.tsx | 108 +++++++++++++----- tests/test_occupation_rating_ingestion.py | 81 ++++++++++++- tests/test_schema.py | 48 +++++++- 18 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 CHANGELOG.d/2.21.0-occupation-catalog.md create mode 100644 docs/adr/0261-occupation-rating-occupation-catalog.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 44838b1a6..d1ed6ca8c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,8 +80,8 @@ flowchart LR | `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) | | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | | `ontology.py` | Loads the governed Turtle source tree (`lineageweave-kg.ttl` plus generated fragments), the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types, source taxonomies, and published O*NET linkages (ADR 0004, ADR 0252, ADR 0255, ADR 0256) | -| `backend/app/occupation_rating_ingestion.py` | Projects authenticated, bounded occupation-rating evidence and the persisted selectable-source catalog (ADR 0258, ADR 0260) | -| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260) | +| `backend/app/occupation_rating_ingestion.py` | Projects authenticated, bounded occupation-rating evidence, the persisted selectable-source catalog, and occupations that have observations in a selected source (ADR 0258, ADR 0260, ADR 0261) | +| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and published occupation title, then reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260, ADR 0261) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `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) | diff --git a/CHANGELOG.d/2.21.0-occupation-catalog.md b/CHANGELOG.d/2.21.0-occupation-catalog.md new file mode 100644 index 000000000..80525974a --- /dev/null +++ b/CHANGELOG.d/2.21.0-occupation-catalog.md @@ -0,0 +1,6 @@ +### Added + +- Occupation evidence now selects a published occupation title from occupations + that actually have observations in the chosen imported source, with fail-closed + empty/unavailable catalog states and the retained title on the opened profile + (ADR 0261). diff --git a/CHANGELOG.md b/CHANGELOG.md index f99d034da..1ac40d85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows ### Added +- Occupation evidence now selects a published occupation title from occupations + that actually have observations in the chosen imported source, with fail-closed + empty/unavailable catalog states and the retained title on the opened profile + (ADR 0261). - Occupation evidence source selection now comes from an authenticated catalog of actually imported rating artifacts, with release, publisher, license, digest, URL, and row-count provenance and fail-closed loading/empty/error diff --git a/backend/app/main.py b/backend/app/main.py index ee7d32133..63abca230 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -99,6 +99,7 @@ ) from backend.app.operations_dashboard import fetch_operations_dashboard from backend.app.occupation_rating_ingestion import ( + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -2315,6 +2316,28 @@ async def read_occupation_rating_sources( return await fetch_occupation_rating_sources(conn) +@app.get( + "/api/occupation-rating-sources/{data_release_code}/{source_table_code}/occupations" +) +async def read_occupation_rating_occupations( + data_release_code: str = Path( + ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" + ), + source_table_code: str = Path( + ..., min_length=1, max_length=63, pattern=r"^[a-z][a-z0-9_]*$" + ), + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, object]: + """Return occupations that have observations in one imported rating source.""" + async with pool.acquire() as conn: + return await fetch_occupation_rating_occupations( + conn, + data_release_code=data_release_code, + source_table_code=source_table_code, + ) + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/occupation_rating_ingestion.py b/backend/app/occupation_rating_ingestion.py index 2324359f7..434ac54d4 100644 --- a/backend/app/occupation_rating_ingestion.py +++ b/backend/app/occupation_rating_ingestion.py @@ -5,6 +5,8 @@ from decimal import Decimal from typing import Any, Protocol +OCCUPATION_CATALOG_BOUND = 2000 + class RatingReadConnection(Protocol): """Small asyncpg-compatible surface used by the rating read projection.""" @@ -21,6 +23,14 @@ def _decimal_text(value: Decimal | None) -> str | None: return str(value) if value is not None else None +def _optional_mapping_value(row: Any, key: str) -> Any: + """Return a mapping value when the projection supplied the column.""" + try: + return row[key] + except (KeyError, IndexError): + return None + + async def fetch_occupation_ratings( conn: RatingReadConnection, *, @@ -38,21 +48,27 @@ async def fetch_occupation_ratings( rating_source.source_row_count, scale_source.source_artifact_url as scale_artifact_url, scale_source.source_artifact_sha256 as scale_artifact_sha256, - scale_source.source_row_count as scale_source_row_count + scale_source.source_row_count as scale_source_row_count, + occupation.occupation_title from occupational_source_table rating_source left join occupational_source_table scale_source on scale_source.data_release_code = rating_source.data_release_code and scale_source.source_table_code = 'scales_reference' + left join occupational_classification_entry occupation + on occupation.data_release_code = rating_source.data_release_code + and occupation.onetsoc_code = $3 where rating_source.data_release_code = $1 and rating_source.source_table_code = $2""", data_release_code, source_table_code, + onetsoc_code, ) if source is None: return { "data_release_code": data_release_code, "source_table_code": source_table_code, "onetsoc_code": onetsoc_code, + "occupation_title": None, "source_available": False, "source": None, "items": [], @@ -112,6 +128,7 @@ async def fetch_occupation_ratings( "data_release_code": data_release_code, "source_table_code": source_table_code, "onetsoc_code": onetsoc_code, + "occupation_title": _optional_mapping_value(source, "occupation_title"), "source_available": True, "source": { "source_table_name": source["source_table_name"], @@ -151,3 +168,57 @@ async def fetch_occupation_rating_sources( source.source_table_name, source.source_table_code""" ) return {"sources": [dict(row) for row in rows]} + + +async def fetch_occupation_rating_occupations( + conn: RatingReadConnection, + *, + data_release_code: str, + source_table_code: str, +) -> dict[str, object]: + """Return occupations that have observations in one imported rating source.""" + source = await conn.fetchrow( + """select source_table_code + from occupational_source_table + where data_release_code = $1 + and source_table_code = $2 + and source_table_code <> 'scales_reference'""", + data_release_code, + source_table_code, + ) + if source is None: + return { + "data_release_code": data_release_code, + "source_table_code": source_table_code, + "source_available": False, + "occupations": [], + } + rows = await conn.fetch( + """select occupation.onetsoc_code, occupation.occupation_title + from occupational_classification_entry occupation + where occupation.data_release_code = $1 + and exists ( + select 1 + from occupational_rating_observation observation + where observation.data_release_code = occupation.data_release_code + and observation.source_table_code = $2 + and observation.onetsoc_code = occupation.onetsoc_code + ) + order by occupation.occupation_title, occupation.onetsoc_code + limit $3""", + data_release_code, + source_table_code, + OCCUPATION_CATALOG_BOUND, + ) + return { + "data_release_code": data_release_code, + "source_table_code": source_table_code, + "source_available": True, + "occupations": [ + { + "onetsoc_code": row["onetsoc_code"], + "occupation_title": row["occupation_title"], + } + for row in rows + ], + } diff --git a/docs/adr/0261-occupation-rating-occupation-catalog.md b/docs/adr/0261-occupation-rating-occupation-catalog.md new file mode 100644 index 000000000..2b67c4d4b --- /dev/null +++ b/docs/adr/0261-occupation-rating-occupation-catalog.md @@ -0,0 +1,46 @@ +# ADR 0261: Imported occupation catalog for rating evidence + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0257, ADR 0258, ADR 0259, ADR 0260 + +## Context + +ADR 0260 removed typed release and source-table codes, but occupation +selection still requires an exact O*NET-SOC code. That makes a valid product +action depend on repository knowledge and allows a user to request an +occupation that has no observation in the selected source. The normalized +store already owns `occupational_classification_entry` titles and the +observation identities that prove a source actually describes an occupation. + +## Decision + +1. Add an authenticated read endpoint that lists occupations with at least + one persisted observation in one exact imported rating source. Return the + official O*NET-SOC code and occupation title. Exclude occupations that + exist only as classification rows, and treat the Scales Reference support + artifact as unavailable. +2. Order by published title then code. Bound the catalog; do not rank, + recommend, or infer similarity. The catalog describes current database + state, not the complete official O*NET occupation list. +3. Distinguish an unavailable source from an available source with no + selectable occupation. Authentication matches ADR 0258. +4. The Dashboard occupation control is a native select populated only from + this catalog for the currently selected source. Changing the source + reloads and resets the occupation. If the occupation catalog is loading, + empty, or unavailable, disable profile submission and give the next + action. Do not retain a typed SOC fallback. +5. Display the published occupation title with the retained code on the + opened profile. Derive no ranking or recommendation. + +## Consequences + +Users select a published occupation that the chosen source actually +describes. An unavailable or empty occupation catalog cannot masquerade as a +typed code. Adding an official occupation remains an importer operation with +digest and row-count validation rather than a UI-created catalog row. + +## References + +National Center for O*NET Development. (2026). *O*NET 31.0 database* [Data +set]. https://www.onetcenter.org/database.html diff --git a/docs/adr/README.md b/docs/adr/README.md index 7b6dd1aa5..c31390658 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,6 +37,7 @@ decision from them. | Occupation-rating authenticated read projection | [0258](0258-occupation-rating-read-api.md) | | Occupation-rating Dashboard evidence view | [0259](0259-occupation-rating-evidence-ui.md) | | Imported occupation-rating source catalog | [0260](0260-occupation-rating-source-catalog.md) | +| Imported occupation catalog for rating evidence | [0261](0261-occupation-rating-occupation-catalog.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 49907c9ce..61d8b4f02 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -163,8 +163,9 @@ suppressed observation retains its value and warning flag together. ### PRD-FR-2F — Occupation-rating evidence view -- Let an authenticated user submit an exact O*NET-SOC code, release, and source - from the existing Dashboard without changing the governed GNB (ADR 0259). +- Let an authenticated user submit an imported source and a published + occupation from the existing Dashboard without changing the governed GNB + (ADR 0259, ADR 0260, ADR 0261). - Display published values beside bounds, sample/error/interval evidence, source time, and text warnings; link both source artifacts. - Give different next actions for unavailable source, empty occupation, @@ -188,6 +189,23 @@ order follows persisted import time rather than parsed version heuristics; and the real PostgreSQL integration test proves an imported synthetic artifact is listed while its supporting scale artifact is not. +### PRD-FR-2H — Imported occupation catalog + +- Populate the occupation selector only from occupations that have + observations in the currently selected imported source, showing the + published title with the retained O*NET-SOC code (ADR 0261). +- Reset the occupation when the source changes. +- Disable profile submission and state the next action while the occupation + catalog is loading, empty, or unavailable; keep unavailable sources + distinct from an empty occupation list. +- Display the published title on the opened profile without deriving a + ranking or recommendation. + +Acceptance: a user never types an O*NET-SOC code; occupation order follows +published title then code; and the real PostgreSQL integration test proves an +imported synthetic occupation is listed for its rating source while a +classification-only occupation is not. + ### PRD-FR-3 — Bounded ontology exploration - Apply RBAC/ABAC, source eligibility, and knowledge cutoff before graph diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 57787ac73..aaa127840 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -29,7 +29,7 @@ explicit unavailable state, not a reason to infer mappings from labels. | Occupation-to-construct relations | ADR 0257 defines a candidate 3NF, release/source-partitioned immutable observation store and deterministic pinned-CSV importer preserving value, optional category, sample/error/CI, suppression, relevance, exact `MM/YYYY` source update month, source digest, and domain provenance. The official O*NET 31.0 Abilities file (94,640 rows, 910 occupations, 52 elements; SHA-256 `7e9cd79791ce6014e1d26d0a449ae5b1e7aa7ef52d39b3934c3bb8d438104b88`) and all 33 Scales Reference rows (SHA-256 `bcba23858ce21ecaacbde303a8993e35d46724b4afb8c9ec2b10e04f42adcfc9`) imported into a throwaway local PostgreSQL database with all 94,640 observations, 55 suppression flags, 7,572 not-relevant flags, and source months from `12/2004` through `08/2026`; every scale retained `scales_reference` artifact provenance, the database was dropped afterward, and no corpus is committed or claimed deployed | Pass exact-head review/checks and protected merge; validate and import every selected official rating artifact through an authorized runtime, returning only aggregate evidence; never invent or locally normalize a weight | | Job-family and job-series semantics | No authoritative employer-specific job architecture is present | Define an organization-neutral import contract that preserves the authorized source hierarchy and distinguishes standard occupation codes from employer job families/series; no label-based binding | | Temporal and multilevel interpretation | Static vocabulary only; no person-level inference is asserted | Version valid and transaction time, preserve occupation/organization/unit nesting and multiple membership, and require TEPP or the owning Rust psychometric service before any calibrated temporal or multilevel result | -| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal code entry with a candidate authenticated catalog of artifacts that actually contain observations. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated catalog, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | +| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal source-code entry with a candidate authenticated catalog of artifacts that actually contain observations; ADR 0261 replaces typed O*NET-SOC entry with a candidate catalog of occupations that have observations in the selected source, showing published titles. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated source/occupation catalogs, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | ### Current exact-head PR queue diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 2bd6f3a35..961a90679 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,7 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | -| `Ontology/OccupationRatingProfile` | Select an imported release/source, enter an exact O*NET-SOC code, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | +| `Ontology/OccupationRatingProfile` | Select an imported release/source, then a published occupation that has observations in that source, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationCatalogEmpty`, `OccupationCatalogUnavailable`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 692bdf10e..7ddf1f081 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1448,7 +1448,7 @@ .occupation-rating-form { display: grid; - grid-template-columns: minmax(14rem, 1fr) minmax(16rem, 1fr) auto; + grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.4fr) auto; align-items: end; gap: var(--space-control-gap); margin: 1rem 0; diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index bd1a0efc5..a43e8893b 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BackendError, fetchMe, + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, fetchOperationsDashboard, @@ -58,6 +59,24 @@ describe("backendFetch provider-error boundary", () => { expect(fetchMock.mock.calls[0][0]).toContain("/api/occupation-rating-sources"); }); + it("reads occupations that have observations in one imported source", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ source_available: true, occupations: [] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchOccupationRatingOccupations("access-token", { + dataReleaseCode: "onet-31.0", + sourceTableCode: "abilities", + }); + + expect(fetchMock.mock.calls[0][0]).toContain( + "/api/occupation-rating-sources/onet-31.0/abilities/occupations", + ); + }); + it("does not expose provider details from server failures", async () => { vi.stubGlobal( "fetch", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3c60a59bb..205cb72e1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -983,6 +983,7 @@ export interface OccupationRatingProfile { data_release_code: string; source_table_code: string; onetsoc_code: string; + occupation_title: string | null; source_available: boolean; source: { source_table_name: string; @@ -1009,12 +1010,32 @@ export interface OccupationRatingSource { source_row_count: number; } +export interface OccupationRatingOccupation { + onetsoc_code: string; + occupation_title: string; +} + export function fetchOccupationRatingSources( accessToken: string, ): Promise<{ sources: OccupationRatingSource[] }> { return backendFetch("/api/occupation-rating-sources", accessToken); } +export function fetchOccupationRatingOccupations( + accessToken: string, + query: { dataReleaseCode: string; sourceTableCode: string }, +): Promise<{ + data_release_code: string; + source_table_code: string; + source_available: boolean; + occupations: OccupationRatingOccupation[]; +}> { + return backendFetch( + `/api/occupation-rating-sources/${encodeURIComponent(query.dataReleaseCode)}/${encodeURIComponent(query.sourceTableCode)}/occupations`, + accessToken, + ); +} + export function fetchOccupationRatings( accessToken: string, query: { diff --git a/frontend/src/components/OccupationRatingProfile.stories.tsx b/frontend/src/components/OccupationRatingProfile.stories.tsx index e78e7a39e..50d56b91a 100644 --- a/frontend/src/components/OccupationRatingProfile.stories.tsx +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -4,7 +4,8 @@ import { OccupationRatingProfile, OccupationRatingProfileView } from "./Occupati import "../App.css"; const ready = { - data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", source_available: true, + data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", + occupation_title: "Synthetic occupation", source_available: true, source: { source_table_name: "Abilities", source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), source_row_count: 94640, scale_artifact_url: "https://example.test/scales.csv", scale_artifact_sha256: "b".repeat(64), scale_source_row_count: 33 }, items: [ { element_id: "1.A.1.a.1", element_name: "Oral Comprehension", scale_id: "IM", scale_name: "Importance", minimum_value: "1.00", maximum_value: "5.00", category_value: null, data_value: "4.10", sample_size: 120, standard_error: "0.0800", lower_ci_bound: "3.9432", upper_ci_bound: "4.2568", recommend_suppress: true, not_relevant: null, source_updated_month: "08/2026", domain_source_code: "Analyst" }, @@ -13,6 +14,46 @@ const ready = { next_offset: null, }; +const importedSource = { + data_release_code: "onet-31.0", release_version: "31.0", + source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", + source_table_code: "abilities", source_table_name: "Abilities", + source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), + source_row_count: 94640, +}; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); +} + +function mockOccupationCatalog({ + sources = [importedSource], + occupations = [{ onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }], + sourceAvailable = true, +}: { + sources?: typeof importedSource[]; + occupations?: { onetsoc_code: string; occupation_title: string }[]; + sourceAvailable?: boolean; +} = {}) { + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/occupations") && url.includes("occupation-rating-sources")) { + return jsonResponse({ + data_release_code: "onet-31.0", + source_table_code: "abilities", + source_available: sourceAvailable, + occupations, + }); + } + if (url.includes("occupation-rating-sources")) { + return jsonResponse({ sources }); + } + return jsonResponse(ready); + }; + return () => { globalThis.fetch = previousFetch; }; +} + const meta = { title: "Ontology/OccupationRatingProfile", component: OccupationRatingProfileView, parameters: { layout: "fullscreen" }, args: { profile: ready } } satisfies Meta; export default meta; type Story = StoryObj; @@ -20,6 +61,7 @@ type Story = StoryObj; export const EvidenceReady: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByText("Synthetic occupation")).toBeVisible(); await expect(canvas.getByText("4.10")).toBeVisible(); await expect(canvas.getByText(/정밀도가 낮아/)).toBeVisible(); }, @@ -27,24 +69,10 @@ export const EvidenceReady: Story = { export const InteractiveEvidenceReady: Story = { render: () => , - beforeEach: () => { - const previousFetch = globalThis.fetch; - globalThis.fetch = async (input) => new Response(JSON.stringify( - String(input).includes("occupation-rating-sources") - ? { sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 94640, - }] } - : ready, - ), { headers: { "Content-Type": "application/json" } }); - return () => { globalThis.fetch = previousFetch; }; - }, + beforeEach: () => mockOccupationCatalog(), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByLabelText("O*NET-SOC 직업 코드"), "15-1252.00"); + await canvas.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); await userEvent.click(canvas.getByRole("button", { name: "직업 근거 열기" })); await expect(canvas.findByText("4.10")).resolves.toBeVisible(); }, @@ -56,13 +84,7 @@ export const NarrowViewport: Story = { }; export const CatalogEmpty: Story = { render: () => , - beforeEach: () => { - const previousFetch = globalThis.fetch; - globalThis.fetch = async () => new Response(JSON.stringify({ sources: [] }), { - headers: { "Content-Type": "application/json" }, - }); - return () => { globalThis.fetch = previousFetch; }; - }, + beforeEach: () => mockOccupationCatalog({ sources: [] }), play: async ({ canvasElement }) => { await expect(within(canvasElement).findByText(/가져온 직업 근거 표가 없습니다/)).resolves.toBeVisible(); }, @@ -78,5 +100,32 @@ export const CatalogUnavailable: Story = { await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("잠시 후 다시 열어 보세요"); }, }; +export const OccupationCatalogEmpty: Story = { + render: () => , + beforeEach: () => mockOccupationCatalog({ occupations: [] }), + play: async ({ canvasElement }) => { + await expect(within(canvasElement).findByText(/이 근거 표에서 확인할 수 있는 직업이 없습니다/)).resolves.toBeVisible(); + }, +}; +export const OccupationCatalogUnavailable: Story = { + render: () => , + beforeEach: () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/occupations") && url.includes("occupation-rating-sources")) { + throw new Error("synthetic occupation catalog failure"); + } + if (url.includes("occupation-rating-sources")) { + return jsonResponse({ sources: [importedSource] }); + } + return jsonResponse(ready); + }; + return () => { globalThis.fetch = previousFetch; }; + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("사용 가능한 직업을 확인하지 못했습니다"); + }, +}; export const SourceUnavailable: Story = { args: { profile: { ...ready, source_available: false, source: null, items: [] } } }; export const EmptyOccupation: Story = { args: { profile: { ...ready, items: [] } } }; diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index c2c9284aa..166900524 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, type OccupationRatingProfile as Payload, @@ -10,6 +11,7 @@ import { OccupationRatingProfile, OccupationRatingProfileView } from "./Occupati vi.mock("../api", async (importOriginal) => ({ ...(await importOriginal()), + fetchOccupationRatingOccupations: vi.fn(), fetchOccupationRatingSources: vi.fn(), fetchOccupationRatings: vi.fn(), })); @@ -18,6 +20,7 @@ const ready: Payload = { data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", + occupation_title: "Synthetic occupation", source_available: true, source: { source_table_name: "Abilities", @@ -38,26 +41,47 @@ const ready: Payload = { next_offset: null, }; +const importedSource = { + data_release_code: "onet-31.0", release_version: "31.0", + source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", + source_table_code: "abilities", source_table_name: "Abilities", + source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), + source_row_count: 2, +}; + +const observedOccupations = { + data_release_code: "onet-31.0", + source_table_code: "abilities", + source_available: true, + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Synthetic chief occupation" }, + { onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }, + ], +}; + describe("OccupationRatingProfile", () => { - it("submits exact identifiers and renders warnings beside the retained value", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], - }); + beforeEach(() => { + vi.mocked(fetchOccupationRatingOccupations).mockReset(); + vi.mocked(fetchOccupationRatingSources).mockReset(); + vi.mocked(fetchOccupationRatings).mockReset(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + it("submits catalog identifiers and renders warnings beside the retained value", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings).mockResolvedValue(ready); render(); expect(await screen.findByRole("option", { name: "31.0 · Abilities" })).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText("O*NET-SOC 직업 코드"), "15-1252.00"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(screen.getByLabelText("직업"), "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); expect(fetchOccupationRatings).toHaveBeenCalledWith("synthetic-token", { onetsocCode: "15-1252.00", dataReleaseCode: "onet-31.0", sourceTableCode: "abilities", offset: 0, }); - expect(await screen.findByText("4.10")).toBeInTheDocument(); + expect(await screen.findByText("Synthetic occupation")).toBeInTheDocument(); + expect(screen.getByText("4.10")).toBeInTheDocument(); expect(screen.getByText(/정밀도가 낮아/)).toBeInTheDocument(); expect(screen.getByText(/해당 없음 응답이 포함됩니다/)).toBeInTheDocument(); expect(screen.getByText(/표를 가로로 밀어/)).toBeInTheDocument(); @@ -69,29 +93,35 @@ describe("OccupationRatingProfile", () => { expect(await screen.findByText(/가져온 직업 근거 표가 없습니다/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + expect(fetchOccupationRatingOccupations).not.toHaveBeenCalled(); }); - it("keeps pagination bound to the loaded profile after form edits", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], + it("fails closed when the selected source has no observed occupation", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue({ + ...observedOccupations, + occupations: [], }); + render(); + + expect(await screen.findByText(/이 근거 표에서 확인할 수 있는 직업이 없습니다/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + }); + + it("keeps pagination bound to the loaded profile after form edits", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings) .mockResolvedValueOnce({ ...ready, next_offset: 100 }) .mockResolvedValueOnce({ ...ready, items: [{ ...ready.items[0], scale_id: "LV" }] }); render(); - const occupation = screen.getByLabelText("O*NET-SOC 직업 코드"); - await userEvent.type(occupation, "15-1252.00"); + const occupation = await screen.findByLabelText("직업"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); await screen.findByText("4.10"); - await userEvent.clear(occupation); - await userEvent.type(occupation, "11-1011.00"); + await userEvent.selectOptions(occupation, "11-1011.00"); await userEvent.click(screen.getByRole("button", { name: "다음 관측값 불러오기" })); expect(fetchOccupationRatings).toHaveBeenLastCalledWith("synthetic-token", { @@ -101,26 +131,19 @@ describe("OccupationRatingProfile", () => { }); it("removes stale evidence while a fresh occupation loads", async () => { - vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ - sources: [{ - data_release_code: "onet-31.0", release_version: "31.0", - source_publisher_name: "Synthetic publisher", source_license_url: "https://example.test/license", - source_table_code: "abilities", source_table_name: "Abilities", - source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), - source_row_count: 2, - }], - }); + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); vi.mocked(fetchOccupationRatings) .mockResolvedValueOnce(ready) .mockImplementationOnce(() => new Promise(() => undefined)); render(); - const occupation = screen.getByLabelText("O*NET-SOC 직업 코드"); - await userEvent.type(occupation, "15-1252.00"); + const occupation = await screen.findByLabelText("직업"); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); await screen.findByText("4.10"); - await userEvent.clear(occupation); - await userEvent.type(occupation, "11-1011.00"); + await userEvent.selectOptions(occupation, "11-1011.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); expect(screen.queryByText("4.10")).not.toBeInTheDocument(); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index 299d355e1..274a78baa 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -1,13 +1,19 @@ import { useEffect, useState } from "react"; import { + fetchOccupationRatingOccupations, fetchOccupationRatingSources, fetchOccupationRatings, + type OccupationRatingOccupation, type OccupationRatingProfile as OccupationRatingProfilePayload, type OccupationRatingSource, } from "../api"; type Props = { accessToken: string }; +function sourceKey(source: Pick): string { + return `${source.data_release_code}|${source.source_table_code}`; +} + function safeHttpUrl(value: string | null | undefined): string | null { if (!value) return null; try { @@ -20,10 +26,12 @@ function safeHttpUrl(value: string | null | undefined): string | null { /** Lets an authenticated user inspect one exact imported occupation profile. */ export function OccupationRatingProfile({ accessToken }: Props) { - const [onetsocCode, setOnetsocCode] = useState(""); const [sources, setSources] = useState(null); const [selectedSource, setSelectedSource] = useState(""); const [sourceCatalogError, setSourceCatalogError] = useState(false); + const [occupations, setOccupations] = useState(null); + const [selectedOccupation, setSelectedOccupation] = useState(""); + const [occupationCatalogError, setOccupationCatalogError] = useState(false); const [profile, setProfile] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); @@ -33,21 +41,43 @@ export function OccupationRatingProfile({ accessToken }: Props) { .then(({ sources: loaded }) => { if (!active) return; setSources(loaded); - setSelectedSource( - loaded[0] ? `${loaded[0].data_release_code}|${loaded[0].source_table_code}` : "", - ); + setSelectedSource(loaded[0] ? sourceKey(loaded[0]) : ""); }) .catch(() => active && setSourceCatalogError(true)); return () => { active = false; }; }, [accessToken]); + useEffect(() => { + const source = sources?.find((item) => sourceKey(item) === selectedSource); + if (!source) { + setOccupations(null); + setSelectedOccupation(""); + setOccupationCatalogError(false); + return; + } + let active = true; + setOccupations(null); + setSelectedOccupation(""); + setOccupationCatalogError(false); + fetchOccupationRatingOccupations(accessToken, { + dataReleaseCode: source.data_release_code, + sourceTableCode: source.source_table_code, + }) + .then((payload) => { + if (!active) return; + const loaded = payload.source_available ? payload.occupations : []; + setOccupations(loaded); + setSelectedOccupation(loaded[0]?.onetsoc_code ?? ""); + }) + .catch(() => active && setOccupationCatalogError(true)); + return () => { active = false; }; + }, [accessToken, sources, selectedSource]); + function load(offset: number | null = null) { - const source = sources?.find( - (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, - ); - const request = offset == null && source + const source = sources?.find((item) => sourceKey(item) === selectedSource); + const request = offset == null && source && selectedOccupation ? { - onetsocCode, + onetsocCode: selectedOccupation, dataReleaseCode: source.data_release_code, sourceTableCode: source.source_table_code, } @@ -76,12 +106,15 @@ export function OccupationRatingProfile({ accessToken }: Props) { .catch(() => setStatus("error")); } + const occupationCatalogReady = occupations !== null && !occupationCatalogError; + const canSubmit = Boolean(selectedSource) && Boolean(selectedOccupation) && occupationCatalogReady && occupations.length > 0; + return (

공개 직업 근거

직업별 업무 특성 확인

-

직업 코드와 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

+

직업과 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

- -
{sources === null && !sourceCatalogError ?

사용 가능한 근거 표를 확인하는 중입니다.

: null} {sources?.length === 0 ?

가져온 직업 근거 표가 없습니다. 데이터 담당자에게 근거 가져오기를 요청하세요.

: null} {sourceCatalogError ?

사용 가능한 근거 표를 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

: null} + {selectedSource && occupations === null && !occupationCatalogError ? ( +

사용 가능한 직업을 확인하는 중입니다.

+ ) : null} + {occupations?.length === 0 && !occupationCatalogError ? ( +

이 근거 표에서 확인할 수 있는 직업이 없습니다. 다른 근거 표를 선택하거나 데이터 담당자에게 가져오기를 요청하세요.

+ ) : null} + {occupationCatalogError ?

사용 가능한 직업을 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

: null} {status === "error" ? ( -

직업 근거를 불러오지 못했습니다. 코드와 접근 권한을 확인한 뒤 다시 시도하세요.

+

직업 근거를 불러오지 못했습니다. 선택한 직업과 접근 권한을 확인한 뒤 다시 시도하세요.

) : null} {profile ? : null} {profile?.next_offset != null ? ( @@ -154,7 +203,7 @@ export function OccupationRatingProfileView({ if (profile.items.length === 0) { return (

- 이 근거 표에는 선택한 직업의 관측값이 없습니다. 직업 코드나 근거 표를 바꿔 확인하세요. + 이 근거 표에는 선택한 직업의 관측값이 없습니다. 직업이나 근거 표를 바꿔 확인하세요.

); } @@ -163,8 +212,9 @@ export function OccupationRatingProfileView({ return ( <>
- {profile.source?.source_table_name} + {profile.occupation_title ?? profile.onetsoc_code} {profile.data_release_code} · {profile.onetsoc_code} + {profile.source?.source_table_name} {sourceArtifactUrl ? ( 평정 원문 열기 ) : ( diff --git a/tests/test_occupation_rating_ingestion.py b/tests/test_occupation_rating_ingestion.py index c396478f9..18248af51 100644 --- a/tests/test_occupation_rating_ingestion.py +++ b/tests/test_occupation_rating_ingestion.py @@ -3,8 +3,14 @@ import asyncio from decimal import Decimal -from backend.app.main import read_occupation_rating_sources, read_occupation_ratings +from backend.app.main import ( + read_occupation_rating_occupations, + read_occupation_rating_sources, + read_occupation_ratings, +) from backend.app.occupation_rating_ingestion import ( + OCCUPATION_CATALOG_BOUND, + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -18,15 +24,17 @@ def __init__(self, source, rows=()): self.rows = list(rows) self.fetch_called = False self.last_fetch_query = "" + self.last_fetch_args: tuple[object, ...] = () async def fetchrow(self, _query: str, *_args: object): """Return configured source metadata.""" return self.source - async def fetch(self, query: str, *_args: object): + async def fetch(self, query: str, *args: object): """Return configured observation rows.""" self.fetch_called = True self.last_fetch_query = query + self.last_fetch_args = args return self.rows @@ -70,6 +78,7 @@ def test_unimported_source_is_not_an_empty_observed_profile() -> None: ) assert result["source_available"] is False + assert result["occupation_title"] is None assert result["items"] == [] assert conn.fetch_called is False @@ -83,6 +92,7 @@ def test_rating_projection_preserves_exact_decimal_and_warning_flags() -> None: "scale_artifact_url": "https://example.test/scales.csv", "scale_artifact_sha256": "b" * 64, "scale_source_row_count": 33, + "occupation_title": "Synthetic occupation", } row = { "element_id": "1.A.1.a.1", @@ -120,6 +130,7 @@ def test_rating_projection_preserves_exact_decimal_and_warning_flags() -> None: assert item["standard_error"] == "0.1830" assert item["recommend_suppress"] is True assert item["not_relevant"] is None + assert result["occupation_title"] == "Synthetic occupation" assert result["source"]["scale_artifact_sha256"] == "b" * 64 assert result["next_offset"] == 1 @@ -133,6 +144,7 @@ def test_empty_profile_keeps_imported_scale_provenance() -> None: "scale_artifact_url": "https://example.test/scales.csv", "scale_artifact_sha256": "b" * 64, "scale_source_row_count": 33, + "occupation_title": None, } result = asyncio.run( @@ -147,6 +159,7 @@ def test_empty_profile_keeps_imported_scale_provenance() -> None: ) assert result["source_available"] is True + assert result["occupation_title"] is None assert result["items"] == [] assert result["source"]["scale_artifact_sha256"] == "b" * 64 @@ -197,3 +210,67 @@ def test_authenticated_source_catalog_route_uses_shared_projection() -> None: ) assert result == {"sources": []} + + +def test_occupation_catalog_lists_observed_titles_in_title_order() -> None: + occupations = ( + { + "onetsoc_code": "11-1011.00", + "occupation_title": "Synthetic chief occupation", + }, + { + "onetsoc_code": "15-1252.00", + "occupation_title": "Synthetic occupation", + }, + ) + conn = FakeConnection({"source_table_code": "abilities"}, occupations) + + result = asyncio.run( + fetch_occupation_rating_occupations( + conn, + data_release_code="onet-31.0", + source_table_code="abilities", + ) + ) + + assert result == { + "data_release_code": "onet-31.0", + "source_table_code": "abilities", + "source_available": True, + "occupations": list(occupations), + } + assert "order by occupation.occupation_title, occupation.onetsoc_code" in ( + conn.last_fetch_query + ) + assert "and exists" in conn.last_fetch_query + assert conn.last_fetch_args[-1] == OCCUPATION_CATALOG_BOUND + + +def test_occupation_catalog_unavailable_source_is_not_an_empty_list() -> None: + conn = FakeConnection(None) + + result = asyncio.run( + fetch_occupation_rating_occupations( + conn, + data_release_code="onet-31.0", + source_table_code="scales_reference", + ) + ) + + assert result["source_available"] is False + assert result["occupations"] == [] + assert conn.fetch_called is False + + +def test_authenticated_occupation_catalog_route_uses_shared_projection() -> None: + result = asyncio.run( + read_occupation_rating_occupations( + data_release_code="onet-31.0", + source_table_code="abilities", + _account=object(), + pool=FakePool(FakeConnection(None)), + ) + ) + + assert result["source_available"] is False + assert result["occupations"] == [] diff --git a/tests/test_schema.py b/tests/test_schema.py index 6d1b8b05c..2d766438d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -29,6 +29,7 @@ import pytest from backend.app.occupation_rating_ingestion import ( + fetch_occupation_rating_occupations, fetch_occupation_rating_sources, fetch_occupation_ratings, ) @@ -531,7 +532,7 @@ def test_onet_rating_importer_is_idempotent_against_postgresql( ) assert cur.fetchone() == (1, Decimal("4.10"), True) - async def read_imported_profile() -> tuple[dict[str, object], dict[str, object]]: + async def read_imported_profile() -> tuple[dict[str, object], dict[str, object], dict[str, object]]: conn = await asyncpg.connect(args.target_dsn) try: profile = await fetch_occupation_ratings( @@ -543,15 +544,56 @@ async def read_imported_profile() -> tuple[dict[str, object], dict[str, object]] offset=0, ) catalog = await fetch_occupation_rating_sources(conn) - return profile, catalog + occupations = await fetch_occupation_rating_occupations( + conn, + data_release_code=args.release_code, + source_table_code=args.source_table_code, + ) + return profile, catalog, occupations finally: await conn.close() - profile, catalog = asyncio.run(read_imported_profile()) + profile, catalog, occupations = asyncio.run(read_imported_profile()) assert profile["source_available"] is True + assert profile["occupation_title"] == "Synthetic occupation" assert profile["items"][0]["data_value"] == "4.10" assert profile["source"]["scale_artifact_sha256"] == args.scales_sha256 assert catalog["sources"][0]["source_table_code"] == "abilities" + assert occupations["source_available"] is True + assert occupations["occupations"] == [ + { + "onetsoc_code": "15-1252.00", + "occupation_title": "Synthetic occupation", + } + ] + + with schema_db.cursor() as cur: + cur.execute( + """ + insert into occupational_classification_entry + (data_release_code, onetsoc_code, occupation_title) + values (%s, '11-1011.00', 'Synthetic classification-only occupation') + """, + (args.release_code,), + ) + + occupations_after = asyncio.run(read_imported_profile())[2] + assert occupations_after["occupations"] == occupations["occupations"] + + async def read_unavailable_occupation_catalog() -> dict[str, object]: + conn = await asyncpg.connect(args.target_dsn) + try: + return await fetch_occupation_rating_occupations( + conn, + data_release_code=args.release_code, + source_table_code="scales_reference", + ) + finally: + await conn.close() + + unavailable = asyncio.run(read_unavailable_occupation_catalog()) + assert unavailable["source_available"] is False + assert unavailable["occupations"] == [] def test_global_ask_evidence_search_indexes_exist_on_normalized_tables(schema_db) -> None: From c8a702ec07d7dbaa9d4e674c5dc7604e42679623 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:26:04 +0000 Subject: [PATCH 2/5] feat(ui): filter occupation catalog by published title Let users find an imported occupation by title or retained code without ranking or typed SOC fallback, and fail closed when the filter matches nothing (ADR 0262). --- ARCHITECTURE.md | 2 +- .../2.21.0-occupation-catalog-filter.md | 5 ++ CHANGELOG.md | 3 + .../0262-occupation-catalog-title-filter.md | 41 ++++++++++ docs/adr/README.md | 1 + docs/product-requirements.md | 12 +++ docs/product-technical-gap-baseline.md | 2 +- docs/storybook-inventory.md | 2 +- frontend/src/App.css | 1 + .../OccupationRatingProfile.stories.tsx | 15 ++++ .../OccupationRatingProfile.test.tsx | 30 ++++++++ .../components/OccupationRatingProfile.tsx | 77 +++++++++++++++---- 12 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 CHANGELOG.d/2.21.0-occupation-catalog-filter.md create mode 100644 docs/adr/0262-occupation-catalog-title-filter.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d1ed6ca8c..069053747 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ flowchart LR | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | | `ontology.py` | Loads the governed Turtle source tree (`lineageweave-kg.ttl` plus generated fragments), the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types, source taxonomies, and published O*NET linkages (ADR 0004, ADR 0252, ADR 0255, ADR 0256) | | `backend/app/occupation_rating_ingestion.py` | Projects authenticated, bounded occupation-rating evidence, the persisted selectable-source catalog, and occupations that have observations in a selected source (ADR 0258, ADR 0260, ADR 0261) | -| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and published occupation title, then reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260, ADR 0261) | +| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source and published occupation title, filters that catalog without ranking, then reads exact occupation evidence in the existing Dashboard while preserving absence, uncertainty, and warning semantics (ADR 0259, ADR 0260, ADR 0261, ADR 0262) | | `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `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) | diff --git a/CHANGELOG.d/2.21.0-occupation-catalog-filter.md b/CHANGELOG.d/2.21.0-occupation-catalog-filter.md new file mode 100644 index 000000000..bf62b2c40 --- /dev/null +++ b/CHANGELOG.d/2.21.0-occupation-catalog-filter.md @@ -0,0 +1,5 @@ +### Added + +- Occupation evidence now filters the imported occupation catalog by published + title or retained code without ranking or typed SOC fallback, and fails closed + when the filter matches nothing (ADR 0262). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac40d85f..534d13685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project are documented here. Format follows ### Added +- Occupation evidence now filters the imported occupation catalog by published + title or retained code without ranking or typed SOC fallback, and fails closed + when the filter matches nothing (ADR 0262). - Occupation evidence now selects a published occupation title from occupations that actually have observations in the chosen imported source, with fail-closed empty/unavailable catalog states and the retained title on the opened profile diff --git a/docs/adr/0262-occupation-catalog-title-filter.md b/docs/adr/0262-occupation-catalog-title-filter.md new file mode 100644 index 000000000..57b2d8f65 --- /dev/null +++ b/docs/adr/0262-occupation-catalog-title-filter.md @@ -0,0 +1,41 @@ +# ADR 0262: Occupation catalog title filter + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0259, ADR 0260, ADR 0261 + +## Context + +ADR 0261 replaced typed O*NET-SOC entry with a native select of occupations +that have observations in the chosen source. An official rating artifact can +cover hundreds of occupations, so a user still cannot find a published title +without scanning the full catalog. A free-typed code would reintroduce the +gap ADR 0261 closed. + +## Decision + +1. Keep the occupation control as a native select populated only from the + imported occupation catalog for the selected source. +2. Add a native search field that filters that catalog by case-insensitive + substring of the published title or retained O*NET-SOC code. Do not rank, + boost, or infer similarity. +3. If the filter matches no catalog row, disable profile submission and give + a next action. If the current selection leaves the filtered set, move to + the first remaining catalog identity or clear the selection. +4. Reset the filter when the source or occupation catalog reloads. Never + submit a value that is not in the loaded catalog. +5. Authentication, provenance, and fail-closed unavailable/empty catalog + states remain ADR 0261. + +## Consequences + +A user can find a published occupation by title without typing an internal +code and without treating filter order as a recommendation. + +## References + +National Center for O*NET Development. (2026). *O*NET 31.0 database* [Data +set]. https://www.onetcenter.org/database.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/README.md b/docs/adr/README.md index c31390658..6ba4a41bc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,6 +38,7 @@ decision from them. | Occupation-rating Dashboard evidence view | [0259](0259-occupation-rating-evidence-ui.md) | | Imported occupation-rating source catalog | [0260](0260-occupation-rating-source-catalog.md) | | Imported occupation catalog for rating evidence | [0261](0261-occupation-rating-occupation-catalog.md) | +| Occupation catalog title filter | [0262](0262-occupation-catalog-title-filter.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 61d8b4f02..5346ba9a8 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -206,6 +206,18 @@ published title then code; and the real PostgreSQL integration test proves an imported synthetic occupation is listed for its rating source while a classification-only occupation is not. +### PRD-FR-2I — Occupation catalog title filter + +- Let an authenticated user filter the imported occupation catalog by + published title or retained code without ranking or typed SOC fallback + (ADR 0262). +- Reset the filter when the source changes. +- Disable profile submission and state the next action when the filter + matches no catalog occupation. + +Acceptance: submitting still sends only a catalog identity; a non-matching +filter never creates a request; and Storybook covers a no-match state. + ### PRD-FR-3 — Bounded ontology exploration - Apply RBAC/ABAC, source eligibility, and knowledge cutoff before graph diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index aaa127840..59bd79c3b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -29,7 +29,7 @@ explicit unavailable state, not a reason to infer mappings from labels. | Occupation-to-construct relations | ADR 0257 defines a candidate 3NF, release/source-partitioned immutable observation store and deterministic pinned-CSV importer preserving value, optional category, sample/error/CI, suppression, relevance, exact `MM/YYYY` source update month, source digest, and domain provenance. The official O*NET 31.0 Abilities file (94,640 rows, 910 occupations, 52 elements; SHA-256 `7e9cd79791ce6014e1d26d0a449ae5b1e7aa7ef52d39b3934c3bb8d438104b88`) and all 33 Scales Reference rows (SHA-256 `bcba23858ce21ecaacbde303a8993e35d46724b4afb8c9ec2b10e04f42adcfc9`) imported into a throwaway local PostgreSQL database with all 94,640 observations, 55 suppression flags, 7,572 not-relevant flags, and source months from `12/2004` through `08/2026`; every scale retained `scales_reference` artifact provenance, the database was dropped afterward, and no corpus is committed or claimed deployed | Pass exact-head review/checks and protected merge; validate and import every selected official rating artifact through an authorized runtime, returning only aggregate evidence; never invent or locally normalize a weight | | Job-family and job-series semantics | No authoritative employer-specific job architecture is present | Define an organization-neutral import contract that preserves the authorized source hierarchy and distinguishes standard occupation codes from employer job families/series; no label-based binding | | Temporal and multilevel interpretation | Static vocabulary only; no person-level inference is asserted | Version valid and transaction time, preserve occupation/organization/unit nesting and multiple membership, and require TEPP or the owning Rust psychometric service before any calibrated temporal or multilevel result | -| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal source-code entry with a candidate authenticated catalog of artifacts that actually contain observations; ADR 0261 replaces typed O*NET-SOC entry with a candidate catalog of occupations that have observations in the selected source, showing published titles. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated source/occupation catalogs, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | +| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view with exact value/error/warning semantics; ADR 0260 replaces internal source-code entry with a candidate authenticated catalog of artifacts that actually contain observations; ADR 0261 replaces typed O*NET-SOC entry with a candidate catalog of occupations that have observations in the selected source, showing published titles; ADR 0262 filters that catalog by title or code without ranking. Component/API/PostgreSQL tests and Storybook build cover the current contract; synthetic populated scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated source/occupation catalogs, title filter, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | ### Current exact-head PR queue diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 961a90679..f9836f5bc 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,7 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | -| `Ontology/OccupationRatingProfile` | Select an imported release/source, then a published occupation that has observations in that source, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationCatalogEmpty`, `OccupationCatalogUnavailable`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | +| `Ontology/OccupationRatingProfile` | Select an imported release/source, filter the published occupation catalog by title or code, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationCatalogEmpty`, `OccupationCatalogUnavailable`, `OccupationFilterEmpty`, `SourceUnavailable`, and `EmptyOccupation` cover the catalog-backed form, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 7ddf1f081..3f34bce91 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1455,6 +1455,7 @@ } .occupation-rating-form label, +.occupation-rating-occupation-select, .occupation-rating-source { display: grid; gap: var(--space-control-gap); diff --git a/frontend/src/components/OccupationRatingProfile.stories.tsx b/frontend/src/components/OccupationRatingProfile.stories.tsx index 50d56b91a..72a890494 100644 --- a/frontend/src/components/OccupationRatingProfile.stories.tsx +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -127,5 +127,20 @@ export const OccupationCatalogUnavailable: Story = { await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("사용 가능한 직업을 확인하지 못했습니다"); }, }; +export const OccupationFilterEmpty: Story = { + render: () => , + beforeEach: () => mockOccupationCatalog({ + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Synthetic chief occupation" }, + { onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }, + ], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.type(canvas.getByLabelText("직업 찾기"), "unknown-occupation"); + await expect(canvas.findByText(/입력한 조건에 맞는 직업이 없습니다/)).resolves.toBeVisible(); + }, +}; export const SourceUnavailable: Story = { args: { profile: { ...ready, source_available: false, source: null, items: [] } } }; export const EmptyOccupation: Story = { args: { profile: { ...ready, items: [] } } }; diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index 166900524..634efd1fb 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -108,6 +108,36 @@ describe("OccupationRatingProfile", () => { expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); }); + it("filters catalog titles without submitting a typed occupation code", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); + vi.mocked(fetchOccupationRatings).mockResolvedValue(ready); + render(); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + + await userEvent.type(screen.getByLabelText("직업 찾기"), "15-1252"); + expect(screen.queryByRole("option", { name: "Synthetic chief occupation (11-1011.00)" })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Synthetic occupation (15-1252.00)" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); + + expect(fetchOccupationRatings).toHaveBeenCalledWith("synthetic-token", { + onetsocCode: "15-1252.00", dataReleaseCode: "onet-31.0", sourceTableCode: "abilities", offset: 0, + }); + }); + + it("fails closed when the title filter matches no catalog occupation", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); + vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); + render(); + await screen.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + + await userEvent.type(screen.getByLabelText("직업 찾기"), "unknown-occupation"); + + expect(await screen.findByText(/입력한 조건에 맞는 직업이 없습니다/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + expect(fetchOccupationRatings).not.toHaveBeenCalled(); + }); + it("keeps pagination bound to the loaded profile after form edits", async () => { vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [importedSource] }); vi.mocked(fetchOccupationRatingOccupations).mockResolvedValue(observedOccupations); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index 274a78baa..9f3e456e2 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -14,6 +14,18 @@ function sourceKey(source: Pick(null); const [selectedOccupation, setSelectedOccupation] = useState(""); + const [occupationQuery, setOccupationQuery] = useState(""); const [occupationCatalogError, setOccupationCatalogError] = useState(false); const [profile, setProfile] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); @@ -52,12 +65,14 @@ export function OccupationRatingProfile({ accessToken }: Props) { if (!source) { setOccupations(null); setSelectedOccupation(""); + setOccupationQuery(""); setOccupationCatalogError(false); return; } let active = true; setOccupations(null); setSelectedOccupation(""); + setOccupationQuery(""); setOccupationCatalogError(false); fetchOccupationRatingOccupations(accessToken, { dataReleaseCode: source.data_release_code, @@ -106,15 +121,28 @@ export function OccupationRatingProfile({ accessToken }: Props) { .catch(() => setStatus("error")); } + const visibleOccupations = (occupations ?? []).filter((item) => + matchesOccupationCatalogQuery(item, occupationQuery), + ); + + useEffect(() => { + if (occupations == null) return; + if (visibleOccupations.some((item) => item.onetsoc_code === selectedOccupation)) return; + setSelectedOccupation(visibleOccupations[0]?.onetsoc_code ?? ""); + }, [occupations, selectedOccupation, visibleOccupations]); + const occupationCatalogReady = occupations !== null && !occupationCatalogError; - const canSubmit = Boolean(selectedSource) && Boolean(selectedOccupation) && occupationCatalogReady && occupations.length > 0; + const canSubmit = Boolean(selectedSource) + && Boolean(selectedOccupation) + && occupationCatalogReady + && visibleOccupations.some((item) => item.onetsoc_code === selectedOccupation); return (

공개 직업 근거

직업별 업무 특성 확인

-

직업과 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

+

직업 이름과 근거 표를 선택해 관측값, 오차, 사용 주의사항을 함께 확인하세요.

- +
+ + +
@@ -168,6 +208,9 @@ export function OccupationRatingProfile({ accessToken }: Props) { {occupations?.length === 0 && !occupationCatalogError ? (

이 근거 표에서 확인할 수 있는 직업이 없습니다. 다른 근거 표를 선택하거나 데이터 담당자에게 가져오기를 요청하세요.

) : null} + {occupations != null && occupations.length > 0 && visibleOccupations.length === 0 ? ( +

입력한 조건에 맞는 직업이 없습니다. 검색어를 바꾸거나 다른 근거 표를 선택하세요.

+ ) : null} {occupationCatalogError ?

사용 가능한 직업을 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

: null} {status === "error" ? (

직업 근거를 불러오지 못했습니다. 선택한 직업과 접근 권한을 확인한 뒤 다시 시도하세요.

From e55189ec515459534acb04c4673fc9774b3acd88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 21:26:04 +0000 Subject: [PATCH 3/5] feat(ui): filter occupation catalog by published title Let users find an imported occupation by title or retained code without ranking or typed SOC fallback, and fail closed when the filter matches nothing (ADR 0262). --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + docs/adr/README.md | 1 + docs/product-requirements.md | 12 +++ docs/product-technical-gap-baseline.md | 2 +- docs/storybook-inventory.md | 2 +- .../OccupationRatingProfile.stories.tsx | 15 ++++ .../OccupationRatingProfile.test.tsx | 27 ++++++ .../components/OccupationRatingProfile.tsx | 84 ++++++++++++++----- 9 files changed, 122 insertions(+), 24 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dcfab0b7d..006a67523 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ flowchart LR | `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) | | `ontology.py` | Loads the governed Turtle source tree (`lineageweave-kg.ttl` plus generated fragments), the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types, source taxonomies, and published O*NET linkages (ADR 0004, ADR 0252, ADR 0255, ADR 0256) | | `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 imported source and stored occupation title before reading exact Dashboard evidence, preserving absence, uncertainty, and warning semantics (ADR 0259–0261) | +| `frontend/src/components/OccupationRatingProfile.tsx` | Selects an imported source, filters its stored occupation titles or retained codes 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 | | `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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d970686..6b66ea57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project are documented here. Format follows ### Added +- Occupation evidence can filter the imported catalog by published title or retained code without ranking or typed fallback, and provides next actions when no catalog entry matches (ADR 0262). - Each imported rating source now exposes its exact represented O*NET-SOC code/title catalog, and the Dashboard uses that catalog instead of requiring users to know or type an occupation code (ADR 0261). diff --git a/docs/adr/README.md b/docs/adr/README.md index 3ff700e68..362e97523 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,6 +38,7 @@ decision from them. | Occupation-rating Dashboard evidence view | [0259](0259-occupation-rating-evidence-ui.md) | | Imported occupation-rating source catalog | [0260](0260-occupation-rating-source-catalog.md) | | Rating-source occupation selector | [0261](0261-rating-source-occupation-selector.md) | +| Occupation catalog title filter | [0262](0262-occupation-catalog-title-filter.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 2ed4d772f..7c07f2d55 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -203,6 +203,18 @@ the PostgreSQL integration test proves the source membership predicate; and component tests prove selector changes clear prior evidence and pagination stays bound to the loaded profile identifiers. +### PRD-FR-2I — Occupation catalog title filter + +- Let an authenticated user filter the imported occupation catalog by + published title or retained code without ranking or typed SOC fallback + (ADR 0262). +- Reset the filter when the source changes. +- Disable profile submission and state the next action when the filter + matches no catalog occupation. + +Acceptance: submitting still sends only a catalog identity; a non-matching +filter never creates a request; and Storybook covers a no-match state. + ### PRD-FR-3 — Bounded ontology exploration - Apply RBAC/ABAC, source eligibility, and knowledge cutoff before graph diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 63b15f75d..826ca4d5e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -29,7 +29,7 @@ explicit unavailable state, not a reason to infer mappings from labels. | Occupation-to-construct relations | ADR 0257 defines a candidate 3NF, release/source-partitioned immutable observation store and deterministic pinned-CSV importer preserving value, optional category, sample/error/CI, suppression, relevance, exact `MM/YYYY` source update month, source digest, and domain provenance. The official O*NET 31.0 Abilities file (94,640 rows, 910 occupations, 52 elements; SHA-256 `7e9cd79791ce6014e1d26d0a449ae5b1e7aa7ef52d39b3934c3bb8d438104b88`) and all 33 Scales Reference rows (SHA-256 `bcba23858ce21ecaacbde303a8993e35d46724b4afb8c9ec2b10e04f42adcfc9`) imported into a throwaway local PostgreSQL database with all 94,640 observations, 55 suppression flags, 7,572 not-relevant flags, and source months from `12/2004` through `08/2026`; every scale retained `scales_reference` artifact provenance, the database was dropped afterward, and no corpus is committed or claimed deployed | Pass exact-head review/checks and protected merge; validate and import every selected official rating artifact through an authorized runtime, returning only aggregate evidence; never invent or locally normalize a weight | | Job-family and job-series semantics | No authoritative employer-specific job architecture is present | Define an organization-neutral import contract that preserves the authorized source hierarchy and distinguishes standard occupation codes from employer job families/series; no label-based binding | | Temporal and multilevel interpretation | Static vocabulary only; no person-level inference is asserted | Version valid and transaction time, preserve occupation/organization/unit nesting and multiple membership, and require TEPP or the owning Rust psychometric service before any calibrated temporal or multilevel result | -| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view; ADR 0260 replaces release/source code entry with persisted artifact selection; ADR 0261 replaces occupation-code entry with stored titles represented in that source. Component/API/PostgreSQL tests and Storybook scenes cover value/error/warning, absence, source/occupation selection, stale-response, pagination, and safe-link contracts; populated synthetic scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated catalogs, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | +| Product consumption | ADR 0258 defines a candidate authenticated occupation-rating API; ADR 0259 adds a candidate Dashboard evidence view; ADR 0260 replaces release/source code entry with persisted artifact selection; ADR 0261 replaces occupation-code entry with stored titles represented in that source; ADR 0262 filters that native catalog by title or retained code without ranking or typed fallback. Component/API/PostgreSQL tests and Storybook scenes cover value/error/warning, absence, source/occupation selection, filtering, stale-response, pagination, and safe-link contracts; populated synthetic scenes were visually audited at 1440×900 and 390×844. Protected delivery and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; verify the authenticated catalogs, profile API, and rendered Dashboard against an authorized imported source using only aggregate/non-identifying evidence | ### Current exact-head PR queue diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 46aa4d467..607ae4afa 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,7 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | -| `Ontology/OccupationRatingProfile` | Select an imported release/source and stored occupation title, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationsEmpty`, `SourceUnavailable`, and `EmptyOccupation` cover both selectors, populated table, horizontal mobile access, and honest catalog/profile absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | +| `Ontology/OccupationRatingProfile` | Select an imported release/source, filter stored occupation titles or codes, inspect the published value beside its sample/error and warning, then open the rating or scale artifact. `InteractiveEvidenceReady`, `EvidenceReady`, `NarrowViewport`, `CatalogEmpty`, `CatalogUnavailable`, `OccupationsEmpty`, `OccupationFilterEmpty`, `SourceUnavailable`, and `EmptyOccupation` cover both selectors, filtering, populated tables, responsive access, and honest absence states. | `OccupationRatingProfile`, native select/table, `--color-border`, `--size-control-min` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/components/OccupationRatingProfile.stories.tsx b/frontend/src/components/OccupationRatingProfile.stories.tsx index aba75d34b..f2cc4eb8b 100644 --- a/frontend/src/components/OccupationRatingProfile.stories.tsx +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -110,5 +110,20 @@ export const OccupationsEmpty: Story = { await expect(within(canvasElement).findByText(/선택할 수 있는 직업이 없습니다/)).resolves.toBeVisible(); }, }; +export const OccupationFilterEmpty: Story = { + render: () => , + beforeEach: () => mockOccupationCatalog({ + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Synthetic chief occupation" }, + { onetsoc_code: "15-1252.00", occupation_title: "Synthetic occupation" }, + ], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("option", { name: "Synthetic occupation (15-1252.00)" }); + await userEvent.type(canvas.getByLabelText("직업 찾기"), "unknown-occupation"); + await expect(canvas.findByText(/입력한 조건에 맞는 직업이 없습니다/)).resolves.toBeVisible(); + }, +}; export const SourceUnavailable: Story = { args: { profile: { ...ready, source_available: false, source: null, items: [] } } }; export const EmptyOccupation: Story = { args: { profile: { ...ready, items: [] } } }; diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index 526061f9a..5c11d0ac0 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -215,6 +215,33 @@ describe("OccupationRatingProfile", () => { expect(screen.getByText("3.20")).toBeInTheDocument(); }); + it("filters published titles and retained codes without a typed fallback", async () => { + vi.mocked(fetchOccupationRatings).mockResolvedValue(ready); + render(); + await screen.findByRole("option", { name: "Software Developers · 15-1252.00" }); + + await userEvent.type(screen.getByLabelText("직업 찾기"), "15-1252"); + + expect(screen.queryByRole("option", { name: "Chief Executives · 11-1011.00" })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Software Developers · 15-1252.00" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); + expect(fetchOccupationRatings).toHaveBeenCalledWith("synthetic-token", { + onetsocCode: "15-1252.00", dataReleaseCode: "onet-31.0", sourceTableCode: "abilities", offset: 0, + }); + }); + + it("offers next actions and submits nothing when the catalog filter has no match", async () => { + render(); + await screen.findByRole("option", { name: "Software Developers · 15-1252.00" }); + vi.mocked(fetchOccupationRatings).mockClear(); + + await userEvent.type(screen.getByLabelText("직업 찾기"), "unknown-occupation"); + + expect(await screen.findByText(/검색어를 바꾸거나 다른 근거 표를 선택하세요/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + expect(fetchOccupationRatings).not.toHaveBeenCalled(); + }); + it("distinguishes an unavailable artifact from an empty occupation profile", () => { const { rerender } = render(); expect(screen.getByRole("status")).toHaveTextContent("아직 준비되지 않았습니다"); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index 21f8260d7..464d11950 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -10,6 +10,18 @@ import { type Props = { accessToken: string }; +function matchesOccupationCatalogQuery( + occupation: RatingSourceOccupation, + query: string, +): boolean { + const needle = query.trim().toLocaleLowerCase("en-US"); + if (!needle) return true; + return ( + occupation.occupation_title.toLocaleLowerCase("en-US").includes(needle) + || occupation.onetsoc_code.toLocaleLowerCase("en-US").includes(needle) + ); +} + function safeHttpUrl(value: string | null | undefined): string | null { if (!value) return null; try { @@ -27,6 +39,7 @@ export function OccupationRatingProfile({ accessToken }: Props) { const [selectedSource, setSelectedSource] = useState(""); const [sourceCatalogError, setSourceCatalogError] = useState(false); const [occupations, setOccupations] = useState(null); + const [occupationQuery, setOccupationQuery] = useState(""); const [occupationCatalogError, setOccupationCatalogError] = useState(false); const [profile, setProfile] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); @@ -56,6 +69,7 @@ export function OccupationRatingProfile({ accessToken }: Props) { (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, ); setOnetsocCode(""); + setOccupationQuery(""); setProfile(null); setOccupationCatalogError(false); if (!source) { @@ -114,6 +128,19 @@ export function OccupationRatingProfile({ accessToken }: Props) { }); } + const visibleOccupations = (occupations ?? []).filter((occupation) => + matchesOccupationCatalogQuery(occupation, occupationQuery), + ); + + useEffect(() => { + if (occupations == null) return; + if (visibleOccupations.some((occupation) => occupation.onetsoc_code === onetsocCode)) return; + requestSequence.current += 1; + setOnetsocCode(visibleOccupations[0]?.onetsoc_code ?? ""); + setProfile(null); + setStatus("idle"); + }, [occupations, onetsocCode, visibleOccupations]); + return (
@@ -128,27 +155,39 @@ export function OccupationRatingProfile({ accessToken }: Props) { load(); }} > - +
+ + +