Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions backend/app/occupation_rating_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment thread
seonghobae marked this conversation as resolved.
return {
"data_release_code": data_release_code,
"source_table_code": source_table_code,
"source_available": True,
"occupations": [dict(row) for row in rows],
}
45 changes: 45 additions & 0 deletions docs/adr/0261-rating-source-occupation-selector.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down
15 changes: 15 additions & 0 deletions docs/product-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ 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
Expand Down
2 changes: 1 addition & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fetchOccupationRatingSources,
fetchOccupationRatings,
fetchOperationsDashboard,
fetchRatingSourceOccupations,
updateTenantConfig,
} from "./api";

Expand Down Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
36 changes: 34 additions & 2 deletions frontend/src/components/OccupationRatingProfile.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down Expand Up @@ -78,5 +89,26 @@ export const CatalogUnavailable: Story = {
await expect(within(canvasElement).findByRole("alert")).resolves.toHaveTextContent("잠시 후 다시 열어 보세요");
},
};
export const OccupationsEmpty: Story = {
render: () => <OccupationRatingProfile accessToken="synthetic-token" />,
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: [] } } };
Loading
Loading