diff --git a/backend/app/iopsy_ontology_api.py b/backend/app/iopsy_ontology_api.py new file mode 100644 index 000000000..564cab69f --- /dev/null +++ b/backend/app/iopsy_ontology_api.py @@ -0,0 +1,137 @@ +"""Read-only API projection over the FJA I/O-Psychology semantic layer. + +These serializers expose the DOT/FJA worker-function I/O-Psychology +profiles and the cognitive/affective/behavioral construct catalog +(ADR 0251) as plain, JSON-safe dictionaries for the Evidence API, +without importing database or HTTP concerns. Fail-closed behavior +mirrors the semantic layer: an undeclared worker function or construct +is an honest ``None``, and invalid domains raise ``ValueError``. +""" + +from __future__ import annotations + +from typing import Any + +from lineageweave.iopsy_taxonomy import ( + IOPsyConstructRecord, + IOPsyRelationRecord, + WorkerFunctionIOPsyProfile, + all_iopsy_construct_records, + all_iopsy_relation_records, + iopsy_profile_for_worker_function, +) + +#: Profile attribute names in the same order as the typed record slots. +_PROFILE_SLOTS: tuple[tuple[str, str], ...] = ( + ("cognitive_demands", "cognitive_demands"), + ("mental_workload_demands", "mental_workload_demands"), + ("affective_demands", "affective_demands"), + ("emotional_labor_demands", "emotional_labor_demands"), + ("behavioral_manifestations", "behavioral_manifestations"), + ("psychomotor_behaviors", "psychomotor_behaviors"), + ("interpersonal_behaviors", "interpersonal_behaviors"), +) + + +def construct_to_payload(construct: IOPsyConstructRecord) -> dict[str, str]: + """Project one I/O psychology construct into its JSON-safe payload shape. + + Args: + construct: The typed semantic-layer construct record. + + Returns: + dict[str, str]: iri, category, label, dimension, theoretical_basis, and + definition fields as plain strings. + """ + return { + "iri": construct.iri, + "category": construct.category, + "label": construct.label, + "dimension": construct.dimension, + "theoretical_basis": construct.theoretical_basis, + "definition": construct.definition, + } + + +def relation_to_payload(relation: IOPsyRelationRecord) -> dict[str, str]: + """Project one I/O psychology relation into its JSON-safe payload shape. + + Args: + relation: The typed semantic-layer relation record. + + Returns: + dict[str, str]: source_iri, source_label, predicate_iri, + predicate_label, target_iri, target_label, and target_category. + """ + return { + "source_iri": relation.source_iri, + "source_label": relation.source_label, + "predicate_iri": relation.predicate_iri, + "predicate_label": relation.predicate_label, + "target_iri": relation.target_iri, + "target_label": relation.target_label, + "target_category": relation.target_category, + } + + +def _profile_slot(profile: WorkerFunctionIOPsyProfile, field: str) -> list[dict[str, str]]: + """Serialize one named profile attribute into a sorted construct list. + + Args: + profile: The typed worker-function I/O psychology profile. + field: The profile attribute name (e.g. ``cognitive_demands``). + + Returns: + list[dict[str, str]]: Construct payload dictionaries, label-sorted. + """ + return sorted( + (construct_to_payload(construct) for construct in getattr(profile, field)), + key=lambda item: item["label"], + ) + + +def worker_function_profile_payload(domain: str, rank: int) -> dict[str, Any] | None: + """Serialize one worker function's I/O psychology demand profile. + + Args: + domain: FJA domain (``data``, ``people``, or ``things``). + rank: Ordinal rank within the published domain limits. + + Returns: + dict[str, Any] | None: The demand/manifestation profile payload, or + ``None`` when the function is not declared. Raises ``ValueError`` + for an unrecognized domain (caller error). + """ + profile = iopsy_profile_for_worker_function(domain, rank) + if profile is None: + return None + payload: dict[str, Any] = { + "function_domain": profile.function_domain, + "function_rank": profile.function_rank, + "function_label": profile.function_label, + } + for field, attribute in _PROFILE_SLOTS: + payload[field] = _profile_slot(profile, attribute) + return payload + + +def construct_catalog_payload() -> dict[str, Any]: + """Serialize the full I/O psychology construct and relation catalog. + + Returns: + dict[str, Any]: Deterministic, JSON-safe payload with a ``constructs`` + map grouped by category plus the complete ``relations`` list. + """ + constructs = all_iopsy_construct_records() + by_category: dict[str, list[dict[str, str]]] = {} + for construct in constructs: + by_category.setdefault(construct.category, []).append( + construct_to_payload(construct) + ) + for category in by_category: + by_category[category] = sorted(by_category[category], key=lambda item: item["label"]) + relations = [relation_to_payload(relation) for relation in all_iopsy_relation_records()] + return { + "constructs": by_category, + "relations": relations, + } \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 91517e9eb..d54b1ab21 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -128,6 +128,10 @@ parse_allowed_property_query, visible_ontology_neighborhood, ) +from backend.app.iopsy_ontology_api import ( + construct_catalog_payload, + worker_function_profile_payload, +) from backend.app.post_chat_ingestion import ( fetch_persisted_chat, fetch_persisted_chats, @@ -2298,6 +2302,44 @@ async def read_ontology_neighborhood( return payload +@app.get("/api/ontology/worker-functions/{domain}/{rank}") +async def read_worker_function_psychology( + domain: str, + rank: int, + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """I/O-Psychology demand profile for one DOT/FJA worker function (ADR 0251). + + Serves the grounded cognitive, affective, and behavioral construct + relations declared in the published ontology. An undeclared domain/rank + pair is an honest 404 -- never a fabricated profile. Unrecognized + domains are client errors. + """ + _require_post_read(account) + if rank < 0 or rank > 100 or domain not in {"data", "people", "things"}: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "domain must be one of data, people, things; rank within the published table extents", + ) + payload = worker_function_profile_payload(domain, rank) + if payload is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "undeclared worker function") + return payload + + +@app.get("/api/ontology/worker-function-constructs") +async def read_worker_function_construct_catalog( + account: CurrentAccount = Depends(get_current_account), +) -> dict[str, Any]: + """Cognitive, affective, and behavioral construct catalog (ADR 0251). + + Returns the deterministic typed construct groups and their nomological + relations from the published ontology, for ontology/evidence surfaces. + """ + _require_post_read(account) + return construct_catalog_payload() + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/tests/test_iopsy_ontology_api.py b/backend/tests/test_iopsy_ontology_api.py new file mode 100644 index 000000000..3b57d93e3 --- /dev/null +++ b/backend/tests/test_iopsy_ontology_api.py @@ -0,0 +1,103 @@ +"""API contract tests for the FJA I/O-Psychology ontology endpoints (ADR 0251). + +Exercises the read-only worker-function psychology and construct-catalog +routes through their handler aliases with the authorization gate stubbed +out, mirroring the sibling ``backend/tests`` style. The payloads are pure +projections of the published ontology, so no database is needed. +""" + +from __future__ import annotations + +import asyncio +import builtins +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from backend.app import iopsy_ontology_api +from backend.app import main + + +@pytest.fixture(autouse=True) +def _stub_gate(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub the coarse post_read authorization gate for handler tests.""" + if hasattr(main, "_require_post_read"): + monkeypatch.setattr(main, "_require_post_read", lambda account: None) + + +def _account() -> SimpleNamespace: + """Return a minimal authorized account double.""" + return SimpleNamespace(corporate_entity_ids=set(), process_unit_ids=set()) + + +def _run(function: object, *args: object) -> object: + """Await one async handler with the given arguments.""" + return asyncio.run(function(*args)) + + +def test_worker_function_psychology_returns_profile() -> None: + """High-complexity data work demands analytic cognition and core performance.""" + payload = _run(main.read_worker_function_psychology, "data", 2, _account()) + + assert payload["function_domain"] == "data" + assert payload["function_rank"] == 2 + assert payload["function_label"] == "Analyzing" + cognitive_labels = {item["label"] for item in payload["cognitive_demands"]} + assert "Diagnostic Reasoning" in cognitive_labels + assert "Inductive & Deductive Reasoning" in cognitive_labels + assert payload["mental_workload_demands"] + assert all(item["category"] == "cognitive" for item in payload["mental_workload_demands"]) + + +def test_people_function_includes_emotional_labor() -> None: + """Negotiating demands deep acting and affective regulation.""" + payload = _run(main.read_worker_function_psychology, "people", 1, _account()) + assert payload["function_label"] == "Negotiating" + emotional_labor = {item["label"] for item in payload["emotional_labor_demands"]} + assert "Emotional Labor — Deep Acting" in emotional_labor + assert payload["affective_demands"] + + +def test_things_function_requires_safety() -> None: + """Things functions manifest safety compliance and psychomotor behavior.""" + payload = _run(main.read_worker_function_psychology, "things", 2, _account()) + behavioral_labels = {item["label"] for item in payload["behavioral_manifestations"]} + assert "Safety Compliance" in behavioral_labels + assert payload["psychomotor_behaviors"] + + +def test_undeclared_function_is_honest_404() -> None: + """An absent domain/rank pair fails closed as an honest 404.""" + with pytest.raises(HTTPException) as exc_info: + _run(main.read_worker_function_psychology, "data", 99, _account()) + assert exc_info.value.status_code == 404 + + +def test_invalid_domain_is_client_error() -> None: + """An unrecognized FJA domain is client error, never fabricated output.""" + with pytest.raises(HTTPException) as exc_info: + _run(main.read_worker_function_psychology, "bogus", 0, _account()) + assert exc_info.value.status_code == 422 + + +def test_construct_catalog_is_deterministic_and_complete() -> None: + """The catalog groups constructs by psychological domain with metadata.""" + payload = iopsy_ontology_api.construct_catalog_payload() + constructs = payload["constructs"] + assert {"cognitive", "affective", "behavioral"} <= set(constructs) + for category in ("cognitive", "affective", "behavioral"): + assert constructs[category] + cognitive = {item["label"] for item in constructs["cognitive"]} + assert "Mental Workload" in cognitive + affective = {item["label"] for item in constructs["affective"]} + assert "Burnout — Emotional Exhaustion" in affective + assert all(item["theoretical_basis"] for item in constructs["cognitive"]) + assert payload["relations"] + + +def test_construct_catalog_isolated_from_main_import() -> None: + """The serializer module imports no FastAPI application concerns.""" + code = builtins.open(iopsy_ontology_api.__file__, encoding="utf-8").read() + assert "import main" not in code + assert "fastapi" not in code \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index eda9d69a8..ca71ff555 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -988,6 +988,59 @@ export function fetchOntologyNeighborhood( return backendFetch(`/api/ontology/neighborhood?${params.toString()}`, accessToken); } +export interface WorkerFunctionConstructPayload { + iri: string; + category: "cognitive" | "affective" | "behavioral"; + label: string; + dimension: string; + theoretical_basis: string; + definition: string; +} + +export interface WorkerFunctionProfilePayload { + function_domain: "data" | "people" | "things"; + function_rank: number; + function_label: string; + cognitive_demands: WorkerFunctionConstructPayload[]; + mental_workload_demands: WorkerFunctionConstructPayload[]; + affective_demands: WorkerFunctionConstructPayload[]; + emotional_labor_demands: WorkerFunctionConstructPayload[]; + behavioral_manifestations: WorkerFunctionConstructPayload[]; + psychomotor_behaviors: WorkerFunctionConstructPayload[]; + interpersonal_behaviors: WorkerFunctionConstructPayload[]; +} + +export interface WorkerFunctionRelationPayload { + source_iri: string; + source_label: string; + predicate_iri: string; + predicate_label: string; + target_iri: string; + target_label: string; + target_category: string; +} + +export interface WorkerFunctionConstructCatalogPayload { + constructs: Partial< + Record<"cognitive" | "affective" | "behavioral", WorkerFunctionConstructPayload[]> + >; + relations: WorkerFunctionRelationPayload[]; +} + +export function fetchWorkerFunctionProfile( + accessToken: string, + domain: string, + rank: number, +): Promise { + return backendFetch(`/api/ontology/worker-functions/${domain}/${rank}`, accessToken); +} + +export function fetchWorkerFunctionConstructCatalog( + accessToken: string, +): Promise { + return backendFetch("/api/ontology/worker-function-constructs", accessToken); +} + export function extractPostKeymen( accessToken: string, postId: string, diff --git a/frontend/src/components/WorkerFunctionPsychology.test.tsx b/frontend/src/components/WorkerFunctionPsychology.test.tsx new file mode 100644 index 000000000..a3594808a --- /dev/null +++ b/frontend/src/components/WorkerFunctionPsychology.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { setLocale } from "../i18n"; +import type { + WorkerFunctionConstructCatalogPayload, + WorkerFunctionProfilePayload, +} from "../api"; +import { WorkerFunctionPsychology } from "./WorkerFunctionPsychology"; + +const PROFILE: WorkerFunctionProfilePayload = { + function_domain: "data", + function_rank: 2, + function_label: "Analyzing", + cognitive_demands: [ + { + iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#cogDiagnosticReasoning", + category: "cognitive", + label: "Diagnostic Reasoning", + dimension: "analytic_inference", + theoretical_basis: "Patel, Evans, & Groen (1989)", + definition: "Hypothesis-driven inference to isolate root causes.", + }, + ], + mental_workload_demands: [], + affective_demands: [], + emotional_labor_demands: [], + behavioral_manifestations: [ + { + iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#behCoreTaskPerformance", + category: "behavioral", + label: "Core Task Performance", + dimension: "task_performance", + theoretical_basis: "Campbell (1990)", + definition: "Direct execution of assigned technical processes.", + }, + ], + psychomotor_behaviors: [], + interpersonal_behaviors: [], +}; + +const CATALOG: WorkerFunctionConstructCatalogPayload = { + constructs: { + cognitive: [ + { + iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#cogMentalWorkload", + category: "cognitive", + label: "Mental Workload", + dimension: "cognitive_load", + theoretical_basis: "Sweller (1988)", + definition: "Proportion of cognitive capacity demanded by task difficulty.", + }, + ], + affective: [], + behavioral: [], + }, + relations: [], +}; + +describe("WorkerFunctionPsychology", () => { + afterEach(() => setLocale("en")); + + it("renders the worker function profile slots with metadata and references", () => { + render(); + expect(screen.getByRole("heading", { name: "Work psychology" })).toBeVisible(); + expect(screen.getByText("Analyzing")).toBeVisible(); + expect(screen.getByText("data · rank 2")).toBeVisible(); + expect(screen.getByText("Diagnostic Reasoning")).toBeVisible(); + expect(screen.getByText(/Patel, Evans, & Groen \(1989\)/)).toBeVisible(); + expect(screen.getByText("Behavioral manifestations")).toBeVisible(); + }); + + it("shows the catalog dimension groups with linking construct chips", () => { + render(); + expect(screen.getByText("Catalog dimensions")).toBeVisible(); + expect(screen.getByRole("link", { name: "Mental Workload" })).toHaveAttribute( + "href", + "https://contextualwisdomlab.github.io/LineageWeave/ontology#cogMentalWorkload", + ); + }); + + it("shows an honest loading placeholder", () => { + render(); + expect(screen.getByText(/Work psychology catalog is unavailable/i)).toBeVisible(); + }); + + it("does not invent a profile when none is loaded", () => { + render(); + expect(screen.queryByText("Analyzing")).not.toBeInTheDocument(); + expect(screen.getByText("Catalog dimensions")).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/WorkerFunctionPsychology.tsx b/frontend/src/components/WorkerFunctionPsychology.tsx new file mode 100644 index 000000000..55c155159 --- /dev/null +++ b/frontend/src/components/WorkerFunctionPsychology.tsx @@ -0,0 +1,117 @@ +import type { + WorkerFunctionConstructPayload, + WorkerFunctionConstructCatalogPayload, + WorkerFunctionProfilePayload, +} from "../api"; +import { workerFunctionPsychologyText } from "../workerFunctionPsychologyI18n"; + +/** One psychological demand slot inside a worker-function profile. */ +export interface WorkerFunctionPsychologySlot { + heading: string; + constructs: WorkerFunctionConstructPayload[]; +} + +function slotLabelFor(label: string): string { + return label + .replace(/_/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +/** Rendered demand profile for one DOT/FJA worker function (ADR 0251). */ +export function WorkerFunctionPsychology({ + profile, + catalog, + loading = false, +}: { + profile: WorkerFunctionProfilePayload | null; + catalog: WorkerFunctionConstructCatalogPayload | null; + loading?: boolean; +}) { + const profileSlots: WorkerFunctionPsychologySlot[] = profile + ? [ + { heading: "Cognitive demands", constructs: profile.cognitive_demands }, + { heading: "Mental workload", constructs: profile.mental_workload_demands }, + { heading: "Affective demands", constructs: profile.affective_demands }, + { heading: "Emotional labor", constructs: profile.emotional_labor_demands }, + { heading: "Behavioral manifestations", constructs: profile.behavioral_manifestations }, + { heading: "Psychomotor behaviors", constructs: profile.psychomotor_behaviors }, + ] + : []; + + const catalogGroups = catalog + ? [ + { heading: "Cognitive", constructs: catalog.constructs.cognitive ?? [] }, + { heading: "Affective", constructs: catalog.constructs.affective ?? [] }, + { heading: "Behavioral", constructs: catalog.constructs.behavioral ?? [] }, + ] + : []; + + return ( +
+

{workerFunctionPsychologyText("Work psychology")}

+ {loading ? ( +

+ {workerFunctionPsychologyText("Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.")} +

+ ) : null} + {!loading && profile ? ( + <> +

+ {profile.function_label}{" "} + + {profile.function_domain} · rank {profile.function_rank} + +

+
+ {profileSlots.map((slot) => ( +
+ {slot.heading} +
    + {slot.constructs.map((construct) => ( +
  • + {construct.label}{" "} + {slotLabelFor(construct.dimension)} +

    {construct.definition}

    +

    + {workerFunctionPsychologyText("Reference")}: {construct.theoretical_basis} +

    +
  • + ))} + {slot.constructs.length === 0 ? ( +
  • + + {workerFunctionPsychologyText("Select a worker function to review its I/O psychology demand profile.")} + +
  • + ) : null} +
+
+ ))} +
+ + ) : null} + {catalogGroups.length > 0 ? ( + <> +

{workerFunctionPsychologyText("Catalog dimensions")}

+
    + {catalogGroups.map((group) => ( +
  • + {group.heading}{" "} + {group.constructs.length} + +
  • + ))} +
+ + ) : null} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/workerFunctionPsychologyI18n.ts b/frontend/src/workerFunctionPsychologyI18n.ts new file mode 100644 index 000000000..48c41180c --- /dev/null +++ b/frontend/src/workerFunctionPsychologyI18n.ts @@ -0,0 +1,66 @@ +import { getLocale, type Locale } from "./i18n"; + +const WORKER_FUNCTION_PSYCHOLOGY_COPY = { + en: { + "Work psychology": "Work psychology", + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.": + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.", + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.": + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.", + "Catalog dimensions": "Catalog dimensions", + "Reference": "Reference", + "Select a worker function to review its I/O psychology demand profile.": + "Select a worker function to review its I/O psychology demand profile.", + }, + ko: { + "Work psychology": "직무 심리", + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.": + "DOT 부록 B의 DOT/FJA 직무 기능 용어집 항목을 엽니다.", + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.": + "직무 심리 카탈로그를 사용할 수 없습니다. 인증 관리자가 온톨로지 카탈로그 투영을 활성화하도록 요청하세요.", + "Catalog dimensions": "카탈로그 차원", + "Reference": "참고 문헌", + "Select a worker function to review its I/O psychology demand profile.": + "I/O 심리학 수요 프로필을 검토하려면 직무 기능을 선택하세요.", + }, + zh: { + "Work psychology": "工作心理", + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.": + "打开 DOT 附录 B 的 DOT/FJA 工作职能词条。", + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.": + "工作心理目录暂不可用。请联系管理员启用本体目录投影。", + "Catalog dimensions": "目录维度", + "Reference": "参考", + "Select a worker function to review its I/O psychology demand profile.": + "选择一项工作职能以查看其 I/O 心理学需求画像。", + }, + ja: { + "Work psychology": "仕事の心理", + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.": + "DOT 付録 B の DOT/FJA 作業機能用語の項目を開きます。", + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.": + "仕事の心理カタログは利用できません。管理者にオントロジーカタログ投影を有効にするよう依頼してください。", + "Catalog dimensions": "カタログの次元", + "Reference": "引用文献", + "Select a worker function to review its I/O psychology demand profile.": + "I/O 心理学の要求プロファイルを確認するには作業機能を選択してください。", + }, + vi: { + "Work psychology": "Tâm lý công việc", + "Open the DOT/FJA worker-function glossary entry in DOT Appendix B.": + "Mở mục thuật ngữ chức năng công việc DOT/FJA trong Phụ lục B của DOT.", + "Work psychology catalog is unavailable. Ask an administrator to enable the ontology catalog projection.": + "Danh mục tâm lý công việc hiện không khả dụng. Hãy yêu cầu quản trị viên bật phép chiếu danh mục ontology.", + "Catalog dimensions": "Các khía cạnh danh mục", + "Reference": "Tham khảo", + "Select a worker function to review its I/O psychology demand profile.": + "Chọn một chức năng công việc để xem hồ sơ nhu cầu Tâm lý I/O.", + }, +} as const satisfies Record>; + +export type WorkerFunctionPsychologyCopyKey = keyof (typeof WORKER_FUNCTION_PSYCHOLOGY_COPY)["en"]; + +/** Return worker-function I/O psychology copy for the active product locale. */ +export function workerFunctionPsychologyText(key: WorkerFunctionPsychologyCopyKey): string { + return WORKER_FUNCTION_PSYCHOLOGY_COPY[getLocale()][key]; +} \ No newline at end of file