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
215 changes: 214 additions & 1 deletion src/backend/src/controller/semantic_models_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4364,9 +4364,222 @@ def retire_concept(self, concept_iri: str, retired_by: Optional[str] = None) ->
"status": "retired",
}

# -- P1-6: reference-reconciliation worklist (split 1->N / merge N->1) -----

def list_references(self, concept_iri: str) -> Dict[str, Any]:
"""Itemized form of the retire gate (API contract §5b).

Returns the SAME set ``reference_count`` counts, broken out:
- ``asset_refs``: entity_semantic_links rows (physical UC/asset refs);
- ``concept_refs``: OTHER concepts pointing at this iri via
skos:broader/narrower/related or rdfs:subClassOf in the served graph;
- ``successors``: the dct:isReplacedBy targets already recorded by
``deprecate_concept`` (lineage, NOT part of the count).

``count`` == len(asset_refs) + len(concept_refs) == reference_count(iri),
so the itemized worklist and the retire gate never disagree. Every entry
carries a human ``label`` so the Simple view never renders a raw IRI.
"""
from src.repositories.semantic_links_repository import entity_semantic_links_repo

concept = self.get_concept(concept_iri)
label = (concept.get("label") if concept else None) or concept_iri

# Asset refs: one row per entity_semantic_links row for this iri.
asset_refs = []
for row in entity_semantic_links_repo.list_for_iri(self._db, concept_iri):
entity_label = row.label
if not entity_label:
tail = (row.entity_id or "").split("#")[-1].split("/")[-1]
entity_label = tail.split(".")[-1] if tail else row.entity_id
asset_refs.append({
"link_id": str(row.id),
"entity_type": row.entity_type,
"entity_id": row.entity_id,
"entity_label": entity_label,
})

# Concept->concept refs: this iri appears as the OBJECT of a relationship
# predicate from some OTHER subject (mirrors reference_count exactly).
concept_uri = URIRef(concept_iri)
ref_predicates = (
(SKOS.broader, "broader"),
(SKOS.narrower, "narrower"),
(SKOS.related, "related"),
(RDFS.subClassOf, "subClassOf"),
)
concept_refs = []
for pred, pred_name in ref_predicates:
for subj in self._graph.subjects(pred, concept_uri):
if str(subj) == concept_iri: # self-reference doesn't count
continue
other = self.get_concept(str(subj))
concept_refs.append({
"iri": str(subj),
"label": (other.get("label") if other else None) or str(subj),
"predicate": pred_name,
})

# Successors: recorded isReplacedBy targets (lineage, not part of count).
successors = []
for o in self._graph.objects(concept_uri, DCT.isReplacedBy):
succ = self.get_concept(str(o))
successors.append({
"iri": str(o),
"label": (succ.get("label") if succ else None) or str(o),
})

return {
"iri": concept_iri,
"label": label,
"count": len(asset_refs) + len(concept_refs),
"asset_refs": asset_refs,
"concept_refs": concept_refs,
"successors": successors,
}

def repoint_reference(
self,
link_id: str,
from_iri: str,
to_iri: str,
actor: Optional[str] = None,
) -> Dict[str, Any]:
"""Move ONE entity_semantic_links row from ``from_iri`` to ``to_iri`` (§5b).

The unit action that composes for both split and merge. Reuses the
SemanticLinksManager remove+add path so the graph side-effects fire
(``remove_entity_semantic_link_from_graph`` / ``add_...`` + cache
invalidation) — the served graph stays fresh, no ref is silently dropped.

- No-op if ``from_iri == to_iri`` (idempotent-safe).
- Raises ValueError (→404) if the link is missing or does not currently
point at ``from_iri``.
"""
from uuid import UUID
from src.repositories.semantic_links_repository import entity_semantic_links_repo
from src.controller.semantic_links_manager import SemanticLinksManager
from src.models.semantic_links import EntitySemanticLinkCreate

try:
link_uuid = UUID(str(link_id))
except (ValueError, AttributeError):
raise ValueError(f"Link not found: {link_id}")

row = entity_semantic_links_repo.get(self._db, id=link_uuid)
if row is None:
raise ValueError(f"Link not found: {link_id}")
if row.iri != from_iri:
raise ValueError(
f"Link {link_id} does not point at {from_iri} (points at {row.iri})"
)

# Idempotent no-op: already at the target.
if from_iri == to_iri:
to_concept = self.get_concept(to_iri)
return {
"link_id": str(link_id),
"to_iri": to_iri,
"to_label": (to_concept.get("label") if to_concept else None) or to_iri,
}

