Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions deployments/api/src/stitch/api/db/init_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ def create_seed_sources():
GemSourceModel.from_entity(
GemData(name="North Sea Platform", country="GBR", lat=57.5, lon=1.5)
),
GemSourceModel.from_entity(
GemData(name="Merge Target Field", country="YYZ", lat=13.37, lon=13.37)
),
]
for i, src in enumerate(gem_sources, start=1):
src.id = i
Expand All @@ -294,6 +297,13 @@ def create_seed_sources():
WMSourceModel.from_entity(
WMData(field_name="Ghawar Field", field_country="SAU", production=500000.0)
),
WMSourceModel.from_entity(
WMData(
field_name="Merge Consumed Field",
field_country="YYZ",
production=1337.0,
)
),
]
for i, src in enumerate(wm_sources, start=1):
src.id = i
Expand Down Expand Up @@ -324,6 +334,8 @@ def create_seed_resources(user: UserEntity) -> list[ResourceModel]:
resources = [
ResourceModel.create(user, name="Multi-Source Asset", country="USA"),
ResourceModel.create(user, name="Single Source Asset", country="GBR"),
ResourceModel.create(user, name="Merge Demo", country="YYZ"),
ResourceModel.create(user, name="Merge Demo", country="YYZ"),
]
for i, res in enumerate(resources, start=1):
res.id = i
Expand All @@ -342,6 +354,8 @@ def create_seed_memberships(
MembershipModel.create(user, resources[0], "wm", wm_sources[0].id),
MembershipModel.create(user, resources[0], "rmi", rmi_sources[0].id),
MembershipModel.create(user, resources[1], "gem", gem_sources[1].id),
MembershipModel.create(user, resources[2], "gem", gem_sources[2].id),
MembershipModel.create(user, resources[3], "wm", wm_sources[2].id),
]
for i, mem in enumerate(memberships, start=1):
mem.id = i
Expand Down
49 changes: 49 additions & 0 deletions deployments/api/src/stitch/api/db/resource_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,52 @@ async def create_source_data(session: AsyncSession, data: CreateSourceData):
rmi={rmi.id: rmi.as_entity() for rmi in rmis},
cc={cc.id: cc.as_entity() for cc in ccs},
)


async def merge_resources(
session: AsyncSession,
user: CurrentUser,
ids: Sequence[int],
) -> Resource:
"""
Stub "merge" behavior:
- Treat ids[0] as the canonical/target resource.
- Update all resources in ids[1:] to have repointed_id = ids[0].

NOTE: This only updates the resource table repointing field (no membership/source consolidation).
"""
if not ids:
raise HTTPException(status_code=400, detail="No resource IDs provided.")
# preserve order but drop duplicates
unique_ids = list(dict.fromkeys(ids))
if len(unique_ids) < 2:
raise HTTPException(
status_code=400, detail="Provide at least 2 unique resource IDs."
)

target_id = unique_ids[0]
other_ids = unique_ids[1:]

# Ensure target exists
target_model = await session.get(ResourceModel, target_id)
if target_model is None:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No Resource with id `{target_id}` found.",
)

# Ensure all others exist, then repoint them
for rid in other_ids:
model = await session.get(ResourceModel, rid)
if model is None:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No Resource with id `{rid}` found.",
)
model.repointed_id = target_id

await session.flush()

# Return the canonical resource entity
await session.refresh(target_model, ["memberships"])
return await resource_model_to_entity(session, target_model)
46 changes: 45 additions & 1 deletion deployments/api/src/stitch/api/routers/resources.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import logging

from collections.abc import Sequence

from fastapi import APIRouter
from fastapi import APIRouter, HTTPException

from pydantic import BaseModel

from stitch.api.db import resource_actions
from stitch.api.db.config import UnitOfWorkDep
from stitch.api.auth import CurrentUser
from stitch.api.entities import CreateResource, Resource

logger = logging.getLogger(__name__)

