From 37a63cc35abff317d86b04c0dd21444f63280787 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:23:14 +0900 Subject: [PATCH 1/6] feat(ontology): select represented occupations --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 3 + backend/app/main.py | 21 +++++++ backend/app/occupation_rating_ingestion.py | 45 ++++++++++++++ .../0261-rating-source-occupation-selector.md | 44 ++++++++++++++ docs/adr/README.md | 1 + docs/product-requirements.md | 14 +++++ docs/product-technical-gap-baseline.md | 2 +- docs/storybook-inventory.md | 2 +- frontend/src/api.test.ts | 16 +++++ frontend/src/api.ts | 22 +++++++ .../OccupationRatingProfile.stories.tsx | 36 ++++++++++- .../OccupationRatingProfile.test.tsx | 58 ++++++++++++++---- .../components/OccupationRatingProfile.tsx | 52 +++++++++++++--- tests/test_occupation_rating_ingestion.py | 60 ++++++++++++++++++- tests/test_schema.py | 15 ++++- 16 files changed, 367 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0261-rating-source-occupation-selector.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 44838b1a6..dcfab0b7d 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 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) | | `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 f99d034da..a3d970686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project are documented here. Format follows ### Added +- 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). - 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..b56a98ed6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -101,6 +101,7 @@ from backend.app.occupation_rating_ingestion import ( fetch_occupation_rating_sources, fetch_occupation_ratings, + fetch_rating_source_occupations, ) from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( @@ -2315,6 +2316,26 @@ async def read_occupation_rating_sources( return await fetch_occupation_rating_sources(conn) +@app.get("/api/occupation-rating-occupations") +async def read_rating_source_occupations( + data_release_code: str = Query( + ..., min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9.-]*$" + ), + source_table_code: str = Query( + ..., 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 represented in one imported rating source.""" + async with pool.acquire() as conn: + return await fetch_rating_source_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..f7d2d10a1 100644 --- a/backend/app/occupation_rating_ingestion.py +++ b/backend/app/occupation_rating_ingestion.py @@ -151,3 +151,48 @@ 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_rating_source_occupations( + conn: RatingReadConnection, + *, + data_release_code: str, + source_table_code: str, +) -> dict[str, object]: + """Return occupations with observations in one exact imported source.""" + source = await conn.fetchrow( + """select 1 + from occupational_source_table + where data_release_code = $1 and source_table_code = $2""", + 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 classification.onetsoc_code, classification.occupation_title + from occupational_classification_entry classification + where classification.data_release_code = $1 + and exists ( + select 1 + from occupational_rating_observation observation + where observation.data_release_code = classification.data_release_code + and observation.source_table_code = $2 + and observation.onetsoc_code = classification.onetsoc_code + ) + order by classification.occupation_title, + classification.onetsoc_code""", + data_release_code, + source_table_code, + ) + return { + "data_release_code": data_release_code, + "source_table_code": source_table_code, + "source_available": True, + "occupations": [dict(row) for row in rows], + } diff --git a/docs/adr/0261-rating-source-occupation-selector.md b/docs/adr/0261-rating-source-occupation-selector.md new file mode 100644 index 000000000..c407bc3ab --- /dev/null +++ b/docs/adr/0261-rating-source-occupation-selector.md @@ -0,0 +1,44 @@ +# ADR 0261: Occupations represented by an imported rating source + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0257, ADR 0258, ADR 0260 + +## Context + +The source catalog removes internal release/source entry, but ADR 0260 still +leaves users to type an O*NET-SOC code. The normalized store already preserves +the source occupation title and code. A release may contain occupations that +are absent from one rating artifact, so the release classification alone is +not sufficient evidence that a profile exists for the selected source. + +## Decision + +1. Add an authenticated read endpoint returning stored O*NET-SOC code/title + pairs that have at least one observation in one exact imported rating + source. Keep unavailable source distinct from an available empty source. +2. Join by normalized release/code identity and an observation-existence + predicate. Do not bind occupations by title similarity, keyword inference, + external search, or a locally reconstructed classification. +3. Order by the stored occupation title and then code. Return the complete + represented set because the official imported classification is the + authoritative finite selector domain; do not introduce an arbitrary result + cutoff that makes valid occupations disappear. +4. Replace free-text occupation-code entry with a native select whose visible + label begins with the stored title and retains the exact code. Changing the + rating source clears both occupation selection and displayed evidence. +5. While the occupation catalog is loading, empty, or unavailable, disable + profile submission and state the next action. Pagination remains bound to + the identifiers returned by the loaded profile under ADR 0259. + +## Consequences + +Users choose an occupation by its authoritative title without knowing an +internal code, while API requests continue to carry exact stable identifiers. +Employer job families and series remain outside this selector until their +separate authorized import contract exists. + +## 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..3ff700e68 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) | +| Rating-source occupation selector | [0261](0261-rating-source-occupation-selector.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..e6385ef68 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -188,6 +188,20 @@ 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 — Occupations represented in a rating source + +- Populate the occupation selector with exact stored code/title pairs that + have observations in the selected imported source (ADR 0261). +- Clear the current occupation and profile when the source changes; never mix + continuation rows across occupations or sources. +- Keep unavailable source, available-empty source, loading, and transport + failure distinct and actionable. + +Acceptance: a user selects a stored title rather than typing an internal code; +the PostgreSQL integration test proves the source membership predicate; and +component tests prove source changes clear prior evidence and pagination stays +bound to the loaded profile identifiers. + ### 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..63b15f75d 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; 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 | ### Current exact-head PR queue diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 2bd6f3a35..46aa4d467 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 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` | | `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/api.test.ts b/frontend/src/api.test.ts index bd1a0efc5..9495dc241 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -5,6 +5,7 @@ import { fetchOccupationRatingSources, fetchOccupationRatings, fetchOperationsDashboard, + fetchRatingSourceOccupations, updateTenantConfig, } from "./api"; @@ -58,6 +59,21 @@ describe("backendFetch provider-error boundary", () => { expect(fetchMock.mock.calls[0][0]).toContain("/api/occupation-rating-sources"); }); + it("reads occupations for one exact imported source", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ occupations: [] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchRatingSourceOccupations("access-token", "onet-31.0", "abilities"); + + expect(fetchMock.mock.calls[0][0]).toContain( + "/api/occupation-rating-occupations?data_release_code=onet-31.0&source_table_code=abilities", + ); + }); + 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..6972a6542 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1015,6 +1015,28 @@ export function fetchOccupationRatingSources( return backendFetch("/api/occupation-rating-sources", accessToken); } +export interface RatingSourceOccupation { + onetsoc_code: string; + occupation_title: string; +} + +export function fetchRatingSourceOccupations( + accessToken: string, + dataReleaseCode: string, + sourceTableCode: string, +): Promise<{ + data_release_code: string; + source_table_code: string; + source_available: boolean; + occupations: RatingSourceOccupation[]; +}> { + const params = new URLSearchParams({ + data_release_code: dataReleaseCode, + source_table_code: sourceTableCode, + }); + return backendFetch(`/api/occupation-rating-occupations?${params.toString()}`, 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..aba75d34b 100644 --- a/frontend/src/components/OccupationRatingProfile.stories.tsx +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -38,13 +38,24 @@ export const InteractiveEvidenceReady: Story = { source_artifact_url: "https://example.test/abilities.csv", source_artifact_sha256: "a".repeat(64), source_row_count: 94640, }] } - : ready, + : String(input).includes("occupation-rating-occupations") + ? { + data_release_code: "onet-31.0", source_table_code: "abilities", + source_available: true, + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Chief Executives" }, + { onetsoc_code: "15-1252.00", occupation_title: "Software Developers" }, + ], + } + : ready, ), { headers: { "Content-Type": "application/json" } }); return () => { globalThis.fetch = previousFetch; }; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByLabelText("O*NET-SOC 직업 코드"), "15-1252.00"); + const occupation = await canvas.findByLabelText("직업"); + await canvas.findByRole("option", { name: "Software Developers · 15-1252.00" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(canvas.getByRole("button", { name: "직업 근거 열기" })); await expect(canvas.findByText("4.10")).resolves.toBeVisible(); }, @@ -78,5 +89,26 @@ export const CatalogUnavailable: Story = { await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("잠시 후 다시 열어 보세요"); }, }; +export const OccupationsEmpty: 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: 2, + }] } + : { data_release_code: "onet-31.0", source_table_code: "abilities", source_available: true, occupations: [] }, + ), { headers: { "Content-Type": "application/json" } }); + return () => { globalThis.fetch = previousFetch; }; + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).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 c2c9284aa..8e599b79c 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -1,9 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchOccupationRatingSources, fetchOccupationRatings, + fetchRatingSourceOccupations, type OccupationRatingProfile as Payload, } from "../api"; import { OccupationRatingProfile, OccupationRatingProfileView } from "./OccupationRatingProfile"; @@ -12,6 +13,7 @@ vi.mock("../api", async (importOriginal) => ({ ...(await importOriginal()), fetchOccupationRatingSources: vi.fn(), fetchOccupationRatings: vi.fn(), + fetchRatingSourceOccupations: vi.fn(), })); const ready: Payload = { @@ -38,6 +40,18 @@ const ready: Payload = { next_offset: null, }; +beforeEach(() => { + vi.mocked(fetchRatingSourceOccupations).mockResolvedValue({ + data_release_code: "onet-31.0", + source_table_code: "abilities", + source_available: true, + occupations: [ + { onetsoc_code: "11-1011.00", occupation_title: "Chief Executives" }, + { onetsoc_code: "15-1252.00", occupation_title: "Software Developers" }, + ], + }); +}); + describe("OccupationRatingProfile", () => { it("submits exact identifiers and renders warnings beside the retained value", async () => { vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ @@ -52,7 +66,11 @@ describe("OccupationRatingProfile", () => { 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: "Software Developers · 15-1252.00" }); + await userEvent.selectOptions( + await screen.findByLabelText("직업"), + "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, @@ -71,6 +89,26 @@ describe("OccupationRatingProfile", () => { expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); }); + it("fails closed when an imported source has no selectable occupation", 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(fetchRatingSourceOccupations).mockResolvedValue({ + data_release_code: "onet-31.0", source_table_code: "abilities", + source_available: true, occupations: [], + }); + render(); + + expect(await screen.findByText(/선택할 수 있는 직업이 없습니다/)).toBeInTheDocument(); + expect(screen.getByLabelText("직업")).toBeDisabled(); + }); + it("keeps pagination bound to the loaded profile after form edits", async () => { vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [{ @@ -85,13 +123,13 @@ describe("OccupationRatingProfile", () => { .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: "Software Developers · 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", { @@ -114,13 +152,13 @@ describe("OccupationRatingProfile", () => { .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: "Software Developers · 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..63266f518 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from "react"; import { fetchOccupationRatingSources, fetchOccupationRatings, + fetchRatingSourceOccupations, type OccupationRatingProfile as OccupationRatingProfilePayload, type OccupationRatingSource, + type RatingSourceOccupation, } from "../api"; type Props = { accessToken: string }; @@ -24,6 +26,8 @@ export function OccupationRatingProfile({ accessToken }: Props) { const [sources, setSources] = useState(null); const [selectedSource, setSelectedSource] = useState(""); const [sourceCatalogError, setSourceCatalogError] = useState(false); + const [occupations, setOccupations] = useState(null); + const [occupationCatalogError, setOccupationCatalogError] = useState(false); const [profile, setProfile] = useState(null); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); @@ -41,6 +45,29 @@ export function OccupationRatingProfile({ accessToken }: Props) { return () => { active = false; }; }, [accessToken]); + useEffect(() => { + const source = sources?.find( + (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, + ); + setOnetsocCode(""); + setProfile(null); + setOccupationCatalogError(false); + if (!source) { + setOccupations(null); + return; + } + let active = true; + setOccupations(null); + fetchRatingSourceOccupations( + accessToken, + source.data_release_code, + source.source_table_code, + ) + .then((payload) => active && setOccupations(payload.occupations)) + .catch(() => active && setOccupationCatalogError(true)); + return () => { active = false; }; + }, [accessToken, selectedSource, sources]); + function load(offset: number | null = null) { const source = sources?.find( (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, @@ -81,7 +108,7 @@ export function OccupationRatingProfile({ accessToken }: Props) {

공개 직업 근거

직업별 업무 특성 확인

-

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

+

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

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

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

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

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

: null} {sourceCatalogError ?

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

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

이 근거 표의 직업 목록을 확인하는 중입니다.

: null} + {selectedSource && occupations?.length === 0 ?

이 근거 표에 선택할 수 있는 직업이 없습니다. 다른 근거 표를 선택하세요.

: null} + {occupationCatalogError ?

직업 목록을 확인하지 못했습니다. 잠시 후 다시 열어 보세요.

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

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

+

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

) : null} {profile ? : null} {profile?.next_offset != null ? ( diff --git a/tests/test_occupation_rating_ingestion.py b/tests/test_occupation_rating_ingestion.py index c396478f9..3f8ec21dd 100644 --- a/tests/test_occupation_rating_ingestion.py +++ b/tests/test_occupation_rating_ingestion.py @@ -3,10 +3,15 @@ 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_sources, + read_occupation_ratings, + read_rating_source_occupations, +) from backend.app.occupation_rating_ingestion import ( fetch_occupation_rating_sources, fetch_occupation_ratings, + fetch_rating_source_occupations, ) @@ -197,3 +202,56 @@ def test_authenticated_source_catalog_route_uses_shared_projection() -> None: ) assert result == {"sources": []} + + +def test_source_occupation_catalog_distinguishes_unavailable_from_empty() -> None: + unavailable = asyncio.run( + fetch_rating_source_occupations( + FakeConnection(None), + data_release_code="onet-31.0", + source_table_code="abilities", + ) + ) + empty = asyncio.run( + fetch_rating_source_occupations( + FakeConnection({"exists": 1}), + data_release_code="onet-31.0", + source_table_code="abilities", + ) + ) + + assert unavailable["source_available"] is False + assert empty["source_available"] is True + assert empty["occupations"] == [] + + +def test_source_occupation_catalog_returns_authoritative_codes_and_titles() -> None: + rows = ( + {"onetsoc_code": "11-1011.00", "occupation_title": "Chief Executives"}, + {"onetsoc_code": "15-1252.00", "occupation_title": "Software Developers"}, + ) + conn = FakeConnection({"exists": 1}, rows) + + result = asyncio.run( + fetch_rating_source_occupations( + conn, + data_release_code="onet-31.0", + source_table_code="abilities", + ) + ) + + assert result["occupations"] == list(rows) + assert "and exists" in conn.last_fetch_query + + +def test_authenticated_source_occupation_route_uses_shared_projection() -> None: + result = asyncio.run( + read_rating_source_occupations( + data_release_code="onet-31.0", + source_table_code="abilities", + _account=object(), + pool=FakePool(FakeConnection({"exists": 1})), + ) + ) + + assert result["source_available"] is True diff --git a/tests/test_schema.py b/tests/test_schema.py index 6d1b8b05c..391367af5 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -31,6 +31,7 @@ from backend.app.occupation_rating_ingestion import ( fetch_occupation_rating_sources, fetch_occupation_ratings, + fetch_rating_source_occupations, ) from backend.app.post_chat_ingestion import gather_global_chat_sources from scripts.import_onet_ratings import import_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,23 @@ 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_rating_source_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["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["occupations"] == [ + {"onetsoc_code": "15-1252.00", "occupation_title": "Synthetic occupation"} + ] def test_global_ask_evidence_search_indexes_exist_on_normalized_tables(schema_db) -> None: From ff4d1765e051b7c8ad56d43d876dc439a7f233eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:27:31 +0900 Subject: [PATCH 2/6] fix(ui): invalidate rating requests on source change --- .../components/OccupationRatingProfile.test.tsx | 17 +++++++++++++---- .../src/components/OccupationRatingProfile.tsx | 2 ++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index 03fc1213f..0600d2539 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -41,6 +41,15 @@ const ready: Payload = { }; beforeEach(() => { + 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(fetchRatingSourceOccupations).mockResolvedValue({ data_release_code: "onet-31.0", source_table_code: "abilities", @@ -171,12 +180,12 @@ describe("OccupationRatingProfile", () => { .mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; })) .mockResolvedValueOnce({ ...ready, onetsoc_code: "11-1011.00", items: [{ ...ready.items[0], data_value: "3.20" }] }); 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: "Software Developers · 15-1252.00" }); + await userEvent.selectOptions(occupation, "15-1252.00"); await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); - await userEvent.clear(occupation); - await userEvent.type(occupation, "11-1011.00"); + await userEvent.selectOptions(occupation, "11-1011.00"); fireEvent.submit(occupation.closest("form")!); expect(await screen.findByText("3.20")).toBeInTheDocument(); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index b6cd448fa..e05e7c591 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -47,6 +47,8 @@ export function OccupationRatingProfile({ accessToken }: Props) { }, [accessToken]); useEffect(() => { + requestSequence.current += 1; + setStatus("idle"); const source = sources?.find( (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, ); From 0f3017aa25ad2a02b068244a31d4008e0206d09e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:33:49 +0900 Subject: [PATCH 3/6] fix(ui): clear evidence when occupation changes --- docs/adr/0261-rating-source-occupation-selector.md | 3 ++- docs/product-requirements.md | 9 +++++---- .../src/components/OccupationRatingProfile.test.tsx | 13 ++++--------- frontend/src/components/OccupationRatingProfile.tsx | 7 ++++++- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/adr/0261-rating-source-occupation-selector.md b/docs/adr/0261-rating-source-occupation-selector.md index c407bc3ab..8a3c73ddf 100644 --- a/docs/adr/0261-rating-source-occupation-selector.md +++ b/docs/adr/0261-rating-source-occupation-selector.md @@ -26,7 +26,8 @@ not sufficient evidence that a profile exists for the selected source. cutoff that makes valid occupations disappear. 4. Replace free-text occupation-code entry with a native select whose visible label begins with the stored title and retains the exact code. Changing the - rating source clears both occupation selection and displayed evidence. + rating source clears both occupation selection and displayed evidence; + changing the occupation clears displayed evidence. 5. While the occupation catalog is loading, empty, or unavailable, disable profile submission and state the next action. Pagination remains bound to the identifiers returned by the loaded profile under ADR 0259. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index e6385ef68..2ed4d772f 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -192,15 +192,16 @@ listed while its supporting scale artifact is not. - Populate the occupation selector with exact stored code/title pairs that have observations in the selected imported source (ADR 0261). -- Clear the current occupation and profile when the source changes; never mix - continuation rows across occupations or sources. +- Clear the current occupation and profile when the source changes, and clear + the profile when the occupation changes; never mix continuation rows across + occupations or sources. - Keep unavailable source, available-empty source, loading, and transport failure distinct and actionable. Acceptance: a user selects a stored title rather than typing an internal code; the PostgreSQL integration test proves the source membership predicate; and -component tests prove source changes clear prior evidence and pagination stays -bound to the loaded profile identifiers. +component tests prove selector changes clear prior evidence and pagination +stays bound to the loaded profile identifiers. ### PRD-FR-3 — Bounded ontology exploration diff --git a/frontend/src/components/OccupationRatingProfile.test.tsx b/frontend/src/components/OccupationRatingProfile.test.tsx index 73bc5ebdc..526061f9a 100644 --- a/frontend/src/components/OccupationRatingProfile.test.tsx +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -137,7 +137,7 @@ describe("OccupationRatingProfile", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - it("keeps pagination bound to the loaded profile after form edits", async () => { + it("clears loaded evidence when the occupation selection changes", async () => { vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [{ data_release_code: "onet-31.0", release_version: "31.0", @@ -147,9 +147,7 @@ describe("OccupationRatingProfile", () => { source_row_count: 2, }], }); - vi.mocked(fetchOccupationRatings) - .mockResolvedValueOnce({ ...ready, next_offset: 100 }) - .mockResolvedValueOnce({ ...ready, items: [{ ...ready.items[0], scale_id: "LV" }] }); + vi.mocked(fetchOccupationRatings).mockResolvedValueOnce({ ...ready, next_offset: 100 }); render(); const occupation = await screen.findByLabelText("직업"); await screen.findByRole("option", { name: "Software Developers · 15-1252.00" }); @@ -158,12 +156,9 @@ describe("OccupationRatingProfile", () => { await screen.findByText("4.10"); await userEvent.selectOptions(occupation, "11-1011.00"); - await userEvent.click(screen.getByRole("button", { name: "다음 관측값 불러오기" })); - expect(fetchOccupationRatings).toHaveBeenLastCalledWith("synthetic-token", { - onetsocCode: "15-1252.00", dataReleaseCode: "onet-31.0", sourceTableCode: "abilities", offset: 100, - }); - expect(await screen.findAllByText("4.10")).toHaveLength(2); + expect(screen.queryByText("4.10")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "다음 관측값 불러오기" })).not.toBeInTheDocument(); }); it("removes stale evidence while a fresh occupation loads", async () => { diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx index 0e72ae903..21f8260d7 100644 --- a/frontend/src/components/OccupationRatingProfile.tsx +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -133,7 +133,12 @@ export function OccupationRatingProfile({ accessToken }: Props) {