Skip to content

Commit 962fee2

Browse files
authored
Merge pull request #23 from EverMind-AI/feat/facade-memory-update-feedback
facade: update, delete_by_ids, feedback (1.2.0)
2 parents fa2ba28 + f23c35f commit 962fee2

4 files changed

Lines changed: 258 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ multimodal upload — is in
9393
### Two ways to call the API
9494

9595
`EverOS` covers the common calls with plain kwargs in and the response's `.data` out.
96-
New methods are named `<resource>_<verb>` (`kb_create`, `doc_ingest`, `task_wait`,
97-
`tag_bind`), so typing `client.kb` lists the knowledge-base surface; the methods 1.0.0
98-
shipped are bare verbs (`add`, `search`, `get`, `flush`, `edit`, `delete`, `upload`).
96+
Memory methods are bare verbs named after their route (`add`, `search`, `get`, `flush`,
97+
`edit`, `delete`, `update`, `delete_by_ids`, `feedback`); every other resource is
98+
`<resource>_<verb>` (`kb_create`, `doc_ingest`, `task_wait`, `tag_bind`), so typing
99+
`client.kb` lists the knowledge-base surface.
99100

100-
Everything the API offers — all 31 operations, including knowledge-base categories and
101+
Everything the API offers — all 37 operations, including knowledge-base categories and
101102
document topics — is on the generated typed clients, reachable as `client.memory`,
102103
`client.storage`, `client.knowledge`, `client.tasks`. Those take and return the full
103104
typed models, so responses arrive as an envelope you read `.data` from. The

‎everos_cloud/client.py‎

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
1414
Naming: the nine methods 1.0.0 shipped are bare verbs and stay that way — ``add`` /
1515
``search`` / ``get`` / ``flush`` / ``edit`` / ``delete`` (memory), ``presign`` /
16-
``upload`` (storage), ``close``. They are public API and cannot be renamed. Everything
17-
added since carries its resource as a prefix — ``kb_`` / ``doc_`` / ``task_`` /
18-
``tag_`` — so completion groups by resource and no facade method collides with the
19-
generated method it wraps. The two styles sitting side by side is a consequence of that
20-
freeze, not a convention worth copying.
16+
``upload`` (storage), ``close``. They are public API and cannot be renamed. Memory
17+
methods added since follow the same bare-verb shape so the memory group stays one
18+
style and each name is the last segment of its route — ``update`` / ``delete_by_ids``
19+
/ ``feedback`` (1.2.0). Every other resource carries its name as a prefix — ``kb_`` /
20+
``doc_`` / ``task_`` / ``tag_`` — so completion groups by resource and no facade method
21+
collides with the generated method it wraps.
2122
2223
Errors: every failure raised by this facade derives from :class:`EverOSError` —
2324
``EverOSAPIError`` for HTTP errors, ``EverOSStorageError`` for object-upload failures,
@@ -39,24 +40,29 @@
3940
AddOperation,
4041
Content,
4142
ContentItem,
43+
DeleteByIdsInput,
4244
DeleteInput,
4345
DeleteOperation,
4446
DocIngestBody,
4547
DocPatchBody,
4648
EditInput,
4749
EditInputOperationsInner,
50+
EpisodePatch,
51+
FeedbackInput,
4852
FlushInput,
4953
GetInput,
5054
KbCreateInput,
5155
KbPatchBody,
5256
MessageItem,
57+
Reason,
5358
SearchBody,
5459
SearchInput,
5560
SignObjectItem,
5661
SignRequest,
5762
TagBindInput,
5863
TagReplaceInput,
5964
TagUnbindInput,
65+
UpdateInput,
6066
UpdateOperation,
6167
)
6268