# Editable-scheme gate: the target concept's collection must be editable.
to_concept_pre = self.get_concept(to_iri)
if not to_concept_pre:
raise ValueError(f"Target concept not found: {to_iri}")
to_collection_iri = to_concept_pre.get("source_context")
if to_collection_iri:
to_collection = self.get_collection(to_collection_iri)
if to_collection and not to_collection.get("is_editable"):
raise ValueError(f"Target collection is not editable: {to_collection_iri}")

entity_id, entity_type = row.entity_id, row.entity_type

links_manager = SemanticLinksManager(self._db, semantic_models_manager=self)
# remove old (graph side-effect + cache invalidation), then add new.
# Pass the UUID object (not the str) so the GUID column's SQLite emulation
# can bind it — the delete route's string path only works on Postgres.
#
# Safety: this is remove-then-add across two operations (no single txn).
# The whole point of this feature is "never silently drop a reference",
# so if the add fails after the remove we restore the original link
# (pointing back at from_iri) and re-raise, rather than leave the ref
# orphaned. A repoint either fully moves or fully rolls back.
links_manager.remove(link_uuid, removed_by=actor)
try:
created = links_manager.add(
EntitySemanticLinkCreate(entity_id=entity_id, entity_type=entity_type, iri=to_iri),
created_by=actor,
)
except Exception:
logger.error(
"repoint_reference: add to %s failed after removing link from %s; "
"restoring the original reference", to_iri, from_iri, exc_info=True,
)
try:
links_manager.add(
EntitySemanticLinkCreate(entity_id=entity_id, entity_type=entity_type, iri=from_iri),
created_by=actor,
)
except Exception:
logger.error("repoint_reference: restore of original link ALSO failed", exc_info=True)
raise

to_concept = self.get_concept(to_iri)
return {
"link_id": created.id,
"to_iri": to_iri,
"to_label": (to_concept.get("label") if to_concept else None) or to_iri,
}

def merge_concepts(
self,
source_iris: List[str],
target_iri: str,
repoint_refs: bool = True,
actor: Optional[str] = None,
) -> Dict[str, Any]:
"""Merge N sources into one target (API contract §5b, N->1 convenience).

For each source: if ``repoint_refs``, repoint ALL its asset refs to the
target; then ``deprecate_concept(source, replaced_by=[target])``. This is
purely a composition of the repoint + deprecate primitives — it invents NO
new lineage predicates. Sources become resolvable deprecated tombstones-in-
waiting (retire still gated on 0 refs).
"""
from src.repositories.semantic_links_repository import entity_semantic_links_repo

merged = []
for source_iri in source_iris:
if source_iri == target_iri:
continue # a concept can't merge into itself
refs_repointed = 0
if repoint_refs:
# Snapshot the link ids first; repoint mutates the row set.
link_ids = [
str(r.id)
for r in entity_semantic_links_repo.list_for_iri(self._db, source_iri)
]
for lid in link_ids:
self.repoint_reference(lid, source_iri, target_iri, actor=actor)
refs_repointed += 1
self.deprecate_concept(
concept_iri=source_iri,
replaced_by=[target_iri],
deprecated_by=actor,
)
merged.append({"source_iri": source_iri, "refs_repointed": refs_repointed})

target = self.get_concept(target_iri)
return {
"target_iri": target_iri,
"target_label": (target.get("label") if target else None) or target_iri,
"merged": merged,
}

def delete_concept(self, concept_iri: str, deleted_by: Optional[str] = None) -> bool:
"""Delete a concept.

Only concepts with status 'draft' can be deleted.
Published concepts should be deprecated instead.
"""
Expand Down
158 changes: 158 additions & 0 deletions src/backend/src/routes/semantic_models_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,72 @@ class RetireConceptResponse(BaseModel):
status: str


# --- Reference-reconciliation worklist models (API contract §5b, split + merge) ---
class AssetRefEntry(BaseModel):
"""One entity_semantic_links row referencing a concept."""
link_id: str
entity_type: str
entity_id: str
entity_label: Optional[str] = None


class ConceptRefEntry(BaseModel):
"""Another concept pointing at this concept in the served graph."""
iri: str
label: str
predicate: str


class SuccessorEntry(BaseModel):
"""A recorded isReplacedBy target (successor) of this concept."""
iri: str
label: str


class ConceptReferencesResponse(BaseModel):
"""GET /semantic-models/concepts/references — itemized retire-gate set (§5b).

``count`` equals reference_count(iri): the same asset + concept->concept set
the retire gate counts. label present so Simple never shows a raw IRI."""
iri: str
label: str
count: int
asset_refs: List[AssetRefEntry] = Field(default_factory=list)
concept_refs: List[ConceptRefEntry] = Field(default_factory=list)
successors: List[SuccessorEntry] = Field(default_factory=list)


