Skip to content
Merged
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
44 changes: 11 additions & 33 deletions src/backend/server/routes/concept_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from ...services.ontology_mutation_command_service import (
OntologyMutationCommandError,
delete_legacy_name,
execute_governed_concept_description_update,
)
from ...services.paper_recommendation_profile_vontology_service import (
load_paper_recommendation_profile,
Expand Down Expand Up @@ -226,10 +227,9 @@ def _get_current_user_concept_id() -> str | None:


def _get_current_org_concept_id() -> str | None:
effective = _get_effective_request_context()
return _normalise_concept_id(
effective.get("organisation_id")
) or _normalise_concept_id(session.get("organisation_concept_id"))
from ...security.access_control import get_effective_organisation_concept_id

return get_effective_organisation_concept_id()


def _is_admin_or_owner_session() -> bool:
Expand Down Expand Up @@ -630,7 +630,9 @@ def create_concept_route():
created_by_concept_id=trusted_actor,
organisation_concept_id=trusted_org,
event_namespace=_get_request_namespace(),
visibility_scope_mode=create_arguments.get("scope_mode"),
visibility_scope_mode=(
create_arguments.get("scope_mode") or "user_only_default"
),
maintain_relationship_inverses=False,
resolve_visibility_from_event_namespace=False,
),
Expand Down Expand Up @@ -794,20 +796,8 @@ def single_concept_route(concept_id: str) -> ResponseReturnValue:
else:
return jsonify(error="Failed to update notes"), 500
elif "description" in data and len(data) == 1:
governed = _execute_governed_http_mutation(
method_name="upsert_singleton_text_relation",
arguments={
"concept_id": concept_id,
"predicate": "hasDescription",
"text": data["description"],
},
mutate=lambda: {
"success": bool(
concept_service.update_concept_description(
concept_id, data["description"]
)
)
},
governed = execute_governed_concept_description_update(
concept_id, data["description"]
)
if governed.get("success") is False:
return _governed_mutation_response(governed)
Expand Down Expand Up @@ -1510,20 +1500,8 @@ def update_concept_description_route(concept_id: str):
400,
)
try:
governed = _execute_governed_http_mutation(
method_name="upsert_singleton_text_relation",
arguments={
"concept_id": concept_id,
"predicate": "hasDescription",
"text": data["description"],
},
mutate=lambda: {
"success": bool(
concept_service.update_concept_description(
concept_id, data["description"]
)
)
},
governed = execute_governed_concept_description_update(
concept_id, data["description"]
)
if governed.get("success") is False:
return _governed_mutation_response(governed)
Expand Down
17 changes: 3 additions & 14 deletions src/backend/server/routes/vontology_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
get_concept_by_id,
ConceptNotFoundError,
resolve_concept_display_names,
update_concept_description,
)
from ...services.text_value_service import get_texts_for_concept, get_texts_for_concepts
from ...services.concept_relation_service import build_concept_relations_payload
Expand Down Expand Up @@ -1435,21 +1434,11 @@ def update_description_route():
)

from ...services.ontology_mutation_command_service import (
execute_governed_ontology_method,
execute_governed_concept_description_update,
)