@@ -80,6 +86,8 @@
8086
_FACADE_FOR = {
8187
"add_memory": "add", "search_memory": "search", "get_memory": "get",
8288
"flush_memory": "flush", "edit_profile": "edit", "delete_memory": "delete",
89+
"update_memory": "update", "delete_memories_by_ids": "delete_by_ids",
90+
"submit_feedback": "feedback",
8391
"sign_objects": "presign",
8492
"bind_tags": "tag_bind", "unbind_tags": "tag_unbind", "replace_tags": "tag_replace",
8593
"create_knowledge_base": "kb_create", "list_knowledge_bases": "kb_list",
@@ -106,6 +114,7 @@
106114

107115
MessageLike = Union[MessageItem, Mapping[str, Any]]
108116
ContentLike = Union[ContentItem, Mapping[str, Any], str]
117+
ReasonLike = Union[Reason, Mapping[str, Any], str]
109118

110119

111120
def _guess_file_type(name: str) -> str:
@@ -273,6 +282,15 @@ def _to_operation(op: Any) -> EditInputOperationsInner:
273282
raise ValueError(f"unknown edit operation action: {op.get('action')!r}")
274283
return EditInputOperationsInner(cls(**op))
275284

285+
@staticmethod
286+
def _to_reason(reason: ReasonLike | None) -> Reason | None:
287+
"""``"redundant"`` / ``{"code": ..., "note": ...}`` / ``Reason`` -> ``Reason``."""
288+
if reason is None or isinstance(reason, Reason):
289+
return reason
290+
if isinstance(reason, str):
291+
return Reason(code=reason)
292+
return Reason(**reason)
293+
276294
@staticmethod
277295
def _to_content(content: ContentLike) -> ContentItem:
278296
"""Coerce a document body into a ``ContentItem``.
@@ -422,6 +440,94 @@ def delete(
422440
)
423441
return self._call(self.memory.delete_memory, payload).data
424442

443+
# -- single-memory edits -------------------------------------------------
444+
# These target memories by id, so like the tag calls they carry NO app_id /
445+
# project_id and `_scope` does not apply. Facade names are the last segment of
446+
# the route: /memory/update -> update, /memory/delete_by_ids -> delete_by_ids,
447+
# /memory/feedback -> feedback.
448+
def update(
449+
self,
450+
memory_id: str,
451+
*,
452+
episode: str | None = None,
453+
summary: str | None = None,
454+
subject: str | None = None,
455+
reason: ReasonLike | None = None,
456+
memory_type: str = "episode",
457+
) -> Any:
458+
"""Patch one episode's ``episode`` text, ``summary`` and/or ``subject``.
459+
460+
Only the fields you pass change (last write wins). ``reason`` is a code such as
461+
``"wrong_subject"`` or ``{"code": ..., "note": ...}``. Returns ``UpdateData``,
462+
whose ``unchanged`` is true when the patch matched what was stored.
463+
464+
Profile items are edited with :meth:`edit`, not here.
465+
"""
466+
patch = _clean(episode=episode, summary=summary, subject=subject)
467+
if not patch:
468+
raise ValueError("update needs at least one of episode / summary / subject")
469+
payload = UpdateInput(
470+
**_clean(
471+
memory_type=memory_type,
472+
memory_id=memory_id,
473+
patch=EpisodePatch(**patch),
474+
reason=self._to_reason(reason),
475+
)
476+
)
477+
return self._call(self.memory.update_memory, payload).data
478+
479+
def delete_by_ids(
480+
self,
481+
memory_ids: Sequence[str],
482+
*,
483+
reason: ReasonLike | None = None,
484+
memory_type: str = "episode",
485+
) -> Any:
486+
"""Soft-delete 1-50 memories by id. Returns ``DeleteByIdsData``.
487+
488+
Ids that do not exist are skipped; one malformed id fails the whole batch (422).
489+
To clear a whole user / agent / session, use :meth:`delete`.
490+
"""
491+
payload = DeleteByIdsInput(
492+
**_clean(
493+
memory_type=memory_type,
494+
memory_ids=list(memory_ids),
495+
reason=self._to_reason(reason),
496+
)
497+
)
498+
return self._call(self.memory.delete_memories_by_ids, payload).data
499+
500+
def feedback(
501+
self,
502+
memory_id: str,
503+
rating: str,
504+
*,
505+
reason: str | None = None,
506+
note: str | None = None,
507+
suggestion: str | None = None,
508+
item_id: str | None = None,
509+
memory_type: str = "episode",
510+
) -> Any:
511+
"""Rate a memory ``"positive"`` or ``"negative"``. Returns ``FeedbackData``.
512+
513+
``reason`` (a code such as ``"outdated"``), ``note`` and ``suggestion`` go with a
514+
negative rating only. ``item_id`` names the item inside a profile memory. This
515+
only records the rating — nothing is edited; use :meth:`update` /
516+
:meth:`delete_by_ids` to change the memory itself.
517+
"""
518+
payload = FeedbackInput(
519+
**_clean(
520+
memory_type=memory_type,
521+
memory_id=memory_id,
522+
item_id=item_id,
523+
rating=rating,
524+
reason=reason,
525+
note=note,
526+
suggestion=suggestion,
527+
)
528+
)
529+
return self._call(self.memory.submit_feedback, payload).data
530+
425531
# -- memory tags ---------------------------------------------------------
426532
# Tag calls carry NO app_id / project_id: the scope of a tag operation is the
427533
# memory ids themselves, so `_scope` deliberately does not apply here.

‎quickstart.md‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ client.edit("user-1", operations=[
5656
# ── Delete memories (scoped soft-delete) ──────────────────────────────────────
5757
client.delete(user_id="user-1", session_id="session-1")
5858

59+
# ── Fix or remove single memories (1.2.0) ─────────────────────────────────────
60+
# update patches one episode's text / summary / subject; only the fields you pass
61+
# change. reason is optional: a code, or {"code": ..., "note": ...}.
62+
client.update("mem-1", summary="Moved to Hangzhou; lives near West Lake.",
63+
reason="wrong_subject")
64+
client.delete_by_ids(["mem-2", "mem-3"], reason="redundant") # 1-50 ids, soft delete
65+
66+
# ── Rate a memory ─────────────────────────────────────────────────────────────
67+
# Record-only: nothing is edited. reason / note / suggestion go with "negative".
68+
client.feedback("mem-1", "negative", reason="outdated",
69+
suggestion="The user currently lives in Shanghai.")
70+
5971
# ── Upload multimodal data ────────────────────────────────────────────────────
6072
# Presigns + POSTs the file directly to S3, returns the object key you then
6173
# reference in a message's multimodal content. file_type is inferred from the ext.
@@ -143,11 +155,11 @@ context manager (`with EverOS(...) as client:`) to release connections on exit.
143155

144156
## Method reference
145157

146-
New facade methods are named `<resource>_<verb>`, so typing `client.kb` / `client.doc` /
147-
`client.task` / `client.tag` lists everything for that resource. The nine methods 1.0.0
148-
shipped are bare verbs with no prefix (`add` / `search` / `get` / `flush` / `edit` /
149-
`delete` for memory, `presign` / `upload` for storage, plus `close`) — that split is
150-
historical, not a rule: those names are public API since 1.0.0 and cannot be changed.
158+
Memory methods are bare verbs named after the last segment of their route (`add` /
159+
`search` / `get` / `flush` / `edit` / `delete` since 1.0.0, `update` / `delete_by_ids` /
160+
`feedback` since 1.2.0), as are `presign` / `upload` for storage and `close`. Every other
161+
resource is named `<resource>_<verb>`, so typing `client.kb` / `client.doc` /
162+
`client.task` / `client.tag` lists everything for that resource.
151163

152164
| Method | Endpoint | Notes |
153165
|---|---|---|
@@ -156,7 +168,10 @@ historical, not a rule: those names are public API since 1.0.0 and cannot be cha
156168
| `get(memory_type, ...)` | `POST /api/v2/memory/get` | Paginated list by `memory_type`. |
157169
| `search(query, ...)` | `POST /api/v2/memory/search` | Keyword / vector / hybrid / agentic. |
158170
| `edit(user_id, operations)` | `POST /api/v2/memory/edit` | Bulk profile add / update / delete. |
159-
| `delete(...)` | `POST /api/v2/memory/delete` | Scoped soft-delete. |
171+
| `delete(...)` | `POST /api/v2/memory/delete` | Scoped soft-delete (whole user / agent / session). |
172+
| `update(memory_id, ...)` | `POST /api/v2/memory/update` | Patch one episode's text / summary / subject; `unchanged` in the result when nothing differed. |
173+
| `delete_by_ids(memory_ids, ...)` | `POST /api/v2/memory/delete_by_ids` | Soft-delete 1-50 memories by id; unknown ids are skipped. |
174+
| `feedback(memory_id, rating, ...)` | `POST /api/v2/memory/feedback` | Record a positive / negative rating; edits nothing. |
160175
| `upload(path)` | `POST /api/v2/object/sign` + S3 | Presign + direct-to-S3, returns `object_key`. |
161176
| `tag_bind` / `tag_unbind` / `tag_replace` | `POST /api/v2/memory/tag/*` | Add / remove / overwrite tags on memory ids. |
162177
| `kb_create(name, ...)` | `POST /api/v2/knowledge_bases` | Create a knowledge base. |

‎tests/test_client.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@
77
from everos_cloud.exceptions import ApiException
88
from everos_cloud.models import (
99
AddInput,
10+
DeleteByIdsInput,
1011
DocIngestBody,
1112
DocIngestData,
1213
EditInput,
14+
FeedbackInput,
1315
KbCreateInput,
1416
KbPatchBody,
1517
MessageItem,
18+
Reason,
1619
SearchBody,
1720
SearchInput,
1821
TagBindInput,
1922
TagReplaceInput,
2023
TagUnbindInput,
24+
UpdateInput,
2125
)
2226

2327

@@ -290,6 +294,118 @@ def test_tag_inputs_carry_no_scope():
290294
assert not hasattr(payload, "project_id") or payload.project_id is None
291295

292296

297+
# ── single-memory edits (1.2.0) ──────────────────────────────────────────────
298+
def test_update_builds_patch_and_reason():
299+
mem = MagicMock()
300+
mem.update_memory.return_value = SimpleNamespace(data="UP")
301+
c = _client(memory=mem)
302+
303+
assert c.update("m1", summary="Moved to Hangzhou", reason="wrong_subject") == "UP"
304+
payload = mem.update_memory.call_args.args[0]
305+
assert isinstance(payload, UpdateInput)
306+
assert payload.memory_type == "episode" and payload.memory_id == "m1"
307+
assert payload.patch.summary == "Moved to Hangzhou"
308+
assert payload.patch.episode is None and payload.patch.subject is None
309+
assert isinstance(payload.reason, Reason)
310+
assert payload.reason.code == "wrong_subject" and payload.reason.note is None
311+
312+
313+
def test_update_reason_accepts_dict_model_or_nothing():
314+
mem = MagicMock()
315+
mem.update_memory.return_value = SimpleNamespace(data=None)
316+
c = _client(memory=mem)
317+
318+
c.update("m1", episode="x", reason={"code": "wrong_time", "note": "was 2024"})
319+
r = mem.update_memory.call_args.args[0].reason
320+
assert r.code == "wrong_time" and r.note == "was 2024"
321+
322+
c.update("m1", episode="x", reason=Reason(code="style"))
323+
assert mem.update_memory.call_args.args[0].reason.code == "style"
324+
325+
c.update("m1", episode="x")
326+
assert mem.update_memory.call_args.args[0].reason is None
327+
328+
329+
def test_update_rejects_an_empty_patch():
330+
mem = MagicMock()
331+
c = _client(memory=mem)
332+
with pytest.raises(ValueError):
333+
c.update("m1")
334+
mem.update_memory.assert_not_called()
335+
336+
337+
def test_delete_by_ids_builds_input_and_returns_data():
338+
mem = MagicMock()
339+
mem.delete_memories_by_ids.return_value = SimpleNamespace(data="D")
340+
c = _client(memory=mem)
341+
342+
assert c.delete_by_ids(("m1", "m2"), reason="redundant") == "D"
343+
payload = mem.delete_memories_by_ids.call_args.args[0]
344+
assert isinstance(payload, DeleteByIdsInput)
345+
assert payload.memory_type == "episode"
346+
assert payload.memory_ids == ["m1", "m2"] # tuple -> list
347+
assert payload.reason.code == "redundant"
348+
349+
c.delete_by_ids(["m3"])
350+
assert mem.delete_memories_by_ids.call_args.args[0].reason is None
351+
352+
353+
_OID = "66dff0f8a9f84d8c9f62f011" # feedback validates memory_id as a 24-char object id
354+
355+
356+
def test_feedback_builds_input_and_returns_data():
357+
mem = MagicMock()
358+
mem.submit_feedback.return_value = SimpleNamespace(data="F")
359+
c = _client(memory=mem)
360+
361+
assert c.feedback(_OID, "negative", reason="outdated", note="moved",
362+
suggestion="Lives in Shanghai now.") == "F"
363+
payload = mem.submit_feedback.call_args.args[0]
364+
assert isinstance(payload, FeedbackInput)
365+
assert payload.memory_type == "episode" and payload.memory_id == _OID
366+
assert payload.rating == "negative" and payload.reason == "outdated"
367+
assert payload.note == "moved" and payload.suggestion == "Lives in Shanghai now."
368+
assert payload.item_id is None
369+
370+
c.feedback(_OID, "positive", memory_type="profile", item_id="it_" + "0" * 24)
371+
payload = mem.submit_feedback.call_args.args[0]
372+
assert payload.memory_type == "profile" and payload.rating == "positive"
373+
assert payload.item_id == "it_" + "0" * 24
374+
assert payload.reason is None and payload.note is None and payload.suggestion is None
375+
376+
377+
def test_by_id_calls_carry_no_scope():
378+
"""These target memory ids, so app_id / project_id must not be injected."""
379+
mem = MagicMock()
380+
for name in ("update_memory", "delete_memories_by_ids", "submit_feedback"):
381+
getattr(mem, name).return_value = SimpleNamespace(data=None)
382+
c = EverOS("sk-test", app_id="myapp", project_id="proj")
383+
c.memory = mem
384+
385+
c.update("m1", subject="s")
386+
c.delete_by_ids(["m1"])
387+
c.feedback(_OID, "positive")
388+
for name in ("update_memory", "delete_memories_by_ids", "submit_feedback"):
389+
payload = getattr(mem, name).call_args.args[0]
390+
assert getattr(payload, "app_id", None) is None
391+
assert getattr(payload, "project_id", None) is None
392+
393+
394+
def test_by_id_errors_and_timeout_go_through_call():
395+
mem = MagicMock()
396+
mem.update_memory.side_effect = ApiException(status=422, reason="Unprocessable")
397+
c = _client(memory=mem)
398+
with pytest.raises(EverOSAPIError) as ei:
399+
c.update("bad", episode="x")
400+
assert ei.value.status == 422
401+
402+
mem.submit_feedback.return_value = SimpleNamespace(data=None)
403+
c2 = EverOS("sk-test", timeout=7)
404+
c2.memory = mem
405+
c2.feedback(_OID, "positive")
406+
assert mem.submit_feedback.call_args.kwargs["_request_timeout"] == 7
407+
408+
293409
# ── knowledge base ───────────────────────────────────────────────────────────
294410
def test_create_kb_builds_input_and_drops_none():
295411
kb = MagicMock()
@@ -681,6 +797,11 @@ def test_generated_name_on_the_facade_points_at_the_real_one():
681797
assert "client.kb_create" in msg # facade equivalent
682798
assert "client.knowledge.create_knowledge_base" in msg # generated location
683799

800+
with pytest.raises(AttributeError) as ei:
801+
c.submit_feedback
802+
assert "client.feedback" in str(ei.value)
803+
assert "client.memory.submit_feedback" in str(ei.value)
804+
684805
# a generated method the facade does NOT cover: still says where it lives
685806
with pytest.raises(AttributeError) as ei:
686807
c.list_topics

0 commit comments

Comments
 (0)