class RepointReferenceRequest(BaseModel):
"""POST /semantic-models/concepts/references/repoint."""
link_id: str = Field(..., min_length=1)
from_iri: str = Field(..., min_length=1)
to_iri: str = Field(..., min_length=1)


class RepointReferenceResponse(BaseModel):
link_id: str
to_iri: str
to_label: Optional[str] = None


class MergeConceptsRequest(BaseModel):
"""POST /semantic-models/concepts/merge (N->1 convenience: repoint + deprecate)."""
source_iris: List[str] = Field(..., min_length=1)
target_iri: str = Field(..., min_length=1)
repoint_refs: bool = Field(True, description="Repoint each source's asset refs to target before deprecating")


class MergedSourceEntry(BaseModel):
source_iri: str
refs_repointed: int


class MergeConceptsResponse(BaseModel):
target_iri: str
target_label: Optional[str] = None
merged: List[MergedSourceEntry] = Field(default_factory=list)


# --- Graph freshness (API contract §6, signed off) ---
class GraphFreshnessResponse(BaseModel):
"""Served in-memory graph freshness. UI shows 'last refreshed HH:MM, N
Expand Down Expand Up @@ -940,6 +1006,98 @@ async def retire_concept(
raise HTTPException(status_code=500, detail="Failed to retire concept")


# --- Reference-reconciliation worklist routes (API contract §5b) ---
@router.get(
'/semantic-models/concepts/references',
response_model=ConceptReferencesResponse,
)
async def list_concept_references(
concept_iri: str = Query(..., alias="iri", min_length=1, description="Concept IRI"),
manager: SemanticModelsManager = Depends(get_semantic_models_manager),
_: bool = Depends(PermissionChecker('semantic-models', FeatureAccessLevel.READ_ONLY)),
) -> ConceptReferencesResponse:
"""Itemized references to a concept — the split/merge worklist source (§5b).

Same set the retire gate counts (asset_refs + concept_refs), plus the
recorded successors. ``count`` equals reference-count.
"""
try:
return ConceptReferencesResponse(**manager.list_references(concept_iri))
except Exception:
logger.error("Error listing references for %s", concept_iri, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to list concept references")


@router.post(
'/semantic-models/concepts/references/repoint',
response_model=RepointReferenceResponse,
)
async def repoint_concept_reference(
body: RepointReferenceRequest,
current_user: CurrentUserDep,
manager: SemanticModelsManager = Depends(get_semantic_models_manager),
_: bool = Depends(PermissionChecker('semantic-models', FeatureAccessLevel.READ_WRITE)),
) -> RepointReferenceResponse:
"""Move one entity_semantic_links row from from_iri to to_iri (§5b).

Remove old + add new via the links manager so graph side-effects fire; no ref
silently dropped. Idempotent if from_iri == to_iri. 404 if the link is missing
or does not point at from_iri. Gated on to_iri's collection being editable.
"""
try:
result = manager.repoint_reference(
link_id=body.link_id,
from_iri=body.from_iri,
to_iri=body.to_iri,
actor=current_user.email,
)
return RepointReferenceResponse(**result)
except ValueError as e:
msg = str(e)
if "not found" in msg.lower() or "does not point" in msg.lower():
raise HTTPException(status_code=404, detail=msg)
raise HTTPException(status_code=400, detail=msg)
except HTTPException:
raise
except Exception:
logger.error("Error repointing reference %s", body.link_id, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to repoint reference")


@router.post(
'/semantic-models/concepts/merge',
response_model=MergeConceptsResponse,
)
async def merge_concepts(
body: MergeConceptsRequest,
current_user: CurrentUserDep,
manager: SemanticModelsManager = Depends(get_semantic_models_manager),
_: bool = Depends(PermissionChecker('semantic-models', FeatureAccessLevel.READ_WRITE)),
) -> MergeConceptsResponse:
"""Merge N sources into one target (§5b): repoint each source's refs to target
(if repoint_refs), then deprecate the source with replaced_by=[target].

Convenience composing the repoint + deprecate primitives; no new lineage
semantics. Editable gate enforced per-repoint (target collection) and by
deprecate (source collection).
"""
try:
result = manager.merge_concepts(
source_iris=body.source_iris,
target_iri=body.target_iri,
repoint_refs=body.repoint_refs,
actor=current_user.email,
)
return MergeConceptsResponse(**result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception:
logger.error("Error merging concepts into %s", body.target_iri, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to merge concepts")


@router.get(
'/semantic-models/graph/freshness',
response_model=GraphFreshnessResponse,
Expand Down
Loading
Loading