governed = execute_governed_ontology_method(
method_name="update_concept",
arguments={
"concept_id": resolved_concept_id,
"update_data": {"description": description},
"request_id": data.get("request_id"),
},
mutate=lambda: {
"success": bool(
update_concept_description(resolved_concept_id, description)
)
},
governed = execute_governed_concept_description_update(
resolved_concept_id, description
)
if governed.get("success") is False:
return _governed_mutation_response(governed)
Expand Down
189 changes: 115 additions & 74 deletions src/backend/services/concept_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,10 @@ def _resolve_creation_actor_context(

if actor_org_id is None:
try:
from flask import has_request_context, session as flask_session
from ..security.access_control import get_effective_organisation_concept_id

if has_request_context():
org_raw = flask_session.get("organisation_concept_id")
if isinstance(org_raw, str) and org_raw.strip():
actor_org_id = org_raw.strip()
# A window's Personal selection is authoritative, including None.
actor_org_id = get_effective_organisation_concept_id()
except Exception:
actor_org_id = None

Expand Down Expand Up @@ -4584,7 +4582,115 @@ def update_concept_notes(
return False


def update_concept_description(concept_id: str, new_description: str) -> bool:
def prepare_concept_description_update(
concept_id: str, new_description: str
) -> Dict[str, Any]:
"""Resolve the exact stored description and metadata before governed execution.

This reads canonical metadata but performs no write. The caller must pass the
same plan to the writer and the verifier, including the one edit timestamp.
"""
existing_context: Dict[str, Any] = {}
existing_provenance: Dict[str, Any] = {}
cleaned_description, extracted_inline_metadata = (
extract_inline_description_metadata(new_description)
)
description_to_store = (
cleaned_description if cleaned_description.strip() else new_description
)

try:
existing_descriptions = get_texts_for_concept(
subject_concept_id=concept_id, predicate="hasDescription", limit=1
)
if existing_descriptions:
first = existing_descriptions[0]
if isinstance(first.get("context"), dict):
existing_context = dict(first["context"])
if isinstance(first.get("provenance"), dict):
existing_provenance = dict(first["provenance"])
except Exception as e:
logger.warning(
"update_concept_description: failed to read existing metadata for %s: %s",
concept_id,
e,
)
# Missing metadata is different from a failed read. Do not replace it
# with empty metadata when its canonical state could not be observed.
raise

now_iso = datetime.now(timezone.utc).isoformat()
provenance_payload: Dict[str, Any] = dict(existing_provenance)
context_payload: Dict[str, Any] = dict(existing_context)
if not provenance_payload:
provenance_payload["source"] = "update_concept_description"
provenance_payload["last_edited_at"] = now_iso
provenance_payload.setdefault("last_edited_by", "update_concept_description")
context_payload.setdefault("write_strategy", "relations_only_v2")

if extracted_inline_metadata:
if isinstance(extracted_inline_metadata.get("source"), str):
provenance_payload.setdefault(
"source_label", extracted_inline_metadata["source"]
)
if isinstance(extracted_inline_metadata.get("attribution"), str):
provenance_payload.setdefault(
"attribution", extracted_inline_metadata["attribution"]
)
if isinstance(extracted_inline_metadata.get("timestamp"), str):
provenance_payload.setdefault(
"upstream_timestamp", extracted_inline_metadata["timestamp"]
)
if isinstance(extracted_inline_metadata.get("parent"), str):
context_payload.setdefault(
"parent_concept_id", extracted_inline_metadata["parent"]
)
confidence_score = extracted_inline_metadata.get("confidence_score")
if isinstance(confidence_score, (int, float)):
context_payload.setdefault("confidence_score", float(confidence_score))
context_payload["inline_metadata_migrated"] = True

from .text_value_service import _compute_fingerprint, _prepare_persisted_text
from ..db.repositories.text_value_repository import TextValuesRepository

stored_text = _prepare_persisted_text(description_to_store)
# Reuse legacy values when the bytes match. A normalised fingerprint can
# otherwise alias a deliberate case/spacing edit to different stored text;
# use the existing exact-identity mode only for that collision.
identity_mode = "normalised"
existing_value = TextValuesRepository.find_one_by_fingerprint(
_compute_fingerprint(stored_text, "en"), "en"
)
if existing_value and existing_value.get("text") != stored_text:
identity_mode = "exact"
existing_value = TextValuesRepository.find_one_by_fingerprint(
_compute_fingerprint(stored_text, "en", identity_mode="exact"), "en"
)
if existing_value and existing_value.get("text") != stored_text:
raise ValueError(
"Exact description fingerprint resolved to different bytes"
)
if existing_value:
# Shared TextValues retain their original provenance on reuse. Bind
# verification to that observed value, without overwriting attribution.
provenance_payload = dict(existing_value.get("provenance") or {})
return {
"subject_concept_id": concept_id,
"predicate": "hasDescription",
"text": stored_text,
"lang": "en",
"provenance": provenance_payload,
"context": context_payload,
"identity_mode": identity_mode,
}


def update_concept_description(
concept_id: str,
new_description: str,
*,
prepared_update: Optional[Dict[str, Any]] = None,
) -> bool:
"""Update concept description using text_relations only (Phase 1 preserved_fields deprecation).

Behaviour:
Expand Down Expand Up @@ -4618,76 +4724,11 @@ def update_concept_description(concept_id: str, new_description: str) -> bool:
)
return False

existing_context: Dict[str, Any] = {}
existing_provenance: Dict[str, Any] = {}
cleaned_description, extracted_inline_metadata = (
extract_inline_description_metadata(new_description)
)
description_to_store = (
cleaned_description if cleaned_description.strip() else new_description
prepared = prepared_update or prepare_concept_description_update(
concept_id, new_description
)

try:
existing_descriptions = get_texts_for_concept(
subject_concept_id=concept_id, predicate="hasDescription", limit=1
)
if existing_descriptions:
first = existing_descriptions[0]
if isinstance(first.get("context"), dict):
existing_context = dict(first["context"])
if isinstance(first.get("provenance"), dict):
existing_provenance = dict(first["provenance"])
except Exception as e: # pragma: no cover
logger.warning(
"update_concept_description: failed to read existing metadata for %s: %s",
concept_id,
e,
)

try:
now_iso = datetime.now(timezone.utc).isoformat()
provenance_payload: Dict[str, Any] = dict(existing_provenance)
context_payload: Dict[str, Any] = dict(existing_context)
if not provenance_payload:
provenance_payload["source"] = "update_concept_description"
provenance_payload["last_edited_at"] = now_iso
provenance_payload.setdefault(
"last_edited_by", "update_concept_description"
)
context_payload.setdefault("write_strategy", "relations_only_v2")

if extracted_inline_metadata:
if isinstance(extracted_inline_metadata.get("source"), str):
provenance_payload.setdefault(
"source_label", extracted_inline_metadata["source"]
)
if isinstance(extracted_inline_metadata.get("attribution"), str):
provenance_payload.setdefault(
"attribution", extracted_inline_metadata["attribution"]
)
if isinstance(extracted_inline_metadata.get("timestamp"), str):
provenance_payload.setdefault(
"upstream_timestamp", extracted_inline_metadata["timestamp"]
)
if isinstance(extracted_inline_metadata.get("parent"), str):
context_payload.setdefault(
"parent_concept_id", extracted_inline_metadata["parent"]
)
confidence_score = extracted_inline_metadata.get("confidence_score")
if isinstance(confidence_score, (int, float)):
context_payload.setdefault(
"confidence_score", float(confidence_score)
)
context_payload["inline_metadata_migrated"] = True

tv_payload = upsert_text_for_concept(
subject_concept_id=concept_id,
predicate="hasDescription",
text=description_to_store,
lang="en",
provenance=provenance_payload,
context=context_payload,
)
tv_payload = upsert_text_for_concept(**prepared)
new_text_value_id = tv_payload.get("text_value_id")
relation_id = tv_payload.get("relation_id")
logger.info(
Expand Down
22 changes: 22 additions & 0 deletions src/backend/services/ontology_mutation_command_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3958,11 +3958,33 @@ def json_dumps_sorted(value: Any) -> str:
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"))


def execute_governed_concept_description_update(
concept_id: str, description: str
) -> dict[str, Any]:
"""Bind browser description verification to the writer's exact prepared input."""
from .concept_service import (
prepare_concept_description_update,
update_concept_description,
)

prepared = prepare_concept_description_update(concept_id, description)
return execute_governed_ontology_method(
method_name="upsert_singleton_text_relation",
arguments=prepared,
mutate=lambda: {
"success": update_concept_description(
concept_id, description, prepared_update=prepared
)
},
)


__all__ = [
"OntologyMutationCommandError",
"build_ontology_mutation_intent",
"canonical_read_back_for_method",
"delete_legacy_name",
"execute_governed_concept_description_update",
"execute_governed_ontology_method",
"is_ontology_mutation_method",
"issue_same_turn_method_delegation",
Expand Down
Loading
Loading