diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a24bce13..dcfab0b7d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,7 +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 source evidence with exact decimal and artifact provenance semantics (ADR 0258) | +| `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 15193b4e0..a3d970686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ 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 + states (ADR 0260). +- The existing Dashboard now includes an authenticated occupation-evidence + view with exact source selection, sample/error context, textual suppression + warnings, artifact links, responsive table access, and distinct unavailable + versus empty next actions (ADR 0259). - Authenticated occupation profiles can now read one exact imported release/source with rating and scale artifact provenance, exact decimal strings, uncertainty, suppression/relevance flags, and explicit unavailable diff --git a/backend/app/main.py b/backend/app/main.py index 60dd9e1a5..b56a98ed6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -98,7 +98,11 @@ upsert_commitment_ticket, ) from backend.app.operations_dashboard import fetch_operations_dashboard -from backend.app.occupation_rating_ingestion import fetch_occupation_ratings +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 ( corporate_entity_exists, @@ -2302,6 +2306,36 @@ async def read_occupation_ratings( ) +@app.get("/api/occupation-rating-sources") +async def read_occupation_rating_sources( + _account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, list[dict[str, object]]]: + """Return the authenticated catalog of imported occupation-rating sources.""" + async with pool.acquire() as conn: + 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 320fe248d..f7d2d10a1 100644 --- a/backend/app/occupation_rating_ingestion.py +++ b/backend/app/occupation_rating_ingestion.py @@ -125,3 +125,74 @@ async def fetch_occupation_ratings( "items": items, "next_offset": offset + limit if len(rows) > limit else None, } + + +async def fetch_occupation_rating_sources( + conn: RatingReadConnection, +) -> dict[str, list[dict[str, object]]]: + """Return imported rating artifacts that contain at least one observation.""" + rows = await conn.fetch( + """select source.data_release_code, release.release_version, + release.source_publisher_name, release.source_license_url, + source.source_table_code, source.source_table_name, + source.source_artifact_url, source.source_artifact_sha256, + source.source_row_count + from occupational_source_table source + join occupational_data_release release + on release.data_release_code = source.data_release_code + where source.source_table_code <> 'scales_reference' + and exists ( + select 1 + from occupational_rating_observation observation + where observation.data_release_code = source.data_release_code + and observation.source_table_code = source.source_table_code + ) + order by release.imported_at desc, source.data_release_code, + 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/0259-occupation-rating-evidence-ui.md b/docs/adr/0259-occupation-rating-evidence-ui.md new file mode 100644 index 000000000..efcf08726 --- /dev/null +++ b/docs/adr/0259-occupation-rating-evidence-ui.md @@ -0,0 +1,52 @@ +# ADR 0259: Occupation-rating evidence in the existing Dashboard + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0183, ADR 0206, ADR 0258 +- Figma file ID: `1Su3lDRmiZdcUs47t1QwIX` + +## Context + +ADR 0258 makes an exact imported occupation profile readable, but an API does +not let an authenticated user find a published work characteristic or notice +that a value has low precision. ADR 0183 fixes the analyst GNB and prohibits a +new destination for every evidence type. The existing Dashboard is the place +for evidence-oriented next actions and already owns responsive table and form +tokens under ADR 0206. + +## Decision + +1. Add the occupation profile below the existing operations evidence on the + Dashboard. Do not add or rename a GNB destination. +2. Require the user to submit an exact O*NET-SOC code, data release, and source + table. Native form validation rejects malformed occupation codes before a + request; the API remains the trust-boundary validator. +3. Show each exact published value beside its declared scale bounds, optional + category, sample size, standard error, confidence interval, source month, + domain source, suppression warning, and not-relevant flag. Do not calculate + a score, rank, weight, trait estimate, or recommendation. +4. Keep `source unavailable` distinct from `occupation has no observations`. + Both states give a next action instead of displaying zero or a blank table. +5. Link the rating artifact and scale definition. The API carries their + digests and row counts for provenance; a later disclosure control may show + those identifiers when user research demonstrates that it aids the task. +6. Reuse the existing Dashboard Figma file, design tokens, native controls, + responsive overflow, focus behavior, and reduced-motion baseline. The table + has a named keyboard-focusable region and every warning is text, not color. +7. Storybook records populated, narrow, source-unavailable, and empty-profile + scenes using synthetic records only. Runtime screenshot review covers the + populated desktop and narrow scenes. + +## Consequences + +Users can inspect source evidence without confusing absence, low precision, or +not-relevant responses with a negative occupational conclusion. The interface +does not introduce a local psychometric or inference implementation. + +## 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/0260-occupation-rating-source-catalog.md b/docs/adr/0260-occupation-rating-source-catalog.md new file mode 100644 index 000000000..93f80ce54 --- /dev/null +++ b/docs/adr/0260-occupation-rating-source-catalog.md @@ -0,0 +1,41 @@ +# ADR 0260: Imported occupation-rating source catalog + +- Status: Accepted +- Date: 2026-08-27 +- Extends: ADR 0257, ADR 0258, ADR 0259 + +## Context + +ADR 0259 initially requires users to type internal release and source-table +codes. That makes a valid product action depend on repository knowledge and +allows a user to request a source that was never imported. The normalized +rating store already owns the exact imported artifact catalog and therefore is +the only authoritative selector source. + +## Decision + +1. Add an authenticated read endpoint that lists rating artifacts containing + at least one persisted occupation observation. Exclude the Scales Reference + support artifact from selectable rating sources. +2. Return release code/version, publisher and license, source code/name, URL, + SHA-256, and declared row count. Order releases by persisted import time and + sources by stored name/code; do not infer recency from a version string. +3. The Dashboard selects only an entry returned by this endpoint. If the + catalog is loading, empty, or unavailable, disable profile submission and + give the user a next action. Do not retain a hidden hand-written fallback. +4. Authentication matches ADR 0258: imported O*NET artifacts are public + reference data, while the catalog still requires a valid workspace account. +5. The catalog does not claim that all official O*NET artifacts are imported. + It describes only current database state with immutable artifact provenance. + +## Consequences + +The occupation evidence workflow no longer asks users to know storage codes, +and an unavailable artifact cannot masquerade as a selectable source. Adding +an official artifact 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/0261-rating-source-occupation-selector.md b/docs/adr/0261-rating-source-occupation-selector.md new file mode 100644 index 000000000..8a3c73ddf --- /dev/null +++ b/docs/adr/0261-rating-source-occupation-selector.md @@ -0,0 +1,45 @@ +# 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; + 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. + +## 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 1a36fa1a8..3ff700e68 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,9 @@ decision from them. | [`ONET_31_LINKAGE_REFERENCES.md`](../doctoring/ONET_31_LINKAGE_REFERENCES.md) | [0256](0256-onet-content-model-published-linkages.md) | | [`ONET_RATING_STORE_REFERENCES.md`](../doctoring/ONET_RATING_STORE_REFERENCES.md) | [0257](0257-onet-occupation-rating-observation-store.md) | | 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 d84b284b0..2ed4d772f 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -161,6 +161,48 @@ Acceptance: invalid identifiers and unbounded pages are rejected; an unavailable source never appears as a negative profile; pagination is deterministic; and a 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). +- 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, + transport failure, and additional pages. + +Acceptance: keyboard users can operate the form and named horizontally +scrollable table; narrow layouts retain complete values; suppression remains +visible beside its value; and Storybook covers populated, narrow, unavailable, +and empty states using synthetic data. + +### PRD-FR-2G — Imported rating-source catalog + +- Populate the occupation evidence selector only from imported artifacts that + contain observations, preserving release and artifact provenance (ADR 0260). +- Exclude the scale-definition support artifact from the rating-source selector. +- Disable profile submission and state the next action while the catalog is + loading, empty, or unavailable. + +Acceptance: a user never types an internal release/source code; the selector +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, 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 selector 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 bd7e5a884..63b15f75d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -29,46 +29,57 @@ 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 that distinguishes unavailable source from empty profile and returns exact decimal/uncertainty/warning/artifact provenance without ranking; accessible UI and authenticated runtime evidence remain absent | Pass exact-head review/checks and protected merge; add the accessible exploration UI, synthetic Storybook unavailable/empty/suppressed/paginated states, screenshots, and authenticated aggregate runtime evidence without exposing identifying records | +| 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 | PR | Exact observed head | Base | Observed gate state | |---:|---|---|---| -| #735 | `56d8cbfa` | `feat/onet-occupation-ratings-contract` | clean immediately after exact parent merge; hosted checks had not yet registered on this head, so this is not merge-readiness evidence | -| #734 | `0dedf33b` | `feat/onet-content-model-linkages` | unstable; two hosted gates pending after exact parent reconciliation | -| #732 | `3e30402d` | `feat/onet-31-content-model-ontology` | unstable; one hosted gate pending after exact parent reconciliation | -| #731 | `9654bb8b` | `feat/soc-2018-full-hierarchy` | unstable; one hosted gate pending after exact parent reconciliation | -| #724 | `fdec8f65` | `feat/io-occupational-taxonomy` | clean; hosted gates passed; parent-first stack gate remains | -| #719 | `6ee2278a` | `feat/io-psych-construct-ontology` | unstable; full tests, frontend, CodeRabbit, and Devin Review passed | -| #718 | `2723fea3` | `feat/fja-worker-function-ontology` | unstable; full tests, frontend, CodeRabbit, and Devin Review passed | -| #717 | `771a8edf` | `feat/voice-of-x-complete-taxonomy` | unstable; 1 pending check(s) | -| #716 | `8b54b2f7` | `fix/structured-workflow-exact-pin` | clean; no non-passing check observed | -| #714 | `aa93318f` | `main` | blocked; no non-passing check observed | -| #713 | `cc3dfc14` | `main` | blocked; review required; 13 pending check(s) | -| #711 | `8902e37f` | `feat/dashboard-case-metrics` | clean; no non-passing check observed | -| #710 | `8df04b68` | `main` | blocked; review required; no non-passing check observed | -| #709 | `8ef4090c` | `main` | blocked; review required; 11 pending check(s) | -| #704 | `027323cf` | `main` | blocked; review required; 2 failed check(s) | -| #702 | `5de66ab9` | `main` | blocked; review required; 2 pending check(s) | -| #701 | `cc3351a9` | `main` | blocked; review required; 1 failed check(s) | -| #700 | `1bc99eca` | `main` | blocked; review required; 1 failed check(s) | -| #680 | `efe864e5` | `main` | blocked; 1 failed check(s) | -| #679 | `13ecf41d` | `main` | blocked; no non-passing check observed | -| #672 | `a3e87a89` | `main` | blocked; review required; 1 failed check(s) | -| #668 | `1194f44d` | `main` | blocked; review required; 1 failed check(s) | -| #667 | `c2d11a8a` | `main` | blocked; review required; 2 pending check(s) | -| #658 | `15d670f0` | `main` | blocked; review required; 1 failed check(s) | -| #657 | `9f71681c` | `main` | blocked; review required; 1 failed check(s) | -| #644 | `f53dd28e` | `main` | blocked; review required; 1 failed check(s) | -| #643 | `8767de1b` | `main` | blocked; review required; 1 failed check(s); 1 pending check(s) | -| #640 | `5594029c` | `main` | blocked; no non-passing check observed | -| #639 | `2f4b1bff` | `main` | blocked; review required; 1 failed check(s) | -| #632 | `24262a99` | `main` | blocked; review required; 1 failed check(s) | -| #629 | `b721b0f2` | `main` | blocked; review required; 1 failed check(s) | - -> Dashboard delivery snapshot: 2026-08-26 07:15 KST. Protected `main` was -> `494b54e2245040bcf02b45376f221c37cd437e76`. This local branch is not +| #740 | `52814353` | `feat/onet-rating-read-api` | unstable; three non-passing hosted contexts after opening the occupation evidence UI PR | +| #739 | `9a3f380f` | `feat/operations-candidate-priority` | unstable; two non-passing hosted contexts | +| #738 | `026ba803` | `feat/onet-rating-importer` | unstable; one hosted context still running after exact parent reconciliation; unresolved review threads 0 | +| #735 | `b0355999` | `feat/onet-occupation-ratings-contract` | unstable; one non-passing hosted context | +| #734 | `4c3677af` | `feat/onet-content-model-linkages` | clean snapshot; parent-first stack gate remains | +| #733 | `b53c1edd` | `feat/io-psych-construct-extraction` | unstable; two non-passing hosted contexts | +| #732 | `7f60aa8e` | `feat/onet-31-content-model-ontology` | clean snapshot; parent-first stack gate remains | +| #731 | `b3b9b360` | `feat/soc-2018-full-hierarchy` | clean snapshot; parent-first stack gate remains | +| #728 | `e52a8272` | `feat/dashboard-case-metrics` | clean snapshot; parent-first stack gate remains | +| #726 | `d6a12fbb` | `feat/io-psych-construct-catalog` | unstable; one non-passing hosted context | +| #724 | `1d2f8052` | `feat/io-occupational-taxonomy` | clean snapshot; parent-first stack gate remains | +| #723 | `316fc190` | `feat/io-psych-construct-persistence` | clean snapshot; parent-first stack gate remains | +| #721 | `9214c50f` | `feat/io-psych-construct-ontology` | clean snapshot; parent-first stack gate remains | +| #720 | `dda0531d` | `main` | blocked; review required; one non-passing hosted context | +| #719 | `6ee2278a` | `feat/io-psych-construct-ontology` | clean snapshot; parent-first stack gate remains | +| #718 | `2723fea3` | `feat/fja-worker-function-ontology` | clean snapshot; parent-first stack gate remains | +| #717 | `bf355876` | `feat/voice-of-x-complete-taxonomy` | unstable; one non-passing hosted context | +| #716 | `0a8bd0b6` | `fix/structured-workflow-exact-pin` | unstable; two non-passing hosted contexts | +| #714 | `76a602c8` | `main` | blocked; one non-passing hosted context | +| #713 | `850494c3` | `main` | blocked; review required; one non-passing hosted context | +| #711 | `05e5f520` | `feat/dashboard-case-metrics` | clean snapshot; parent-first stack gate remains | +| #710 | `27a917ee` | `main` | blocked; review required | +| #709 | `8ef4090c` | `main` | blocked; review required; all hosted checks passed; auto-merge awaits the independent approval gate | +| #704 | `7b9a70ee` | `main` | blocked; review required; one non-passing hosted context | +| #702 | `05bdd5b7` | `main` | blocked; two non-passing hosted contexts | +| #701 | `cc3351a9` | `main` | blocked; review required; one non-passing hosted context | +| #700 | `1bc99eca` | `main` | blocked; review required; one non-passing hosted context | +| #680 | `b05e3100` | `main` | blocked; one non-passing hosted context | +| #679 | `135dfe7c` | `main` | blocked; review required; two non-passing hosted contexts | +| #672 | `a3e87a89` | `main` | blocked; review required; one non-passing hosted context | +| #668 | `234f975b` | `main` | blocked; review required; three non-passing hosted contexts | +| #667 | `92a64c40` | `main` | blocked; one non-passing hosted context | +| #658 | `15d670f0` | `main` | blocked; review required; one non-passing hosted context | +| #657 | `9f71681c` | `main` | blocked; review required; one non-passing hosted context | +| #644 | `f53dd28e` | `main` | blocked; review required; one non-passing hosted context | +| #643 | `8767de1b` | `main` | blocked; review required; one non-passing hosted context | +| #640 | `ebfe60af` | `main` | blocked; one non-passing hosted context | +| #639 | `2f4b1bff` | `main` | blocked; review required; one non-passing hosted context | +| #632 | `24262a99` | `main` | blocked; review required; one non-passing hosted context | +| #629 | `b721b0f2` | `main` | blocked; review required; one non-passing hosted context | + +> Dashboard delivery snapshot: 2026-08-27 05:40 KST. Protected `main` was +> `ff7431bd1851c03e737808d22c6a2d43968582f9`. `mergeStateStatus` and hosted +> context counts are observations, not protected merge-readiness evidence. +> This local branch is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..46aa4d467 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,6 +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` | | `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 901b17b1a..692bdf10e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1439,8 +1439,43 @@ .dashboard-case-card dd { margin: 0; font-weight: 600; } .dashboard-case-card button { margin-top: auto; } +.occupation-rating-profile { + max-width: 1440px; + margin: 0 auto 2rem; + padding: 2rem; + color: var(--color-text-heading); +} + +.occupation-rating-form { + display: grid; + grid-template-columns: minmax(14rem, 1fr) minmax(16rem, 1fr) auto; + align-items: end; + gap: var(--space-control-gap); + margin: 1rem 0; +} + +.occupation-rating-form label, +.occupation-rating-source { + display: grid; + gap: var(--space-control-gap); +} + +.occupation-rating-form input, +.occupation-rating-form select { min-height: var(--size-control-min); } +.occupation-rating-source { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); margin: 1rem 0; } +.occupation-rating-scroll-hint { display: none; } +.occupation-rating-table { overflow-x: auto; border: 1px solid var(--color-border); } +.occupation-rating-table table { width: 100%; min-width: 64rem; border-collapse: collapse; } +.occupation-rating-table caption { padding: 0.75rem; text-align: left; } +.occupation-rating-table th, +.occupation-rating-table td { padding: 0.75rem; border-bottom: 1px solid var(--color-border); text-align: left; vertical-align: top; } +.occupation-rating-table small { display: block; color: var(--color-text); } + @media (max-width: 900px) { .operations-dashboard { padding: 1rem; } + .occupation-rating-profile { padding: 1rem; } + .occupation-rating-form { grid-template-columns: 1fr; } + .occupation-rating-scroll-hint { display: block; } .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..671e12aab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -100,6 +100,7 @@ import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; import { OperationsDashboard } from "./components/OperationsDashboard"; +import { OccupationRatingProfile } from "./components/OccupationRatingProfile"; import { initialWorkspaceDestination } from "./gnbChrome"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -5119,13 +5120,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean />
{destination === "dashboard" ? ( - { - setPostToOpen(postId); - setDestination("board"); - }} - /> + <> + { + setPostToOpen(postId); + setDestination("board"); + }} + /> + + ) : null} {destination === "board" ? ( { vi.unstubAllGlobals(); @@ -19,6 +27,53 @@ describe("backendFetch provider-error boundary", () => { ); }); + it("encodes an exact occupation rating source request", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ source_available: false, items: [] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchOccupationRatings("access-token", { + onetsocCode: "15-1252.00", + dataReleaseCode: "onet-31.0", + sourceTableCode: "abilities", + }); + + expect(fetchMock.mock.calls[0][0]).toContain( + "/api/occupations/15-1252.00/ratings?data_release_code=onet-31.0&source_table_code=abilities&limit=100&offset=0", + ); + }); + + it("reads the authenticated occupation source catalog", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sources: [] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchOccupationRatingSources("access-token"); + + 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 fca5882d0..6972a6542 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -960,6 +960,105 @@ export function fetchOntologyNeighborhood( return backendFetch(`/api/ontology/neighborhood?${params.toString()}`, accessToken); } +export interface OccupationRatingItem { + element_id: string; + element_name: string; + scale_id: string; + scale_name: string; + minimum_value: string; + maximum_value: string; + category_value: number | null; + data_value: string; + sample_size: number | null; + standard_error: string | null; + lower_ci_bound: string | null; + upper_ci_bound: string | null; + recommend_suppress: boolean | null; + not_relevant: boolean | null; + source_updated_month: string | null; + domain_source_code: string | null; +} + +export interface OccupationRatingProfile { + data_release_code: string; + source_table_code: string; + onetsoc_code: string; + source_available: boolean; + source: { + source_table_name: string; + source_artifact_url: string; + source_artifact_sha256: string; + source_row_count: number; + scale_artifact_url: string | null; + scale_artifact_sha256: string | null; + scale_source_row_count: number | null; + } | null; + items: OccupationRatingItem[]; + next_offset: number | null; +} + +export interface OccupationRatingSource { + data_release_code: string; + release_version: string; + source_publisher_name: string; + source_license_url: string; + source_table_code: string; + source_table_name: string; + source_artifact_url: string; + source_artifact_sha256: string; + source_row_count: number; +} + +export function fetchOccupationRatingSources( + accessToken: string, +): Promise<{ sources: OccupationRatingSource[] }> { + 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: { + onetsocCode: string; + dataReleaseCode: string; + sourceTableCode: string; + limit?: number; + offset?: number; + }, +): Promise { + const params = new URLSearchParams({ + data_release_code: query.dataReleaseCode, + source_table_code: query.sourceTableCode, + limit: String(query.limit ?? 100), + offset: String(query.offset ?? 0), + }); + return backendFetch( + `/api/occupations/${encodeURIComponent(query.onetsocCode)}/ratings?${params.toString()}`, + accessToken, + ); +} + export function extractPostKeymen( accessToken: string, postId: string, diff --git a/frontend/src/components/OccupationRatingProfile.stories.tsx b/frontend/src/components/OccupationRatingProfile.stories.tsx new file mode 100644 index 000000000..aba75d34b --- /dev/null +++ b/frontend/src/components/OccupationRatingProfile.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { OccupationRatingProfile, OccupationRatingProfileView } from "./OccupationRatingProfile"; +import "../App.css"; + +const ready = { + data_release_code: "onet-31.0", source_table_code: "abilities", onetsoc_code: "15-1252.00", 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" }, + { element_id: "1.A.1.a.2", element_name: "Written Comprehension", scale_id: "LV", scale_name: "Level", minimum_value: "0.00", maximum_value: "7.00", category_value: null, data_value: "5.25", sample_size: 118, standard_error: "0.1100", lower_ci_bound: "5.0344", upper_ci_bound: "5.4656", recommend_suppress: false, not_relevant: false, source_updated_month: "08/2026", domain_source_code: "Analyst" }, + ], + next_offset: null, +}; + +const meta = { title: "Ontology/OccupationRatingProfile", component: OccupationRatingProfileView, parameters: { layout: "fullscreen" }, args: { profile: ready } } satisfies Meta; +export default meta; +type Story = StoryObj; + +export const EvidenceReady: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("4.10")).toBeVisible(); + await expect(canvas.getByText(/정밀도가 낮아/)).toBeVisible(); + }, +}; + +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, + }] } + : 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); + 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(); + }, +}; + +export const NarrowViewport: Story = { + ...InteractiveEvidenceReady, + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; +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; }; + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).findByText(/가져온 직업 근거 표가 없습니다/)).resolves.toBeVisible(); + }, +}; +export const CatalogUnavailable: Story = { + render: () => , + beforeEach: () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error("synthetic catalog failure"); }; + return () => { globalThis.fetch = previousFetch; }; + }, + play: async ({ canvasElement }) => { + 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 new file mode 100644 index 000000000..7c3f881c2 --- /dev/null +++ b/frontend/src/components/OccupationRatingProfile.test.tsx @@ -0,0 +1,280 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchOccupationRatingSources, + fetchOccupationRatings, + fetchRatingSourceOccupations, + type OccupationRatingProfile as Payload, +} from "../api"; +import { OccupationRatingProfile, OccupationRatingProfileView } from "./OccupationRatingProfile"; + +vi.mock("../api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchOccupationRatingSources: vi.fn(), + fetchOccupationRatings: vi.fn(), + fetchRatingSourceOccupations: vi.fn(), +})); + +const ready: Payload = { + data_release_code: "onet-31.0", + source_table_code: "abilities", + onetsoc_code: "15-1252.00", + 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: 2, + 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: true, source_updated_month: "08/2026", domain_source_code: "Analyst", + }], + next_offset: null, +}; + +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", + 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({ + 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(fetchOccupationRatings).mockResolvedValue(ready); + render(); + expect(await screen.findByRole("option", { name: "31.0 · Abilities" })).toBeInTheDocument(); + 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, + }); + expect(await screen.findByText("4.10")).toBeInTheDocument(); + expect(screen.getByText(/정밀도가 낮아/)).toBeInTheDocument(); + expect(screen.getByText(/해당 없음 응답이 포함됩니다/)).toBeInTheDocument(); + expect(screen.getByText(/표를 가로로 밀어/)).toBeInTheDocument(); + }); + + it("fails closed when no imported rating source exists", async () => { + vi.mocked(fetchOccupationRatingSources).mockResolvedValue({ sources: [] }); + render(); + + expect(await screen.findByText(/가져온 직업 근거 표가 없습니다/)).toBeInTheDocument(); + 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("distinguishes an unavailable occupation catalog from an empty one", async () => { + vi.mocked(fetchRatingSourceOccupations).mockResolvedValue({ + data_release_code: "onet-31.0", source_table_code: "abilities", + source_available: false, occupations: [], + }); + render(); + + expect(await screen.findByText(/직업 목록이 아직 준비되지 않았습니다/)).toHaveAttribute("role", "status"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText(/선택할 수 있는 직업이 없습니다/)).not.toBeInTheDocument(); + }); + + it("reports a transport failure separately from an unavailable occupation catalog", async () => { + vi.mocked(fetchRatingSourceOccupations).mockRejectedValue(new Error("synthetic transport failure")); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("직업 목록을 확인하지 못했습니다"); + expect(screen.queryByText(/직업 목록이 아직 준비되지 않았습니다/)).not.toBeInTheDocument(); + }); + + it("clears a stale catalog error when authentication changes", async () => { + vi.mocked(fetchOccupationRatingSources) + .mockRejectedValueOnce(new Error("synthetic catalog failure")) + .mockResolvedValueOnce({ 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, + }] }); + const { rerender } = render(); + expect(await screen.findByRole("alert")).toHaveTextContent("근거 표를 확인하지 못했습니다"); + + rerender(); + + expect(await screen.findByRole("option", { name: "31.0 · Abilities" })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("clears evidence and ignores an in-flight response when authentication changes", async () => { + let finishExpired: ((profile: Payload) => void) | undefined; + 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(fetchOccupationRatings).mockImplementation( + () => new Promise((resolve) => { finishExpired = resolve; }), + ); + const { rerender } = render(); + await screen.findByRole("option", { name: "31.0 · Abilities" }); + await screen.findByRole("option", { name: "Software Developers · 15-1252.00" }); + await userEvent.selectOptions(screen.getByLabelText("직업"), "15-1252.00"); + await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); + + rerender(); + finishExpired?.(ready); + + expect(await screen.findByRole("option", { name: "31.0 · Abilities" })).toBeInTheDocument(); + expect(screen.queryByText("4.10")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "직업 근거 열기" })).toBeDisabled(); + }); + + 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", + 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(fetchOccupationRatings).mockResolvedValueOnce({ ...ready, next_offset: 100 }); + render(); + 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.selectOptions(occupation, "11-1011.00"); + + expect(screen.queryByText("4.10")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "다음 관측값 불러오기" })).not.toBeInTheDocument(); + }); + + 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(fetchOccupationRatings) + .mockResolvedValueOnce(ready) + .mockImplementationOnce(() => new Promise(() => undefined)); + render(); + 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.selectOptions(occupation, "11-1011.00"); + await userEvent.click(screen.getByRole("button", { name: "직업 근거 열기" })); + + expect(screen.queryByText("4.10")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "근거를 불러오는 중" })).toBeDisabled(); + }); + + it("ignores a superseded occupation response that finishes last", async () => { + let finishFirst: ((profile: Payload) => void) | undefined; + 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(fetchOccupationRatings) + .mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; })) + .mockResolvedValueOnce({ ...ready, onetsoc_code: "11-1011.00", items: [{ ...ready.items[0], data_value: "3.20" }] }); + render(); + 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.selectOptions(occupation, "11-1011.00"); + fireEvent.submit(occupation.closest("form")!); + expect(await screen.findByText("3.20")).toBeInTheDocument(); + + finishFirst?.(ready); + expect(screen.queryByText("4.10")).not.toBeInTheDocument(); + expect(screen.getByText("3.20")).toBeInTheDocument(); + }); + + it("distinguishes an unavailable artifact from an empty occupation profile", () => { + const { rerender } = render(); + expect(screen.getByRole("status")).toHaveTextContent("아직 준비되지 않았습니다"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("직업이나 근거 표를 바꿔"); + }); + + it("does not turn a non-http artifact value into a customer link", () => { + render(); + + expect(screen.queryByRole("link", { name: "평정 원문 열기" })).not.toBeInTheDocument(); + expect(screen.getByText(/데이터 담당자에게 출처 확인을 요청하세요/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/OccupationRatingProfile.tsx b/frontend/src/components/OccupationRatingProfile.tsx new file mode 100644 index 000000000..9f733b5b5 --- /dev/null +++ b/frontend/src/components/OccupationRatingProfile.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState } from "react"; +import { + fetchOccupationRatingSources, + fetchOccupationRatings, + fetchRatingSourceOccupations, + type OccupationRatingProfile as OccupationRatingProfilePayload, + type OccupationRatingSource, + type RatingSourceOccupation, +} from "../api"; + +type Props = { accessToken: string }; + +function safeHttpUrl(value: string | null | undefined): string | null { + if (!value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null; + } catch { + return 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 [occupationCatalogUnavailable, setOccupationCatalogUnavailable] = useState(false); + const [occupationCatalogError, setOccupationCatalogError] = useState(false); + const [profile, setProfile] = useState(null); + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + const requestSequence = useRef(0); + const selectedSourceRecord = sources?.find( + (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, + ); + const profileMatchesForm = profile != null + && profile.onetsoc_code === onetsocCode + && profile.data_release_code === selectedSourceRecord?.data_release_code + && profile.source_table_code === selectedSourceRecord?.source_table_code; + + useEffect(() => { + let active = true; + requestSequence.current += 1; + setSourceCatalogError(false); + setSources(null); + setSelectedSource(""); + setProfile(null); + setStatus("idle"); + fetchOccupationRatingSources(accessToken) + .then(({ sources: loaded }) => { + if (!active) return; + setSources(loaded); + setSelectedSource( + loaded[0] ? `${loaded[0].data_release_code}|${loaded[0].source_table_code}` : "", + ); + }) + .catch(() => active && setSourceCatalogError(true)); + return () => { active = false; }; + }, [accessToken]); + + useEffect(() => { + requestSequence.current += 1; + setStatus("idle"); + const source = sources?.find( + (item) => `${item.data_release_code}|${item.source_table_code}` === selectedSource, + ); + setOnetsocCode(""); + setProfile(null); + setOccupationCatalogUnavailable(false); + setOccupationCatalogError(false); + if (!source) { + setOccupations(null); + return; + } + let active = true; + setOccupations(null); + fetchRatingSourceOccupations( + accessToken, + source.data_release_code, + source.source_table_code, + ) + .then((payload) => { + if (!active) return; + if (!payload.source_available) { + setOccupationCatalogUnavailable(true); + return; + } + setOccupations(payload.occupations); + }) + .catch(() => active && setOccupationCatalogError(true)); + return () => { active = false; }; + }, [accessToken, selectedSource, sources]); + + function load(offset: number | null = null) { + const requestId = requestSequence.current + 1; + requestSequence.current = requestId; + const source = selectedSourceRecord; + const request = offset == null && source + ? { + onetsocCode, + dataReleaseCode: source.data_release_code, + sourceTableCode: source.source_table_code, + } + : profile + ? { + onetsocCode: profile.onetsoc_code, + dataReleaseCode: profile.data_release_code, + sourceTableCode: profile.source_table_code, + } + : null; + if (!request) return; + if (offset == null) setProfile(null); + setStatus("loading"); + fetchOccupationRatings(accessToken, { + ...request, + offset: offset ?? 0, + }) + .then((payload) => { + if (requestSequence.current !== requestId) return; + setProfile((current) => + offset != null && current + ? { ...payload, items: [...current.items, ...payload.items] } + : payload, + ); + setStatus("idle"); + }) + .catch(() => { + if (requestSequence.current === requestId) setStatus("error"); + }); + } + + return ( +
+
+

공개 직업 근거

+

직업별 업무 특성 확인

+

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

+
+
{ + event.preventDefault(); + load(); + }} + > + + + +
+ {sources === null && !sourceCatalogError ?

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

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

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

: null} + {sourceCatalogError ?

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

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

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

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

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

: null} + {occupationCatalogUnavailable ?

이 근거 표의 직업 목록이 아직 준비되지 않았습니다. 다른 근거 표를 선택하거나 데이터 담당자에게 가져오기를 요청하세요.

: null} + {occupationCatalogError ?

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

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

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

+ ) : null} + {profile ? : null} + {profileMatchesForm && profile.next_offset != null ? ( + + ) : null} +
+ ); +} + +/** Renders an exact occupation profile for runtime and Storybook scenes. */ +export function OccupationRatingProfileView({ + profile, +}: { + profile: OccupationRatingProfilePayload; +}) { + if (!profile.source_available) { + return ( +

+ 선택한 릴리스와 근거 표가 아직 준비되지 않았습니다. 다른 근거 표를 선택하거나 데이터 담당자에게 가져오기를 요청하세요. +

+ ); + } + if (profile.items.length === 0) { + return ( +

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

+ ); + } + const sourceArtifactUrl = safeHttpUrl(profile.source?.source_artifact_url); + const scaleArtifactUrl = safeHttpUrl(profile.source?.scale_artifact_url); + return ( + <> +
+ {profile.source?.source_table_name} + {profile.data_release_code} · {profile.onetsoc_code} + {sourceArtifactUrl ? ( + 평정 원문 열기 + ) : ( + 원문 링크를 사용할 수 없습니다. 데이터 담당자에게 출처 확인을 요청하세요. + )} + {scaleArtifactUrl ? ( + 척도 정의 열기 + ) : null} +
+

표를 가로로 밀어 오차와 사용 주의를 확인하세요.

+
+ + + + + + + + + {profile.items.map((item) => ( + + + + + + + + + ))} + +
값과 오차 및 사용 주의사항
업무 특성척도표본·오차출처 시점사용 주의
{item.element_name}{item.element_id}{item.scale_name} ({item.minimum_value}–{item.maximum_value}){item.data_value}{item.category_value == null ? null : ` · 범주 ${item.category_value}`} + {item.sample_size == null ? "표본 수 없음" : `N ${item.sample_size}`} + {item.standard_error == null ? null : ` · SE ${item.standard_error}`} + {item.lower_ci_bound == null || item.upper_ci_bound == null ? null : ` · CI ${item.lower_ci_bound}–${item.upper_ci_bound}`} + {item.source_updated_month ?? "시점 없음"}{item.domain_source_code ? ` · ${item.domain_source_code}` : ""} + {[ + item.recommend_suppress ? "정밀도가 낮아 해석 전 원문을 확인하세요." : null, + item.not_relevant ? "해당 없음 응답이 포함됩니다." : null, + ].filter(Boolean).join(" ") || "공개 근거와 함께 해석하세요."} +
+
+ + ); +} diff --git a/tests/test_occupation_rating_ingestion.py b/tests/test_occupation_rating_ingestion.py index 3f2244d7f..3f8ec21dd 100644 --- a/tests/test_occupation_rating_ingestion.py +++ b/tests/test_occupation_rating_ingestion.py @@ -3,8 +3,16 @@ import asyncio from decimal import Decimal -from backend.app.main import read_occupation_ratings -from backend.app.occupation_rating_ingestion import fetch_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, +) class FakeConnection: @@ -14,14 +22,16 @@ def __init__(self, source, rows=()): self.source = source self.rows = list(rows) self.fetch_called = False + self.last_fetch_query = "" 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 return self.rows @@ -160,3 +170,88 @@ def test_authenticated_route_delegates_to_bounded_projection() -> None: ) assert result["source_available"] is False + + +def test_source_catalog_returns_only_query_selected_imports() -> None: + source = { + "data_release_code": "onet-31.0", + "release_version": "31.0", + "source_publisher_name": "National Center for O*NET Development", + "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" * 64, + "source_row_count": 94640, + } + + conn = FakeConnection(None, (source,)) + result = asyncio.run(fetch_occupation_rating_sources(conn)) + + assert result == {"sources": [source]} + assert "source_table_code <> 'scales_reference'" in conn.last_fetch_query + assert "and exists" in conn.last_fetch_query + + +def test_authenticated_source_catalog_route_uses_shared_projection() -> None: + result = asyncio.run( + read_occupation_rating_sources( + _account=object(), + pool=FakePool(FakeConnection(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 d12fa59de..391367af5 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -28,7 +28,11 @@ import psycopg2.errors import pytest -from backend.app.occupation_rating_ingestion import fetch_occupation_ratings +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 @@ -528,10 +532,10 @@ def test_onet_rating_importer_is_idempotent_against_postgresql( ) assert cur.fetchone() == (1, Decimal("4.10"), True) - async def read_imported_profile() -> 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: - return await fetch_occupation_ratings( + profile = await fetch_occupation_ratings( conn, data_release_code=args.release_code, source_table_code=args.source_table_code, @@ -539,13 +543,24 @@ async def read_imported_profile() -> dict[str, object]: limit=100, offset=0, ) + catalog = await fetch_occupation_rating_sources(conn) + 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 = 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: