diff --git a/deployments/api/src/stitch/api/db/og_field_resource_actions.py b/deployments/api/src/stitch/api/db/og_field_resource_actions.py
index 94c5d157..d5960d1f 100644
--- a/deployments/api/src/stitch/api/db/og_field_resource_actions.py
+++ b/deployments/api/src/stitch/api/db/og_field_resource_actions.py
@@ -22,7 +22,11 @@
attach_sources_to_resource,
get_or_create_sources,
)
-from stitch.ogsi.model import OGFieldListItemView, OGFieldResource
+from stitch.ogsi.model import (
+ OGFieldListItemView,
+ OGFieldResource,
+ OGFieldSourceValueView,
+)
from stitch.ogsi.model.types import OGSISrcKey
from .model import (
@@ -30,7 +34,7 @@
MembershipStatus,
ResourceModel,
)
-from .model.oil_gas_field_source_value import value_attr_for
+from .model.oil_gas_field_source_value import ATTRIBUTE_NAMES, value_attr_for
from .queries import base_resource_query, construct_base_query_statement, add_ranking
from .utils import (
coalesce_resources,
@@ -127,6 +131,45 @@ async def get(
)
+async def field_source_values(
+ session: AsyncSession,
+ id: int,
+ field: str,
+ licensed_sources: Collection[OGSISrcKey] | None = None,
+) -> list[OGFieldSourceValueView]:
+ """Every source's value for one field of a resource, best-priority first.
+
+ Returns only sources that carry a value for ``field`` (empty/null omitted),
+ each with its effective per-resource priority. The first entry is the
+ coalesced winner. Licensing is applied. Priority is source-scoped today; the
+ contract is unchanged when it becomes resource/field-scoped.
+ """
+ if field not in ATTRIBUTE_NAMES:
+ raise HTTPException(
+ status_code=422,
+ detail=f"field={field} is not a known resource field.",
+ )
+ if await session.get(ResourceModel, id) is None:
+ raise HTTPException(
+ status_code=HTTP_404_NOT_FOUND, detail=f"No Resource with id `{id}` found."
+ )
+
+ by_id = await ResourceModel.source_data_by_resource_id(
+ session, [id], licensed_sources
+ )
+ values = [
+ OGFieldSourceValueView(
+ source=src.source, id=src.id, value=value, priority=priority
+ )
+ for src, priority in by_id.get(id, [])
+ if src.id is not None
+ and (value := getattr(src, field)) is not None
+ and value != ""
+ ]
+ values.sort(key=lambda v: (v.priority, v.id))
+ return values
+
+
async def create(
session: AsyncSession, user: CurrentUser, resource: OGFieldResource
) -> OGFieldResource:
diff --git a/deployments/api/src/stitch/api/routers/oil_gas_fields.py b/deployments/api/src/stitch/api/routers/oil_gas_fields.py
index b38c1f00..75c7b620 100644
--- a/deployments/api/src/stitch/api/routers/oil_gas_fields.py
+++ b/deployments/api/src/stitch/api/routers/oil_gas_fields.py
@@ -41,6 +41,7 @@
OGFieldListItemView,
OGFieldResource,
OGFieldResourceView,
+ OGFieldSourceValueView,
OGFieldView,
)
@@ -284,6 +285,27 @@ async def get_resource_detail(
return resource_to_detail_view(resource=res)
+@router.get(
+ "/{id}/fields/{field}/sources",
+ response_model=list[OGFieldSourceValueView],
+ dependencies=[Depends(require_permissions(RESOURCE_READ))],
+)
+async def get_field_source_values(
+ *,
+ uow: UnitOfWorkDep,
+ user: CurrentUser,
+ claims: Claims,
+ id: int,
+ field: str,
+) -> list[OGFieldSourceValueView]:
+ return await resource_actions.field_source_values(
+ session=uow.session,
+ id=id,
+ field=field,
+ licensed_sources=licensed_sources(claims),
+ )
+
+
@router.post(
"/",
response_model=OGFieldResourceView,
diff --git a/deployments/api/tests/db/test_resource_actions.py b/deployments/api/tests/db/test_resource_actions.py
index edac7208..e7eea148 100644
--- a/deployments/api/tests/db/test_resource_actions.py
+++ b/deployments/api/tests/db/test_resource_actions.py
@@ -1092,6 +1092,115 @@ async def test_override_is_scoped_to_its_resource(
assert (await resource_actions.get(session, untouched)).view.name == "GEM Name"
+class TestFieldSourceValues:
+ """Per-field source-value listing, best-priority first."""
+
+ async def _seed(self, session, user) -> int:
+ # gem(2) outranks wm(3) by default; llm has no state_province.
+ return await _create_resource_with_sources(
+ session,
+ user,
+ {"source": "gem", "name": "GEM Name", "country": "USA", "basin": "Alpha"},
+ {"source": "wm", "name": "WM Name", "country": "CAN", "basin": "Beta"},
+ {"source": "llm", "name": "LLM Name", "country": "GBR"},
+ )
+
+ @pytest.mark.anyio
+ async def test_lists_values_sorted_by_priority_with_winner_first(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ session = seeded_integration_session
+ rid = await self._seed(session, test_user)
+
+ rows = await resource_actions.field_source_values(session, rid, "name")
+
+ # gem(2) < wm(3) < llm(4): winner (gem) first, then in priority order.
+ assert [(r.source, r.value) for r in rows] == [
+ ("gem", "GEM Name"),
+ ("wm", "WM Name"),
+ ("llm", "LLM Name"),
+ ]
+ assert [r.priority for r in rows] == sorted(r.priority for r in rows)
+
+ @pytest.mark.anyio
+ async def test_override_reorders_field_values(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ session = seeded_integration_session
+ rid = await self._seed(session, test_user)
+ session.add(
+ OGFieldResourceSourcePriority(resource_id=rid, source="wm", priority=1)
+ )
+ await session.flush()
+
+ rows = await resource_actions.field_source_values(session, rid, "basin")
+
+ # wm promoted above gem; llm has no basin so it is omitted.
+ assert [(r.source, r.value) for r in rows] == [
+ ("wm", "Beta"),
+ ("gem", "Alpha"),
+ ]
+
+ @pytest.mark.anyio
+ async def test_omits_sources_without_a_value(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ session = seeded_integration_session
+ rid = await self._seed(session, test_user)
+
+ rows = await resource_actions.field_source_values(
+ session, rid, "state_province"
+ )
+
+ assert rows == []
+
+ @pytest.mark.anyio
+ async def test_unlicensed_sources_are_excluded(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ session = seeded_integration_session
+ rid = await self._seed(session, test_user)
+
+ rows = await resource_actions.field_source_values(
+ session, rid, "name", licensed_sources=["gem"]
+ )
+
+ assert [r.source for r in rows] == ["gem"]
+
+ @pytest.mark.anyio
+ async def test_unknown_field_is_rejected(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ session = seeded_integration_session
+ rid = await self._seed(session, test_user)
+
+ with pytest.raises(HTTPException) as exc:
+ await resource_actions.field_source_values(session, rid, "not_a_field")
+ assert exc.value.status_code == 422
+
+ @pytest.mark.anyio
+ async def test_missing_resource_is_404(
+ self,
+ seeded_integration_session: AsyncSession,
+ test_user: User,
+ ):
+ with pytest.raises(HTTPException) as exc:
+ await resource_actions.field_source_values(
+ seeded_integration_session, 9_999_999, "name"
+ )
+ assert exc.value.status_code == 404
+
+
class TestResourceDetailCoalescing:
"""Detail-path (``resource_actions.get``) coalescing behavior.
diff --git a/deployments/seed/data/006-source-values-demo.json b/deployments/seed/data/006-source-values-demo.json
new file mode 100644
index 00000000..8ad7e9cf
--- /dev/null
+++ b/deployments/seed/data/006-source-values-demo.json
@@ -0,0 +1,56 @@
+[
+ {
+ "id": 0,
+ "source_data": [
+ {
+ "source": "rmi",
+ "name": "Coalescing Demo Field",
+ "country": "USA",
+ "location_type": "Onshore",
+ "discovery_year": 1969
+ },
+ {
+ "source": "gem",
+ "name": "Coalescing Demo (GEM A)",
+ "country": "USA",
+ "basin": "Permian Basin",
+ "latitude": 31.9,
+ "longitude": -102.1,
+ "production_conventionality": "Unconventional",
+ "discovery_year": 1970,
+ "production_start_year": 1975
+ },
+ {
+ "source": "gem",
+ "name": "Coalescing Demo (GEM B)",
+ "country": "CAN",
+ "basin": "Williston Basin",
+ "production_conventionality": "Conventional",
+ "discovery_year": 1971
+ },
+ {
+ "source": "wm",
+ "name": "Coalescing Demo (WM)",
+ "country": "GBR",
+ "basin": "North Sea",
+ "state_province": "Texas",
+ "latitude": 31.85,
+ "longitude": -102.2,
+ "location_type": "Offshore",
+ "primary_hydrocarbon_group": "Light Oil",
+ "discovery_year": 1968,
+ "production_start_year": 1974,
+ "field_status": "Producing"
+ },
+ {
+ "source": "llm",
+ "name": "Coalescing Demo (LLM)",
+ "country": null,
+ "basin": "Ghawar (guess)",
+ "primary_hydrocarbon_group": "Medium Oil",
+ "field_status": "Non-Producing"
+ }
+ ],
+ "constituents": []
+ }
+]
diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx
index 0b7f023c..de174bed 100644
--- a/deployments/stitch-frontend/src/components/FieldCard.jsx
+++ b/deployments/stitch-frontend/src/components/FieldCard.jsx
@@ -1,3 +1,4 @@
+import { useId } from "react";
import {
SOURCE_COLORS,
SOURCE_LABELS,
@@ -7,7 +8,19 @@ import {
// Used to display a single field value in a card, as seen in the ResourceDetailPage.
// Pass `source` (one of "gem" | "wm" | "rmi" | "llm") to tint the left border by data source.
-export function FieldCard({ label, value, source }) {
+// Pass `expandable` + `isOpen` + `onToggle` to make the value a toggle button; `children`
+// (e.g. an "All sources" panel) render below the box while open. The card is presentational:
+// the parent owns open state and any data fetching.
+export function FieldCard({
+ label,
+ value,
+ source,
+ expandable = false,
+ isOpen = false,
+ onToggle,
+ children,
+}) {
+ const panelId = useId();
const display =
value === null || value === undefined || value === ""
? null
@@ -18,30 +31,61 @@ export function FieldCard({ label, value, source }) {
? (SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL)
: null;
+ const boxContent = (
+ <>
+
+
+ {display ?? —}
+
+ {expandable && (
+
+ {isOpen ? "▾" : "▸"}
+
+ )}
+
+ {sourceLabel && (
+
+
+ Source: {sourceLabel}
+
+ )}
+ >
+ );
+
+ const boxClasses =
+ "min-h-[2.5rem] w-full rounded-md border border-line bg-panel px-3 py-2 text-left text-sm text-ink";
+
return (
{label}
-
-
- {display ??
—}
+ {expandable ? (
+
+ ) : (
+
+ {boxContent}
- {sourceLabel && (
-
-
- Source: {sourceLabel}
-
- )}
-
+ )}
+ {expandable && isOpen &&
{children}
}
);
}
diff --git a/deployments/stitch-frontend/src/components/FieldCard.test.jsx b/deployments/stitch-frontend/src/components/FieldCard.test.jsx
index 1a825509..d064ea91 100644
--- a/deployments/stitch-frontend/src/components/FieldCard.test.jsx
+++ b/deployments/stitch-frontend/src/components/FieldCard.test.jsx
@@ -1,5 +1,6 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { FieldCard, FieldGrid } from "./FieldCard";
import {
SOURCE_COLORS,
@@ -82,6 +83,55 @@ describe("FieldCard", () => {
});
});
+describe("FieldCard expandable behavior", () => {
+ it("renders a plain box (no button) when not expandable", () => {
+ render(
);
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("renders a toggle button reflecting isOpen when expandable", () => {
+ const { rerender } = render(
+
,
+ );
+ const toggle = screen.getByRole("button");
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+
+ rerender(
);
+ expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("calls onToggle when the value button is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggle = vi.fn();
+ render(
+
,
+ );
+ await user.click(screen.getByRole("button"));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders children only when expandable and open", () => {
+ const { rerender } = render(
+
+ panel body
+ ,
+ );
+ expect(screen.queryByText("panel body")).not.toBeInTheDocument();
+
+ rerender(
+
+ panel body
+ ,
+ );
+ expect(screen.getByText("panel body")).toBeInTheDocument();
+ });
+});
+
describe("FieldGrid", () => {
it("renders its children", () => {
render(
diff --git a/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx b/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx
new file mode 100644
index 00000000..4770fa9f
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/ResourceFieldCard.jsx
@@ -0,0 +1,101 @@
+import { useState } from "react";
+import { FieldCard } from "./FieldCard";
+import { useFieldSourceValues } from "../hooks/useResources";
+import {
+ SOURCE_COLORS,
+ SOURCE_LABELS,
+ UNKNOWN_SOURCE_LABEL,
+ DEFAULT_FIELD_COLOR,
+} from "../constants/sourceMeta";
+
+function SourceValueRow({ source, value, id, isWinner }) {
+ const barColor = SOURCE_COLORS[source] ?? DEFAULT_FIELD_COLOR;
+ const sourceLabel = SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL;
+ const meta =
+ id !== null && id !== undefined ? `${sourceLabel} · #${id}` : sourceLabel;
+ // Quote strings to set text values apart; render numbers/booleans bare.
+ const display = typeof value === "string" ? `"${value}"` : String(value);
+
+ return (
+
+ );
+}
+
+function FieldSourcesPanel({ isLoading, isError, sources }) {
+ return (
+
+ {/* Reserved header — future controls will live alongside this label. */}
+
+ All sources
+
+ {isLoading &&
Loading sources…
}
+ {isError && (
+
Failed to load source values.
+ )}
+ {!isLoading && !isError && sources.length === 0 && (
+
+ No source values for this field.
+
+ )}
+ {!isLoading && !isError && sources.length > 0 && (
+
+ {/* The endpoint returns best-priority first, so index 0 is the winner. */}
+ {sources.map((row, idx) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// A FieldCard for the resource detail page: clicking a populated value lazily
+// fetches every source's value for that field and shows them in priority order.
+export default function ResourceFieldCard({
+ endpoint,
+ resourceId,
+ fieldKey,
+ label,
+ value,
+ source,
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const expandable = value !== null && value !== undefined && value !== "";
+ const { data, isLoading, isError } = useFieldSourceValues(
+ endpoint,
+ resourceId,
+ fieldKey,
+ isOpen && expandable,
+ );
+
+ return (
+
setIsOpen((current) => !current)}
+ >
+
+
+ );
+}
diff --git a/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx b/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx
new file mode 100644
index 00000000..f28b7010
--- /dev/null
+++ b/deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ResourceFieldCard from "./ResourceFieldCard";
+import { useFieldSourceValues } from "../hooks/useResources";
+import { SOURCE_LABELS } from "../constants/sourceMeta";
+
+vi.mock("../hooks/useResources", () => ({
+ useFieldSourceValues: vi.fn(),
+}));
+
+function renderCard(props = {}) {
+ return render(
+
,
+ );
+}
+
+describe("ResourceFieldCard", () => {
+ beforeEach(() => {
+ useFieldSourceValues.mockReset();
+ useFieldSourceValues.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ });
+
+ it("is not expandable when the value is empty", () => {
+ renderCard({ value: null });
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("does not enable the query until the panel is opened", () => {
+ renderCard();
+ // Closed on first render → query disabled.
+ expect(useFieldSourceValues).toHaveBeenLastCalledWith(
+ "oil-gas-fields",
+ 42,
+ "basin",
+ false,
+ );
+ });
+
+ it("enables the query and shows the panel when opened", async () => {
+ const user = userEvent.setup();
+ useFieldSourceValues.mockReturnValue({
+ data: [
+ { source: "wm", id: 20, value: "Foo Basin", priority: 1 },
+ { source: "gem", id: 10, value: "Bar Basin", priority: 2 },
+ ],
+ isLoading: false,
+ isError: false,
+ });
+ renderCard();
+
+ await user.click(screen.getByRole("button"));
+
+ expect(useFieldSourceValues).toHaveBeenLastCalledWith(
+ "oil-gas-fields",
+ 42,
+ "basin",
+ true,
+ );
+ expect(screen.getByText("All sources")).toBeInTheDocument();
+ expect(screen.getByText('"Foo Basin"')).toBeInTheDocument();
+ expect(screen.getByText('"Bar Basin"')).toBeInTheDocument();
+ expect(screen.getByText(`${SOURCE_LABELS.wm} · #20`)).toBeInTheDocument();
+ });
+
+ it("highlights the first (winning) row and diminishes the rest", async () => {
+ const user = userEvent.setup();
+ useFieldSourceValues.mockReturnValue({
+ data: [
+ { source: "wm", id: 20, value: "Foo Basin", priority: 1 },
+ { source: "gem", id: 10, value: "Bar Basin", priority: 2 },
+ ],
+ isLoading: false,
+ isError: false,
+ });
+ renderCard();
+ await user.click(screen.getByRole("button"));
+
+ expect(screen.getByText('"Foo Basin"').closest(".border-l-4")).toHaveClass(
+ "bg-surface",
+ );
+ expect(screen.getByText('"Bar Basin"').closest(".border-l-4")).toHaveClass(
+ "bg-panel",
+ );
+ });
+
+ it("shows a loading state while fetching", async () => {
+ const user = userEvent.setup();
+ useFieldSourceValues.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+ renderCard();
+ await user.click(screen.getByRole("button"));
+ expect(screen.getByText("Loading sources…")).toBeInTheDocument();
+ });
+
+ it("shows an error state when the fetch fails", async () => {
+ const user = userEvent.setup();
+ useFieldSourceValues.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ });
+ renderCard();
+ await user.click(screen.getByRole("button"));
+ expect(
+ screen.getByText("Failed to load source values."),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js
index 1b4de26d..a990b884 100644
--- a/deployments/stitch-frontend/src/hooks/useResources.js
+++ b/deployments/stitch-frontend/src/hooks/useResources.js
@@ -87,6 +87,19 @@ function useSourceDetailReal(
});
}
+function useFieldSourceValuesReal(
+ endpoint = "oil-gas-fields",
+ id,
+ field,
+ enabled = false,
+) {
+ const config = useConfig();
+ return useAuthenticatedQuery({
+ ...resourceQueries.fieldSources(config, endpoint, id, field),
+ enabled: enabled && Boolean(field) && Number.isFinite(id),
+ });
+}
+
function useMergeCandidatesReal(endpoint = "oil-gas-fields", enabled = false) {
const config = useConfig();
return useAuthenticatedQuery({
@@ -287,6 +300,22 @@ function useSourceDetailMock(
});
}
+function useFieldSourceValuesMock(
+ endpoint = "oil-gas-fields",
+ id,
+ field,
+ enabled = false,
+) {
+ // Mock mode has no backend; the panel degrades to an empty state, matching
+ // useSourceDetailMock. Real behavior is exercised against the API. Guard `id`
+ // the same way the real hook does so the query key stays stable.
+ return useQuery({
+ queryKey: resourceKeys.fieldSources(endpoint, id, field),
+ queryFn: () => Promise.resolve([]),
+ enabled: enabled && Boolean(field) && Number.isFinite(id),
+ });
+}
+
function useMergeCandidatesMock(endpoint = "oil-gas-fields", enabled = false) {
return useQuery({
queryKey: resourceKeys.mergeCandidates(endpoint),
@@ -343,6 +372,9 @@ export const useResourceDetail = USE_MOCK_DATA
export const useSourceDetail = USE_MOCK_DATA
? useSourceDetailMock
: useSourceDetailReal;
+export const useFieldSourceValues = USE_MOCK_DATA
+ ? useFieldSourceValuesMock
+ : useFieldSourceValuesReal;
export const useMergeCandidates = USE_MOCK_DATA
? useMergeCandidatesMock
: useMergeCandidatesReal;
diff --git a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx
index 6354c680..01d67100 100644
--- a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx
+++ b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx
@@ -12,6 +12,7 @@ import {
import SourceMixBar from "../components/SourceMixBar";
import SectionHeader from "../components/SectionHeader";
import { FieldCard, FieldGrid } from "../components/FieldCard";
+import ResourceFieldCard from "../components/ResourceFieldCard";
import { SOURCE_LABELS } from "../constants/sourceMeta";
import Button from "../components/Button";
import {
@@ -578,8 +579,11 @@ export default function ResourceDetailPage() {
{IDENTITY_FIELDS.map((key) => (
-
{PRODUCTION_FIELDS.map((key) => (
- {
refetch: vi.fn(),
});
vi.mocked(useSourceDetail).mockReturnValue(defaultSourceDetailHookReturn);
+ vi.mocked(useFieldSourceValues).mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ });
vi.stubGlobal("crypto", {
randomUUID: () => "persist-uuid-123",
});
diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js
index 63bc3d4d..ade766c2 100644
--- a/deployments/stitch-frontend/src/queries/api.js
+++ b/deployments/stitch-frontend/src/queries/api.js
@@ -65,6 +65,25 @@ export async function getResourceDetail(
return data;
}
+export async function getFieldSourceValues(
+ config,
+ id,
+ field,
+ fetcher,
+ endpoint = "oil-gas-fields",
+) {
+ const url = `${config.apiBaseUrl}/${endpoint}/${id}/fields/${encodeURIComponent(
+ field,
+ )}/sources`;
+ const response = await fetcher(url);
+ if (!response.ok) {
+ const error = new Error(`HTTP error! status: ${response.status}`);
+ error.status = response.status;
+ throw error;
+ }
+ return await response.json();
+}
+
export async function createLLMSuggestion(
config,
id,
diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js
index 55b4de94..9ebe280b 100644
--- a/deployments/stitch-frontend/src/queries/resources.js
+++ b/deployments/stitch-frontend/src/queries/resources.js
@@ -3,6 +3,7 @@ import {
getResource,
getResources,
getResourceDetail,
+ getFieldSourceValues,
getMergeCandidates,
getMergeCandidate,
getMergeCandidatePreview,
@@ -33,6 +34,11 @@ export const resourceKeys = {
...resourceKeys.details(endpoint),
id,
],
+ fieldSources: (endpoint = "oil-gas-fields", id, field) => [
+ ...resourceKeys.detail(endpoint, id),
+ "field-sources",
+ field,
+ ],
views: (endpoint = "resources") => [...resourceKeys.all(endpoint), "view"],
view: (endpoint = "resources", id) => [...resourceKeys.views(endpoint), id],
@@ -100,6 +106,14 @@ export const resourceQueries = {
enabled: false,
}),
+ fieldSources: (config, endpoint = "oil-gas-fields", id, field) => ({
+ queryKey: resourceKeys.fieldSources(endpoint, id, field),
+ queryFn: (fetcher) =>
+ getFieldSourceValues(config, id, field, fetcher, endpoint),
+ enabled: false,
+ staleTime: DEFAULT_STALE_TIME,
+ }),
+
view: (config, endpoint = "resources", id) => ({
queryKey: resourceKeys.view(endpoint, id),
queryFn: (fetcher) => getResource(config, id, fetcher, endpoint),
diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
index 49fbf477..ad778640 100644
--- a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
+++ b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
@@ -21,6 +21,7 @@
__all__ = [
"OGFieldSource",
"OGFieldSourceView",
+ "OGFieldSourceValueView",
"OGFieldResource",
"OGFieldResourceView",
"OGFieldView",
@@ -115,6 +116,22 @@ def _normalize_source_data(cls, value):
return normalized
+class OGFieldSourceValueView(BaseModel):
+ """One source's value for a single field, with its effective priority.
+
+ Returned by the per-field source-values endpoint. ``priority`` is the
+ effective per-resource ranking (lower ``priority`` value = higher priority);
+ the winning value is the entry with the lowest ``priority`` value (ties
+ broken by ``id``). When priority becomes resource/field-scoped, only that
+ resolution changes -- this shape is stable.
+ """
+
+ source: OGSISrcKey
+ id: int
+ value: Any
+ priority: int
+
+
class OGFieldResource(Resource[int, OGFieldSource]):
provenance: dict[str, tuple[Any, OGSISrcKey, int] | None] = Field(
default_factory=dict