feat(api): PATCH /entities/{id} + /v1/engineering/* aliases
PATCH lets users edit an active entity's description, properties,
confidence, and source_refs without cloning — closes the duplicate-trap
half-fixed by /invalidate + /supersede. Issue D just adds the
/engineering/* query surface to the /v1 allowlist.
- engineering/service.py: update_entity supports description replace,
properties shallow merge with null-delete semantics, confidence
0..1 bounds check, source_refs dedup-append. Writes audit row
- api/routes.py: PATCH /entities/{id} with EntityPatchRequest
- main.py: engineering/* query endpoints aliased under /v1 (Issue D)
- tests/test_patch_entity.py: 12 tests (merge, null-delete, bounds,
dedup, 404, audit, v1 alias)
- DEV-LEDGER.md: session log + test_count 509 -> 521
Forbidden fields via PATCH (by design): entity_type, project, name,
status. Use supersede+create or the dedicated status endpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -534,6 +534,106 @@ def invalidate_active_entity(
|
||||
return ok, "invalidated" if ok else "not_active"
|
||||
|
||||
|
||||
def update_entity(
|
||||
entity_id: str,
|
||||
*,
|
||||
description: str | None = None,
|
||||
properties_patch: dict | None = None,
|
||||
confidence: float | None = None,
|
||||
append_source_refs: list[str] | None = None,
|
||||
actor: str = "api",
|
||||
note: str = "",
|
||||
) -> Entity | None:
|
||||
"""Update mutable fields on an existing entity (Issue E follow-up).
|
||||
|
||||
Field rules (kept narrow on purpose):
|
||||
|
||||
- ``description``: replaces the current value when provided.
|
||||
- ``properties_patch``: merged into the existing ``properties`` dict,
|
||||
shallow. Pass ``None`` as a value to delete a key; pass a new
|
||||
value to overwrite it.
|
||||
- ``confidence``: replaces when provided. Must be in [0, 1].
|
||||
- ``append_source_refs``: appended verbatim to the existing list
|
||||
(duplicates are filtered out, order preserved).
|
||||
|
||||
What you cannot change via this path:
|
||||
|
||||
- ``entity_type`` — requires supersede+create (a new type is a new
|
||||
thing).
|
||||
- ``project`` — use ``promote_entity`` with ``target_project`` for
|
||||
inbox→project graduation, or supersede+create for anything else.
|
||||
- ``name`` — renames are destructive to cross-references;
|
||||
supersede+create.
|
||||
- ``status`` — use the dedicated promote/reject/invalidate/supersede
|
||||
endpoints.
|
||||
|
||||
Returns the updated entity, or None if no such entity exists.
|
||||
"""
|
||||
entity = get_entity(entity_id)
|
||||
if entity is None:
|
||||
return None
|
||||
if confidence is not None and not (0.0 <= confidence <= 1.0):
|
||||
raise ValueError("confidence must be in [0, 1]")
|
||||
|
||||
before = {
|
||||
"description": entity.description,
|
||||
"properties": dict(entity.properties or {}),
|
||||
"confidence": entity.confidence,
|
||||
"source_refs": list(entity.source_refs or []),
|
||||
}
|
||||
|
||||
new_description = entity.description if description is None else description
|
||||
new_confidence = entity.confidence if confidence is None else confidence
|
||||
new_properties = dict(entity.properties or {})
|
||||
if properties_patch:
|
||||
for key, value in properties_patch.items():
|
||||
if value is None:
|
||||
new_properties.pop(key, None)
|
||||
else:
|
||||
new_properties[key] = value
|
||||
new_refs = list(entity.source_refs or [])
|
||||
if append_source_refs:
|
||||
existing = set(new_refs)
|
||||
for ref in append_source_refs:
|
||||
if ref and ref not in existing:
|
||||
new_refs.append(ref)
|
||||
existing.add(ref)
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE entities
|
||||
SET description = ?, properties = ?, confidence = ?,
|
||||
source_refs = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
(
|
||||
new_description,
|
||||
json.dumps(new_properties),
|
||||
new_confidence,
|
||||
json.dumps(new_refs),
|
||||
now,
|
||||
entity_id,
|
||||
),
|
||||
)
|
||||
|
||||
after = {
|
||||
"description": new_description,
|
||||
"properties": new_properties,
|
||||
"confidence": new_confidence,
|
||||
"source_refs": new_refs,
|
||||
}
|
||||
_audit_entity(
|
||||
entity_id=entity_id,
|
||||
action="updated",
|
||||
actor=actor,
|
||||
before=before,
|
||||
after=after,
|
||||
note=note,
|
||||
)
|
||||
log.info("entity_updated", entity_id=entity_id, actor=actor)
|
||||
return get_entity(entity_id)
|
||||
|
||||
|
||||
def get_entity_audit(entity_id: str, limit: int = 100) -> list[dict]:
|
||||
"""Fetch audit entries for an entity from the shared audit table."""
|
||||
with get_connection() as conn:
|
||||
|
||||
Reference in New Issue
Block a user