router = APIRouter(
prefix="/resources",
Expand All @@ -33,3 +38,42 @@ async def create_resource(
return await resource_actions.create(
session=uow.session, user=user, resource=resource_in
)


class MergeRequest(BaseModel):
resource_ids: list[int]


@router.post("/merge", response_model=Resource)
async def merge_resources_endpoint(
*, uow: UnitOfWorkDep, user: CurrentUser, payload: MergeRequest
) -> Resource:
"""
Merge multiple resources into one (STUB):
repoint resource_ids[1:] -> resource_ids[0]
"""
ids = payload.resource_ids
# preserve order but drop duplicates
unique_ids = list(dict.fromkeys(ids))
if len(unique_ids) < 2:
raise HTTPException(
status_code=400, detail="Provide at least 2 unique resource IDs"
)

logger.info(
"Merge requested by user=%s for resource_ids=%s",
getattr(user, "sub", "<anon>"),
unique_ids,
)

try:
return await resource_actions.merge_resources(
session=uow.session,
user=user,
ids=unique_ids,
)
except HTTPException:
raise
except Exception as exc:
logger.exception("Error while merging resources %s: %s", unique_ids, exc)
raise HTTPException(status_code=500, detail="Internal error during merge")
25 changes: 25 additions & 0 deletions deployments/entity-linkage/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM python:3.12-slim-trixie

ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Small, self-contained runtime (not tied to the uv workspace/lock).
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir httpx==0.28.1

COPY deployments/entity-linkage/entity_linkage.py /app/entity_linkage.py

# Defaults (override via compose/env)
ENV API_URL="http://api:8000" \
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image default API_URL is http://api:8000, but the FastAPI app is mounted under /api/v1 (see stitch.api.main where APIRouter(prefix="/api/v1") is used). With the current default, this container will GET /resources/ and fail. Consider changing the default to include /api/v1 (or making the script tolerant by probing both base paths).

Suggested change
ENV API_URL="http://api:8000" \
ENV API_URL="http://api:8000/api/v1" \

Copilot uses AI. Check for mistakes.
ENTITY_LINKAGE_MODE="oneshot" \
ENTITY_LINKAGE_SLEEP_SECONDS="10" \
ENTITY_LINKAGE_TIMEOUT_SECONDS="10" \
ENTITY_LINKAGE_MAX_RETRIES="60" \
ENTITY_LINKAGE_RETRY_BACKOFF_SECONDS="1" \
ENTITY_LINKAGE_LOG_LEVEL="INFO"

CMD ["python", "/app/entity_linkage.py"]
21 changes: 21 additions & 0 deletions deployments/entity-linkage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# entity-linkage (deployment)

A small client container that:
1) makes a GET request to the Stitch API
2) makes a POST request to the Stitch API

Note that for now, it does not terminate (runs in loop looking for resources to
merge)

Note that the the merging logic is trivial at this point (exact match on
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "Note that the the" has a duplicated "the".

Suggested change
Note that the the merging logic is trivial at this point (exact match on
Note that the merging logic is trivial at this point (exact match on

Copilot uses AI. Check for mistakes.
resource name and country).

## Configuration

- `API_URL` (required)
- Example: `http://api:8000`
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README’s API_URL example (http://api:8000) doesn’t match the actual API base path, which is mounted under /api/v1 in stitch.api.main. Using the README example will cause this service to call /resources/ and fail. Update the example to include /api/v1 (or clarify what API_URL should point at).

Suggested change
- Example: `http://api:8000`
- Example: `http://api:8000/api/v1`

Copilot uses AI. Check for mistakes.
- `ENTITY_LINKAGE_SLEEP_SECONDS` (default: `10`)
- `ENTITY_LINKAGE_TIMEOUT_SECONDS` (default: `10`)
- `ENTITY_LINKAGE_MAX_RETRIES` (default: `60`)
- `ENTITY_LINKAGE_RETRY_BACKOFF_SECONDS` (default: `1`)
- `ENTITY_LINKAGE_LOG_LEVEL` (default: `INFO`)
Loading