cortexdb-mcp 0.3.3__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/.gitignore +3 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/PKG-INFO +12 -1
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/README.md +11 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/__init__.py +1 -1
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/render.py +62 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/server.py +233 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/pyproject.toml +1 -1
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/tests/test_integration.py +67 -67
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/tests/test_server.py +302 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/Dockerfile +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/__main__.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/api.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/config.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/cortexdb_mcp/insights.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/tests/__init__.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.5.0}/tests/test_insights.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: MCP Server for CortexDB — expose memory operations to AI agents
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -157,6 +157,7 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
157
157
|
| Tool | Maps to | Description |
|
|
158
158
|
|------|---------|-------------|
|
|
159
159
|
| `memory_store` | `POST /v1/experience` | Store a new memory. Source/tags/type become labels. |
|
|
160
|
+
| `memory_status` | `GET /v1/experience/status` | Did a store land? Check by event ID or idempotency key after a timeout — "not found"/"failed" both mean re-storing is safe. |
|
|
160
161
|
| `memory_search` | `POST /v1/recall` | Search memories using natural language. |
|
|
161
162
|
| `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. |
|
|
162
163
|
| `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
|
|
@@ -180,6 +181,16 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
180
181
|
| `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
|
|
181
182
|
| `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
|
|
182
183
|
|
|
184
|
+
### Bi-temporal Conflicts & Claim History
|
|
185
|
+
|
|
186
|
+
| Tool | Maps to | Description |
|
|
187
|
+
|------|---------|-------------|
|
|
188
|
+
| `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
|
|
189
|
+
| `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
|
|
190
|
+
| `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
|
|
191
|
+
|
|
192
|
+
Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
|
|
193
|
+
|
|
183
194
|
### Admin & Observability
|
|
184
195
|
|
|
185
196
|
| Tool | Maps to | Description |
|
|
@@ -144,6 +144,7 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
144
144
|
| Tool | Maps to | Description |
|
|
145
145
|
|------|---------|-------------|
|
|
146
146
|
| `memory_store` | `POST /v1/experience` | Store a new memory. Source/tags/type become labels. |
|
|
147
|
+
| `memory_status` | `GET /v1/experience/status` | Did a store land? Check by event ID or idempotency key after a timeout — "not found"/"failed" both mean re-storing is safe. |
|
|
147
148
|
| `memory_search` | `POST /v1/recall` | Search memories using natural language. |
|
|
148
149
|
| `memory_forget` | `POST /v1/forget` | Delete memories. With `query`, narrows by subject. |
|
|
149
150
|
| `get_context` | `POST /v1/recall` (holistic) | Deep context with facts + beliefs. |
|
|
@@ -167,6 +168,16 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
167
168
|
| `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
|
|
168
169
|
| `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
|
|
169
170
|
|
|
171
|
+
### Bi-temporal Conflicts & Claim History
|
|
172
|
+
|
|
173
|
+
| Tool | Maps to | Description |
|
|
174
|
+
|------|---------|-------------|
|
|
175
|
+
| `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
|
|
176
|
+
| `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
|
|
177
|
+
| `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
|
|
178
|
+
|
|
179
|
+
Validity boundaries carry a basis: `stated` boundaries render as "since/until \<date\>"; `observed` boundaries are knowledge bounds and render as "by \<date\>" — never "on \<date\>".
|
|
180
|
+
|
|
170
181
|
### Admin & Observability
|
|
171
182
|
|
|
172
183
|
| Tool | Maps to | Description |
|
|
@@ -87,6 +87,68 @@ def fact_text(f: dict[str, Any]) -> str:
|
|
|
87
87
|
return " ".join(p for p in (subj_text, predicate, obj_text) if p)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _boundary_date(iso: str) -> str:
|
|
91
|
+
"""Trim an RFC3339 boundary to its date part — validity boundaries are
|
|
92
|
+
day-grained after date grounding; the clock adds noise, not signal."""
|
|
93
|
+
return (iso or "")[:10]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def record_validity_text(r: dict[str, Any]) -> str:
|
|
97
|
+
"""Render a RecordView's valid interval honoring ``BoundaryBasis``.
|
|
98
|
+
|
|
99
|
+
THE rendering rule (BITEMPORAL_CONFLICT_DESIGN §2): a boundary whose
|
|
100
|
+
basis is ``"observed"`` is only a knowledge bound — it must read as
|
|
101
|
+
"by <date>" (we knew it held/ended by then), NEVER "on/since <date>".
|
|
102
|
+
Stated boundaries render as "since <date>" / "until <date>".
|
|
103
|
+
"""
|
|
104
|
+
parts: list[str] = []
|
|
105
|
+
vf, vt = r.get("valid_from"), r.get("valid_to")
|
|
106
|
+
if vf:
|
|
107
|
+
d = _boundary_date(str(vf))
|
|
108
|
+
parts.append(
|
|
109
|
+
f"held by {d}" if r.get("valid_from_basis") == "observed" else f"since {d}"
|
|
110
|
+
)
|
|
111
|
+
if vt:
|
|
112
|
+
d = _boundary_date(str(vt))
|
|
113
|
+
parts.append(
|
|
114
|
+
f"ended by {d}" if r.get("valid_to_basis") == "observed" else f"until {d}"
|
|
115
|
+
)
|
|
116
|
+
return ", ".join(parts) if parts else "undated"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def record_text(r: dict[str, Any]) -> str:
|
|
120
|
+
"""Render one bi-temporal RecordView to a readable line:
|
|
121
|
+
``<value> (<validity>; recorded <date>[; <status>][; conf NN%])``."""
|
|
122
|
+
value = "[redacted]" if r.get("redacted") else str(r.get("object") or "?")
|
|
123
|
+
bits: list[str] = [record_validity_text(r)]
|
|
124
|
+
recorded = _boundary_date(str(r.get("recorded_from") or ""))
|
|
125
|
+
if recorded:
|
|
126
|
+
bits.append(f"recorded {recorded}")
|
|
127
|
+
status = r.get("status")
|
|
128
|
+
if status and status != "active":
|
|
129
|
+
bits.append(str(status))
|
|
130
|
+
conf = r.get("confidence")
|
|
131
|
+
if isinstance(conf, (int, float)):
|
|
132
|
+
bits.append(f"conf {conf:.0%}")
|
|
133
|
+
return f"{value} ({'; '.join(bits)})"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def conflict_lines(c: dict[str, Any]) -> list[str]:
|
|
137
|
+
"""Render a ConflictView as a header line plus one indented line per
|
|
138
|
+
member record (each honoring the observed→"by" boundary rule)."""
|
|
139
|
+
header = (
|
|
140
|
+
f"{c.get('id', '?')} [{c.get('kind', '?')}] "
|
|
141
|
+
f"{c.get('subject', '?')} {c.get('predicate', '?')} — {c.get('status', '?')}"
|
|
142
|
+
)
|
|
143
|
+
lines = [header]
|
|
144
|
+
for r in c.get("records", []) or []:
|
|
145
|
+
lines.append(f" - {record_text(r)}")
|
|
146
|
+
resolved_by = c.get("resolved_by")
|
|
147
|
+
if resolved_by:
|
|
148
|
+
lines.append(f" resolved by {resolved_by} at {c.get('resolved_at', '?')}")
|
|
149
|
+
return lines
|
|
150
|
+
|
|
151
|
+
|
|
90
152
|
def belief_statement(b: dict[str, Any]) -> str:
|
|
91
153
|
"""The human-readable statement a belief asserts (claim.object.value). The
|
|
92
154
|
claim subject is an internal topic key and is deliberately not used."""
|
|
@@ -33,9 +33,11 @@ from cortexdb_mcp.config import CortexMCPConfig
|
|
|
33
33
|
from cortexdb_mcp.insights import InsightsEngine
|
|
34
34
|
from cortexdb_mcp.render import (
|
|
35
35
|
belief_text as _belief_text,
|
|
36
|
+
conflict_lines as _conflict_lines,
|
|
36
37
|
fact_text as _fact_text,
|
|
37
38
|
is_synthetic_subject as _is_synthetic_subject,
|
|
38
39
|
memories_from_context as _memories_from_context,
|
|
40
|
+
record_text as _record_text,
|
|
39
41
|
typed_value_text as _typed_value_text,
|
|
40
42
|
)
|
|
41
43
|
|
|
@@ -251,6 +253,57 @@ async def memory_store(
|
|
|
251
253
|
return f"Stored successfully. Event ID: {event_id}{warning_suffix}"
|
|
252
254
|
|
|
253
255
|
|
|
256
|
+
@mcp.tool()
|
|
257
|
+
async def memory_status(
|
|
258
|
+
event_id: str | None = None,
|
|
259
|
+
idempotency_key: str | None = None,
|
|
260
|
+
) -> str:
|
|
261
|
+
"""Check whether a stored memory landed and how far it has progressed,
|
|
262
|
+
via GET /v1/experience/status. Use this after a memory_store call timed
|
|
263
|
+
out or errored to decide whether to retry: "not found" or "failed" both
|
|
264
|
+
mean re-storing the same content is safe (it re-processes, never
|
|
265
|
+
duplicates).
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
event_id:
|
|
270
|
+
The ``Event ID`` returned by ``memory_store``.
|
|
271
|
+
idempotency_key:
|
|
272
|
+
Alternatively, the idempotency key of the original write
|
|
273
|
+
(keys are kept for 24 hours).
|
|
274
|
+
"""
|
|
275
|
+
if not event_id and not idempotency_key:
|
|
276
|
+
return "Pass event_id (from memory_store) or idempotency_key."
|
|
277
|
+
params: dict[str, Any] = {}
|
|
278
|
+
if event_id:
|
|
279
|
+
params["event_id"] = event_id
|
|
280
|
+
if idempotency_key:
|
|
281
|
+
params["idempotency_key"] = idempotency_key
|
|
282
|
+
result = await _request("GET", "/v1/experience/status", params=params)
|
|
283
|
+
if not result.get("found"):
|
|
284
|
+
return (
|
|
285
|
+
"Not found: never stored, expired (>24h), or failed and evicted. "
|
|
286
|
+
"Re-storing the same content is safe."
|
|
287
|
+
)
|
|
288
|
+
status = result.get("status", "unknown")
|
|
289
|
+
stages = ", ".join(result.get("stages_completed", [])) or "none"
|
|
290
|
+
line = (
|
|
291
|
+
f"Status: {status}. Event ID: {result.get('event_id', 'unknown')}. "
|
|
292
|
+
f"Stages completed: {stages}."
|
|
293
|
+
)
|
|
294
|
+
if status == "failed":
|
|
295
|
+
reason = result.get("failure_reason", "unknown")
|
|
296
|
+
line += (
|
|
297
|
+
f" Indexing failed ({reason}) — the memory is NOT searchable; "
|
|
298
|
+
"re-store the same content to re-process it."
|
|
299
|
+
)
|
|
300
|
+
elif status == "indexed":
|
|
301
|
+
line += " The memory is stored and searchable."
|
|
302
|
+
else:
|
|
303
|
+
line += " Accepted and durable; indexing still in progress."
|
|
304
|
+
return line
|
|
305
|
+
|
|
306
|
+
|
|
254
307
|
@mcp.tool()
|
|
255
308
|
async def memory_search(
|
|
256
309
|
query: str,
|
|
@@ -722,6 +775,186 @@ async def entity_link(
|
|
|
722
775
|
)
|
|
723
776
|
|
|
724
777
|
|
|
778
|
+
# ===========================================================================
|
|
779
|
+
# TOOLS — Bi-temporal conflicts & claim history
|
|
780
|
+
# ===========================================================================
|
|
781
|
+
#
|
|
782
|
+
# Rendering honors BoundaryBasis (BITEMPORAL_CONFLICT_DESIGN §2): boundaries
|
|
783
|
+
# whose basis is "observed" render as "by <date>" (via render.record_text),
|
|
784
|
+
# never "on/since <date>".
|
|
785
|
+
|
|
786
|
+
# Per-action argument contract for POST /v1/conflicts/{id}/resolve.
|
|
787
|
+
_RESOLVE_ACTIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
|
788
|
+
"pick": {"required": ("winner",), "optional": ("reason",)},
|
|
789
|
+
"split": {"required": ("boundary", "earlier", "later"), "optional": ()},
|
|
790
|
+
"new_information": {"required": ("record",), "optional": ()},
|
|
791
|
+
"dismiss": {"required": (), "optional": ("reason",)},
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _resolve_body(action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
796
|
+
"""Validate + build the resolve request body; raises ValueError with a
|
|
797
|
+
message the agent can act on (which field is missing/extraneous)."""
|
|
798
|
+
spec = _RESOLVE_ACTIONS.get(action)
|
|
799
|
+
if spec is None:
|
|
800
|
+
raise ValueError(
|
|
801
|
+
f"unknown action {action!r}; expected one of {sorted(_RESOLVE_ACTIONS)}"
|
|
802
|
+
)
|
|
803
|
+
allowed = set(spec["required"]) | set(spec["optional"])
|
|
804
|
+
provided = {k for k, v in kwargs.items() if v is not None}
|
|
805
|
+
missing = [k for k in spec["required"] if k not in provided]
|
|
806
|
+
if missing:
|
|
807
|
+
raise ValueError(f"action {action!r} requires {missing}")
|
|
808
|
+
extraneous = sorted(provided - allowed)
|
|
809
|
+
if extraneous:
|
|
810
|
+
raise ValueError(
|
|
811
|
+
f"action {action!r} does not accept {extraneous} (allowed: {sorted(allowed)})"
|
|
812
|
+
)
|
|
813
|
+
body: dict[str, Any] = {"action": action}
|
|
814
|
+
for k in allowed:
|
|
815
|
+
if kwargs.get(k) is not None:
|
|
816
|
+
body[k] = kwargs[k]
|
|
817
|
+
if action in ("pick", "dismiss"):
|
|
818
|
+
body.setdefault("reason", "")
|
|
819
|
+
return body
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@mcp.tool()
|
|
823
|
+
async def list_conflicts(
|
|
824
|
+
tenant_id: str | None = None,
|
|
825
|
+
status: str = "open",
|
|
826
|
+
limit: int = 20,
|
|
827
|
+
) -> str:
|
|
828
|
+
"""List detected memory conflicts (contradicting values for the same
|
|
829
|
+
claim). Maps to GET /v1/conflicts.
|
|
830
|
+
|
|
831
|
+
Parameters
|
|
832
|
+
----------
|
|
833
|
+
tenant_id:
|
|
834
|
+
Scope override (defaults to the configured scope).
|
|
835
|
+
status:
|
|
836
|
+
"open" (default) for unresolved conflicts only, or "all".
|
|
837
|
+
limit:
|
|
838
|
+
Max conflicts to return.
|
|
839
|
+
"""
|
|
840
|
+
scope = _scope_for(tenant_id)
|
|
841
|
+
result = await _request(
|
|
842
|
+
"GET",
|
|
843
|
+
"/v1/conflicts",
|
|
844
|
+
params={"scope": scope, "status": status, "limit": str(limit)},
|
|
845
|
+
)
|
|
846
|
+
conflicts = result.get("conflicts", [])
|
|
847
|
+
total = result.get("total", len(conflicts))
|
|
848
|
+
if not conflicts:
|
|
849
|
+
qualifier = "open " if status != "all" else ""
|
|
850
|
+
return f"No {qualifier}conflicts in this scope."
|
|
851
|
+
parts: list[str] = [f"Showing {len(conflicts)} of {total} conflict(s):\n"]
|
|
852
|
+
for c in conflicts:
|
|
853
|
+
parts.extend(_conflict_lines(c))
|
|
854
|
+
parts.append("")
|
|
855
|
+
next_cursor = result.get("next_cursor")
|
|
856
|
+
if next_cursor is not None:
|
|
857
|
+
parts.append(f"More available (cursor={next_cursor}).")
|
|
858
|
+
return "\n".join(parts).rstrip()
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
@mcp.tool()
|
|
862
|
+
async def resolve_conflict(
|
|
863
|
+
conflict_id: str,
|
|
864
|
+
action: str,
|
|
865
|
+
winner: str | None = None,
|
|
866
|
+
reason: str | None = None,
|
|
867
|
+
boundary: str | None = None,
|
|
868
|
+
earlier: str | None = None,
|
|
869
|
+
later: str | None = None,
|
|
870
|
+
record: str | None = None,
|
|
871
|
+
) -> str:
|
|
872
|
+
"""Resolve a memory conflict. Maps to POST /v1/conflicts/{id}/resolve.
|
|
873
|
+
|
|
874
|
+
Parameters
|
|
875
|
+
----------
|
|
876
|
+
conflict_id:
|
|
877
|
+
The conflict UUID (from list_conflicts).
|
|
878
|
+
action:
|
|
879
|
+
One of:
|
|
880
|
+
- "pick": one record wins outright — requires `winner` (record
|
|
881
|
+
UUID); `reason` optional.
|
|
882
|
+
- "split": both values were true at different times — requires
|
|
883
|
+
`boundary` (absolute date, e.g. "2024-03"), `earlier` and
|
|
884
|
+
`later` (record UUIDs).
|
|
885
|
+
- "new_information": the record supersedes the older value —
|
|
886
|
+
requires `record` (record UUID).
|
|
887
|
+
- "dismiss": not a real conflict — `reason` optional.
|
|
888
|
+
"""
|
|
889
|
+
try:
|
|
890
|
+
body = _resolve_body(
|
|
891
|
+
action,
|
|
892
|
+
{
|
|
893
|
+
"winner": winner,
|
|
894
|
+
"reason": reason,
|
|
895
|
+
"boundary": boundary,
|
|
896
|
+
"earlier": earlier,
|
|
897
|
+
"later": later,
|
|
898
|
+
"record": record,
|
|
899
|
+
},
|
|
900
|
+
)
|
|
901
|
+
except ValueError as exc:
|
|
902
|
+
return f"Invalid resolution: {exc}"
|
|
903
|
+
result = await _request(
|
|
904
|
+
"POST", f"/v1/conflicts/{conflict_id}/resolve", json_body=body
|
|
905
|
+
)
|
|
906
|
+
if not result.get("id"):
|
|
907
|
+
return f"Conflict {conflict_id} resolved ({action})."
|
|
908
|
+
parts = [f"Conflict resolved ({action}):", ""]
|
|
909
|
+
parts.extend(_conflict_lines(result))
|
|
910
|
+
return "\n".join(parts)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@mcp.tool()
|
|
914
|
+
async def claim_history(
|
|
915
|
+
subject: str,
|
|
916
|
+
predicate: str,
|
|
917
|
+
tenant_id: str | None = None,
|
|
918
|
+
) -> str:
|
|
919
|
+
"""Show the full bi-temporal change-log of one claim — every valid
|
|
920
|
+
interval and every record-time correction, plus any conflicts involving
|
|
921
|
+
it. Maps to GET /v1/claims/history.
|
|
922
|
+
|
|
923
|
+
Parameters
|
|
924
|
+
----------
|
|
925
|
+
subject:
|
|
926
|
+
The claim's subject entity (e.g. "Alice").
|
|
927
|
+
predicate:
|
|
928
|
+
The claim's predicate (e.g. "works_at").
|
|
929
|
+
tenant_id:
|
|
930
|
+
Scope override.
|
|
931
|
+
"""
|
|
932
|
+
scope = _scope_for(tenant_id)
|
|
933
|
+
result = await _request(
|
|
934
|
+
"GET",
|
|
935
|
+
"/v1/claims/history",
|
|
936
|
+
params={"scope": scope, "subject": subject, "predicate": predicate},
|
|
937
|
+
)
|
|
938
|
+
claim = result.get("claim", {}) or {}
|
|
939
|
+
history = result.get("history", []) or []
|
|
940
|
+
conflicts = result.get("conflicts", []) or []
|
|
941
|
+
if not history:
|
|
942
|
+
return f"No recorded history for claim '{subject} {predicate}'."
|
|
943
|
+
parts: list[str] = [
|
|
944
|
+
f"History of {claim.get('subject', subject)} "
|
|
945
|
+
f"{claim.get('predicate', predicate)} "
|
|
946
|
+
f"({len(history)} record(s)):\n"
|
|
947
|
+
]
|
|
948
|
+
for r in history:
|
|
949
|
+
parts.append(f" {_record_text(r)}")
|
|
950
|
+
if conflicts:
|
|
951
|
+
parts.append(f"\n{len(conflicts)} conflict(s) on this claim:")
|
|
952
|
+
for c in conflicts:
|
|
953
|
+
for line in _conflict_lines(c):
|
|
954
|
+
parts.append(f" {line}")
|
|
955
|
+
return "\n".join(parts)
|
|
956
|
+
|
|
957
|
+
|
|
725
958
|
# ===========================================================================
|
|
726
959
|
# TOOLS — Admin & Observability
|
|
727
960
|
# ===========================================================================
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
"""Live-instance smoke test for the v1 MCP server.
|
|
2
|
-
|
|
3
|
-
Hits a real CortexDB v1 deployment. Skipped by default — set
|
|
4
|
-
``CORTEXDB_LIVE=1`` to run.
|
|
5
|
-
|
|
6
|
-
Defaults to ``https://api-v1.cortexdb.ai`` and anonymous signup so the
|
|
7
|
-
test needs no pre-existing API key. Override with the usual env vars
|
|
8
|
-
(``CORTEXDB_URL``, ``CORTEXDB_API_KEY``, ``CORTEXDB_ACTOR``,
|
|
9
|
-
``CORTEXDB_SCOPE``) when targeting a private deployment.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import os
|
|
15
|
-
import time
|
|
16
|
-
|
|
17
|
-
import pytest
|
|
18
|
-
|
|
19
|
-
# Hard gate — never runs in normal CI.
|
|
20
|
-
if not os.environ.get("CORTEXDB_LIVE"):
|
|
21
|
-
pytest.skip("set CORTEXDB_LIVE=1 to run", allow_module_level=True)
|
|
22
|
-
|
|
23
|
-
# Stash a real API URL into env BEFORE importing the server module, since
|
|
24
|
-
# `_config` is built at import time.
|
|
25
|
-
os.environ.setdefault("CORTEXDB_URL", "https://api-v1.cortexdb.ai")
|
|
26
|
-
|
|
27
|
-
from cortexdb_mcp.server import ( # noqa: E402
|
|
28
|
-
health_check,
|
|
29
|
-
memory_list,
|
|
30
|
-
memory_search,
|
|
31
|
-
memory_store,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@pytest.mark.asyncio
|
|
36
|
-
async def test_health_check_returns_caller():
|
|
37
|
-
"""Health check proves the bearer works AND the deployment is reachable."""
|
|
38
|
-
out = await health_check()
|
|
39
|
-
assert "caller" in out
|
|
40
|
-
assert "deployment_preset" in out
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@pytest.mark.asyncio
|
|
44
|
-
async def test_store_then_search_roundtrip():
|
|
45
|
-
"""Full happy path: store a memory, wait for indexing, search it back."""
|
|
46
|
-
marker = f"mcp-live-{int(time.time())}"
|
|
47
|
-
store_out = await memory_store(
|
|
48
|
-
content=f"MCP integration test event {marker}",
|
|
49
|
-
source="mcp-integration-test",
|
|
50
|
-
)
|
|
51
|
-
assert "Stored successfully" in store_out
|
|
52
|
-
|
|
53
|
-
# Give the v1 pipeline a beat to index. Async enrichment finishes
|
|
54
|
-
# in ~4-10s; 15s margin keeps the test stable.
|
|
55
|
-
time.sleep(15)
|
|
56
|
-
|
|
57
|
-
search_out = await memory_search(query=marker, max_results=5)
|
|
58
|
-
# The marker text should round-trip through recall.
|
|
59
|
-
assert marker in search_out or "No results" in search_out
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@pytest.mark.asyncio
|
|
63
|
-
async def test_memory_list_returns_recent():
|
|
64
|
-
"""List endpoint surfaces events the caller can see."""
|
|
65
|
-
out = await memory_list(limit=3)
|
|
66
|
-
# Either some events or the empty-list message — both are healthy.
|
|
67
|
-
assert isinstance(out, str)
|
|
1
|
+
"""Live-instance smoke test for the v1 MCP server.
|
|
2
|
+
|
|
3
|
+
Hits a real CortexDB v1 deployment. Skipped by default — set
|
|
4
|
+
``CORTEXDB_LIVE=1`` to run.
|
|
5
|
+
|
|
6
|
+
Defaults to ``https://api-v1.cortexdb.ai`` and anonymous signup so the
|
|
7
|
+
test needs no pre-existing API key. Override with the usual env vars
|
|
8
|
+
(``CORTEXDB_URL``, ``CORTEXDB_API_KEY``, ``CORTEXDB_ACTOR``,
|
|
9
|
+
``CORTEXDB_SCOPE``) when targeting a private deployment.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
# Hard gate — never runs in normal CI.
|
|
20
|
+
if not os.environ.get("CORTEXDB_LIVE"):
|
|
21
|
+
pytest.skip("set CORTEXDB_LIVE=1 to run", allow_module_level=True)
|
|
22
|
+
|
|
23
|
+
# Stash a real API URL into env BEFORE importing the server module, since
|
|
24
|
+
# `_config` is built at import time.
|
|
25
|
+
os.environ.setdefault("CORTEXDB_URL", "https://api-v1.cortexdb.ai")
|
|
26
|
+
|
|
27
|
+
from cortexdb_mcp.server import ( # noqa: E402
|
|
28
|
+
health_check,
|
|
29
|
+
memory_list,
|
|
30
|
+
memory_search,
|
|
31
|
+
memory_store,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_health_check_returns_caller():
|
|
37
|
+
"""Health check proves the bearer works AND the deployment is reachable."""
|
|
38
|
+
out = await health_check()
|
|
39
|
+
assert "caller" in out
|
|
40
|
+
assert "deployment_preset" in out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_store_then_search_roundtrip():
|
|
45
|
+
"""Full happy path: store a memory, wait for indexing, search it back."""
|
|
46
|
+
marker = f"mcp-live-{int(time.time())}"
|
|
47
|
+
store_out = await memory_store(
|
|
48
|
+
content=f"MCP integration test event {marker}",
|
|
49
|
+
source="mcp-integration-test",
|
|
50
|
+
)
|
|
51
|
+
assert "Stored successfully" in store_out
|
|
52
|
+
|
|
53
|
+
# Give the v1 pipeline a beat to index. Async enrichment finishes
|
|
54
|
+
# in ~4-10s; 15s margin keeps the test stable.
|
|
55
|
+
time.sleep(15)
|
|
56
|
+
|
|
57
|
+
search_out = await memory_search(query=marker, max_results=5)
|
|
58
|
+
# The marker text should round-trip through recall.
|
|
59
|
+
assert marker in search_out or "No results" in search_out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_memory_list_returns_recent():
|
|
64
|
+
"""List endpoint surfaces events the caller can see."""
|
|
65
|
+
out = await memory_list(limit=3)
|
|
66
|
+
# Either some events or the empty-list message — both are healthy.
|
|
67
|
+
assert isinstance(out, str)
|
|
@@ -15,14 +15,18 @@ import pytest
|
|
|
15
15
|
|
|
16
16
|
from cortexdb_mcp.config import CortexMCPConfig
|
|
17
17
|
from cortexdb_mcp.insights import Insight, InsightsEngine, InsightType, Severity
|
|
18
|
+
from cortexdb_mcp.render import record_validity_text
|
|
18
19
|
from cortexdb_mcp.server import (
|
|
19
20
|
_belief_text,
|
|
20
21
|
_config,
|
|
22
|
+
_conflict_lines,
|
|
21
23
|
_fact_text,
|
|
22
24
|
_is_synthetic_subject,
|
|
23
25
|
_memories_from_context,
|
|
26
|
+
_record_text,
|
|
24
27
|
_typed_value_text,
|
|
25
28
|
advanced_search,
|
|
29
|
+
claim_history,
|
|
26
30
|
entity_edges,
|
|
27
31
|
entity_get,
|
|
28
32
|
entity_link,
|
|
@@ -30,13 +34,16 @@ from cortexdb_mcp.server import (
|
|
|
30
34
|
get_context,
|
|
31
35
|
get_insights,
|
|
32
36
|
health_check,
|
|
37
|
+
list_conflicts,
|
|
33
38
|
memory_bulk_delete,
|
|
34
39
|
memory_delete,
|
|
35
40
|
memory_forget,
|
|
36
41
|
memory_get,
|
|
37
42
|
memory_list,
|
|
38
43
|
memory_search,
|
|
44
|
+
memory_status,
|
|
39
45
|
memory_store,
|
|
46
|
+
resolve_conflict,
|
|
40
47
|
)
|
|
41
48
|
|
|
42
49
|
|
|
@@ -149,6 +156,55 @@ class TestMemoryStore:
|
|
|
149
156
|
assert "observed_at" in body["context"]
|
|
150
157
|
assert body["idempotency_key"].startswith("mcp-")
|
|
151
158
|
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_memory_status_by_event_id(self):
|
|
161
|
+
patcher, mock = _mock_request(
|
|
162
|
+
{
|
|
163
|
+
"found": True,
|
|
164
|
+
"event_id": "evt_123",
|
|
165
|
+
"status": "indexed",
|
|
166
|
+
"stages_completed": ["captured", "indexed"],
|
|
167
|
+
"note": "stored and queryable",
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
with patcher:
|
|
171
|
+
result = await memory_status(event_id="evt_123")
|
|
172
|
+
assert "indexed" in result
|
|
173
|
+
assert "evt_123" in result
|
|
174
|
+
method, path = mock.call_args[0][:2]
|
|
175
|
+
assert method == "GET"
|
|
176
|
+
assert path == "/v1/experience/status"
|
|
177
|
+
assert mock.call_args[1]["params"] == {"event_id": "evt_123"}
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_memory_status_failed_says_restore_is_safe(self):
|
|
181
|
+
patcher, _ = _mock_request(
|
|
182
|
+
{
|
|
183
|
+
"found": True,
|
|
184
|
+
"event_id": "evt_bad",
|
|
185
|
+
"status": "failed",
|
|
186
|
+
"stages_completed": ["captured"],
|
|
187
|
+
"failure_reason": "embedding provider unavailable",
|
|
188
|
+
"note": "…",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
with patcher:
|
|
192
|
+
result = await memory_status(idempotency_key="k-1")
|
|
193
|
+
assert "failed" in result
|
|
194
|
+
assert "re-store" in result.lower()
|
|
195
|
+
|
|
196
|
+
@pytest.mark.asyncio
|
|
197
|
+
async def test_memory_status_not_found_is_safe_to_retry(self):
|
|
198
|
+
patcher, _ = _mock_request({"found": False, "note": "…"})
|
|
199
|
+
with patcher:
|
|
200
|
+
result = await memory_status(idempotency_key="never-sent")
|
|
201
|
+
assert "safe" in result.lower()
|
|
202
|
+
|
|
203
|
+
@pytest.mark.asyncio
|
|
204
|
+
async def test_memory_status_requires_an_argument(self):
|
|
205
|
+
result = await memory_status()
|
|
206
|
+
assert "event_id" in result
|
|
207
|
+
|
|
152
208
|
@pytest.mark.asyncio
|
|
153
209
|
async def test_source_and_tags_become_labels(self):
|
|
154
210
|
patcher, mock = _mock_request({"event_id": "evt_456"})
|
|
@@ -432,6 +488,252 @@ class TestEntities:
|
|
|
432
488
|
assert "evt_link" in out
|
|
433
489
|
|
|
434
490
|
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# Bi-temporal conflicts & claim history
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _record(
|
|
497
|
+
obj="Google",
|
|
498
|
+
*,
|
|
499
|
+
valid_from="2020-01-01T00:00:00Z",
|
|
500
|
+
vf_basis="stated",
|
|
501
|
+
valid_to=None,
|
|
502
|
+
vt_basis="stated",
|
|
503
|
+
status="active",
|
|
504
|
+
rid="0197a3a0-0000-7000-8000-00000000000a",
|
|
505
|
+
):
|
|
506
|
+
return {
|
|
507
|
+
"id": rid,
|
|
508
|
+
"subject": "alice",
|
|
509
|
+
"predicate": "works_at",
|
|
510
|
+
"object": obj,
|
|
511
|
+
"redacted": False,
|
|
512
|
+
"valid_from": valid_from,
|
|
513
|
+
"valid_from_basis": vf_basis,
|
|
514
|
+
"valid_to": valid_to,
|
|
515
|
+
"valid_to_basis": vt_basis,
|
|
516
|
+
"recorded_from": "2026-06-15T12:00:00Z",
|
|
517
|
+
"recorded_to": None,
|
|
518
|
+
"status": status,
|
|
519
|
+
"supersedes": None,
|
|
520
|
+
"source_authority": "extraction",
|
|
521
|
+
"extractor": "llm",
|
|
522
|
+
"confidence": 0.9,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _conflict(status="open", records=None, cid="0197a3a0-0000-7000-8000-0000000000c1"):
|
|
527
|
+
return {
|
|
528
|
+
"id": cid,
|
|
529
|
+
"scope": "org:test/user:test",
|
|
530
|
+
"subject": "alice",
|
|
531
|
+
"predicate": "works_at",
|
|
532
|
+
"kind": "value_disagreement",
|
|
533
|
+
"status": status,
|
|
534
|
+
"records": records if records is not None else [
|
|
535
|
+
_record("Google"),
|
|
536
|
+
_record("Meta", rid="0197a3a0-0000-7000-8000-00000000000b"),
|
|
537
|
+
],
|
|
538
|
+
"detected_at": "2026-06-15T12:00:00Z",
|
|
539
|
+
"resolved_at": None,
|
|
540
|
+
"resolved_by": None,
|
|
541
|
+
"resolution": None,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class TestRecordRendering:
|
|
546
|
+
"""Lock the BoundaryBasis rule: observed boundaries render as
|
|
547
|
+
"by <date>", never "on <date>" / "since <date>" (§2)."""
|
|
548
|
+
|
|
549
|
+
def test_stated_boundaries_render_since_until(self):
|
|
550
|
+
r = _record(valid_from="2020-01-01T00:00:00Z",
|
|
551
|
+
valid_to="2022-06-01T00:00:00Z")
|
|
552
|
+
out = record_validity_text(r)
|
|
553
|
+
assert out == "since 2020-01-01, until 2022-06-01"
|
|
554
|
+
|
|
555
|
+
def test_observed_valid_from_renders_by_never_since(self):
|
|
556
|
+
r = _record(vf_basis="observed")
|
|
557
|
+
out = record_validity_text(r)
|
|
558
|
+
assert "held by 2020-01-01" in out
|
|
559
|
+
assert "since" not in out
|
|
560
|
+
assert "on 2020" not in out
|
|
561
|
+
|
|
562
|
+
def test_observed_valid_to_renders_ended_by_never_until(self):
|
|
563
|
+
r = _record(valid_to="2022-06-01T00:00:00Z", vt_basis="observed")
|
|
564
|
+
out = record_validity_text(r)
|
|
565
|
+
assert "ended by 2022-06-01" in out
|
|
566
|
+
assert "until" not in out
|
|
567
|
+
|
|
568
|
+
def test_unbounded_is_undated(self):
|
|
569
|
+
assert record_validity_text(_record(valid_from=None)) == "undated"
|
|
570
|
+
|
|
571
|
+
def test_record_text_value_and_metadata(self):
|
|
572
|
+
out = _record_text(_record("Google"))
|
|
573
|
+
assert out.startswith("Google (")
|
|
574
|
+
assert "recorded 2026-06-15" in out
|
|
575
|
+
assert "conf 90%" in out
|
|
576
|
+
assert "active" not in out # only non-active statuses shown
|
|
577
|
+
|
|
578
|
+
def test_record_text_redacted(self):
|
|
579
|
+
r = _record("secret")
|
|
580
|
+
r["redacted"] = True
|
|
581
|
+
assert _record_text(r).startswith("[redacted]")
|
|
582
|
+
|
|
583
|
+
def test_conflict_lines_header_and_members(self):
|
|
584
|
+
lines = _conflict_lines(_conflict())
|
|
585
|
+
assert "[value_disagreement] alice works_at — open" in lines[0]
|
|
586
|
+
assert any("Google" in line for line in lines)
|
|
587
|
+
assert any("Meta" in line for line in lines)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class TestListConflicts:
|
|
591
|
+
@pytest.mark.asyncio
|
|
592
|
+
async def test_list_hits_v1_conflicts_with_scope(self):
|
|
593
|
+
patcher, mock = _mock_request({
|
|
594
|
+
"conflicts": [_conflict()], "total": 1, "next_cursor": None,
|
|
595
|
+
})
|
|
596
|
+
with patcher:
|
|
597
|
+
out = await list_conflicts()
|
|
598
|
+
method, path = mock.call_args[0][:2]
|
|
599
|
+
assert method == "GET"
|
|
600
|
+
assert path == "/v1/conflicts"
|
|
601
|
+
params = mock.call_args[1]["params"]
|
|
602
|
+
assert params["scope"] == "org:test/user:test"
|
|
603
|
+
assert params["status"] == "open"
|
|
604
|
+
assert "value_disagreement" in out
|
|
605
|
+
assert "Google" in out and "Meta" in out
|
|
606
|
+
|
|
607
|
+
@pytest.mark.asyncio
|
|
608
|
+
async def test_observed_basis_renders_by_in_tool_output(self):
|
|
609
|
+
c = _conflict(records=[_record("Google", vf_basis="observed")])
|
|
610
|
+
patcher, _ = _mock_request({"conflicts": [c], "total": 1, "next_cursor": None})
|
|
611
|
+
with patcher:
|
|
612
|
+
out = await list_conflicts()
|
|
613
|
+
assert "by 2020-01-01" in out
|
|
614
|
+
assert "since 2020-01-01" not in out
|
|
615
|
+
|
|
616
|
+
@pytest.mark.asyncio
|
|
617
|
+
async def test_empty_queue(self):
|
|
618
|
+
patcher, _ = _mock_request({"conflicts": [], "total": 0, "next_cursor": None})
|
|
619
|
+
with patcher:
|
|
620
|
+
out = await list_conflicts()
|
|
621
|
+
assert "No open conflicts" in out
|
|
622
|
+
|
|
623
|
+
@pytest.mark.asyncio
|
|
624
|
+
async def test_pagination_hint(self):
|
|
625
|
+
patcher, mock = _mock_request({
|
|
626
|
+
"conflicts": [_conflict()], "total": 5, "next_cursor": 1,
|
|
627
|
+
})
|
|
628
|
+
with patcher:
|
|
629
|
+
out = await list_conflicts(status="all", limit=1)
|
|
630
|
+
assert mock.call_args[1]["params"]["status"] == "all"
|
|
631
|
+
assert "cursor=1" in out
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class TestResolveConflict:
|
|
635
|
+
@pytest.mark.asyncio
|
|
636
|
+
async def test_pick_posts_tagged_body(self):
|
|
637
|
+
patcher, mock = _mock_request(_conflict(status="manually_resolved"))
|
|
638
|
+
with patcher:
|
|
639
|
+
out = await resolve_conflict(
|
|
640
|
+
conflict_id="c1", action="pick",
|
|
641
|
+
winner="0197a3a0-0000-7000-8000-00000000000b",
|
|
642
|
+
reason="user confirmed",
|
|
643
|
+
)
|
|
644
|
+
method, path = mock.call_args[0][:2]
|
|
645
|
+
assert method == "POST"
|
|
646
|
+
assert path == "/v1/conflicts/c1/resolve"
|
|
647
|
+
body = mock.call_args[1]["json_body"]
|
|
648
|
+
assert body == {
|
|
649
|
+
"action": "pick",
|
|
650
|
+
"winner": "0197a3a0-0000-7000-8000-00000000000b",
|
|
651
|
+
"reason": "user confirmed",
|
|
652
|
+
}
|
|
653
|
+
assert "manually_resolved" in out
|
|
654
|
+
|
|
655
|
+
@pytest.mark.asyncio
|
|
656
|
+
async def test_split_requires_all_fields(self):
|
|
657
|
+
patcher, mock = _mock_request({})
|
|
658
|
+
with patcher:
|
|
659
|
+
out = await resolve_conflict(
|
|
660
|
+
conflict_id="c1", action="split", boundary="2024-03",
|
|
661
|
+
)
|
|
662
|
+
assert "Invalid resolution" in out
|
|
663
|
+
assert "earlier" in out
|
|
664
|
+
mock.assert_not_called() # nothing hit the wire
|
|
665
|
+
|
|
666
|
+
@pytest.mark.asyncio
|
|
667
|
+
async def test_unknown_action_and_extraneous_args(self):
|
|
668
|
+
patcher, mock = _mock_request({})
|
|
669
|
+
with patcher:
|
|
670
|
+
out1 = await resolve_conflict(conflict_id="c1", action="merge")
|
|
671
|
+
out2 = await resolve_conflict(
|
|
672
|
+
conflict_id="c1", action="dismiss", winner="r1",
|
|
673
|
+
)
|
|
674
|
+
assert "unknown action" in out1
|
|
675
|
+
assert "does not accept" in out2
|
|
676
|
+
mock.assert_not_called()
|
|
677
|
+
|
|
678
|
+
@pytest.mark.asyncio
|
|
679
|
+
async def test_dismiss_defaults_reason(self):
|
|
680
|
+
patcher, mock = _mock_request(_conflict(status="dismissed"))
|
|
681
|
+
with patcher:
|
|
682
|
+
await resolve_conflict(conflict_id="c1", action="dismiss")
|
|
683
|
+
assert mock.call_args[1]["json_body"] == {"action": "dismiss", "reason": ""}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class TestClaimHistory:
|
|
687
|
+
@pytest.mark.asyncio
|
|
688
|
+
async def test_history_hits_endpoint_and_renders_records(self):
|
|
689
|
+
patcher, mock = _mock_request({
|
|
690
|
+
"claim": {"scope": "org:test/user:test", "subject": "alice",
|
|
691
|
+
"predicate": "works_at"},
|
|
692
|
+
"history": [
|
|
693
|
+
_record("Google", valid_to="2022-06-01T00:00:00Z",
|
|
694
|
+
vt_basis="observed"),
|
|
695
|
+
_record("Meta", valid_from="2022-06-01T00:00:00Z",
|
|
696
|
+
vf_basis="observed",
|
|
697
|
+
rid="0197a3a0-0000-7000-8000-00000000000b"),
|
|
698
|
+
],
|
|
699
|
+
"conflicts": [],
|
|
700
|
+
})
|
|
701
|
+
with patcher:
|
|
702
|
+
out = await claim_history(subject="Alice", predicate="works_at")
|
|
703
|
+
method, path = mock.call_args[0][:2]
|
|
704
|
+
assert method == "GET"
|
|
705
|
+
assert path == "/v1/claims/history"
|
|
706
|
+
params = mock.call_args[1]["params"]
|
|
707
|
+
assert params == {
|
|
708
|
+
"scope": "org:test/user:test",
|
|
709
|
+
"subject": "Alice",
|
|
710
|
+
"predicate": "works_at",
|
|
711
|
+
}
|
|
712
|
+
# Observed boundaries render as "by", never "since"/"until".
|
|
713
|
+
assert "ended by 2022-06-01" in out
|
|
714
|
+
assert "held by 2022-06-01" in out
|
|
715
|
+
assert "2 record(s)" in out
|
|
716
|
+
|
|
717
|
+
@pytest.mark.asyncio
|
|
718
|
+
async def test_history_lists_conflicts(self):
|
|
719
|
+
patcher, _ = _mock_request({
|
|
720
|
+
"claim": {"subject": "alice", "predicate": "works_at"},
|
|
721
|
+
"history": [_record()],
|
|
722
|
+
"conflicts": [_conflict()],
|
|
723
|
+
})
|
|
724
|
+
with patcher:
|
|
725
|
+
out = await claim_history(subject="alice", predicate="works_at")
|
|
726
|
+
assert "1 conflict(s)" in out
|
|
727
|
+
assert "value_disagreement" in out
|
|
728
|
+
|
|
729
|
+
@pytest.mark.asyncio
|
|
730
|
+
async def test_history_empty(self):
|
|
731
|
+
patcher, _ = _mock_request({"claim": {}, "history": [], "conflicts": []})
|
|
732
|
+
with patcher:
|
|
733
|
+
out = await claim_history(subject="nobody", predicate="none")
|
|
734
|
+
assert "No recorded history" in out
|
|
735
|
+
|
|
736
|
+
|
|
435
737
|
# ---------------------------------------------------------------------------
|
|
436
738
|
# Admin tools
|
|
437
739
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|