cortexdb-mcp 0.3.3__tar.gz → 0.4.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.4.0}/.gitignore +3 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/PKG-INFO +11 -1
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/README.md +10 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/render.py +62 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/server.py +182 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/pyproject.toml +1 -1
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/tests/test_server.py +252 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/Dockerfile +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/__init__.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/__main__.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/api.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/config.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/cortexdb_mcp/insights.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/tests/__init__.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/tests/test_insights.py +0 -0
- {cortexdb_mcp-0.3.3 → cortexdb_mcp-0.4.0}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -180,6 +180,16 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
180
180
|
| `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
|
|
181
181
|
| `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
|
|
182
182
|
|
|
183
|
+
### Bi-temporal Conflicts & Claim History
|
|
184
|
+
|
|
185
|
+
| Tool | Maps to | Description |
|
|
186
|
+
|------|---------|-------------|
|
|
187
|
+
| `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
|
|
188
|
+
| `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
|
|
189
|
+
| `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
|
|
190
|
+
|
|
191
|
+
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\>".
|
|
192
|
+
|
|
183
193
|
### Admin & Observability
|
|
184
194
|
|
|
185
195
|
| Tool | Maps to | Description |
|
|
@@ -167,6 +167,16 @@ On Windows, MCP clients sometimes need the absolute path:
|
|
|
167
167
|
| `entity_edges` | `GET /v1/facts?subject=…` | Predicate/object pairs for an entity. |
|
|
168
168
|
| `entity_link` | `POST /v1/experience` | Store a sentence the extractor will turn into a fact. |
|
|
169
169
|
|
|
170
|
+
### Bi-temporal Conflicts & Claim History
|
|
171
|
+
|
|
172
|
+
| Tool | Maps to | Description |
|
|
173
|
+
|------|---------|-------------|
|
|
174
|
+
| `list_conflicts` | `GET /v1/conflicts` | Queue of contradicting values detected for the same claim. |
|
|
175
|
+
| `resolve_conflict` | `POST /v1/conflicts/{id}/resolve` | Resolve: `pick` / `split` / `new_information` / `dismiss`. |
|
|
176
|
+
| `claim_history` | `GET /v1/claims/history` | Full bi-temporal change-log of one claim (+ its conflicts). |
|
|
177
|
+
|
|
178
|
+
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\>".
|
|
179
|
+
|
|
170
180
|
### Admin & Observability
|
|
171
181
|
|
|
172
182
|
| 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
|
|
|
@@ -722,6 +724,186 @@ async def entity_link(
|
|
|
722
724
|
)
|
|
723
725
|
|
|
724
726
|
|
|
727
|
+
# ===========================================================================
|
|
728
|
+
# TOOLS — Bi-temporal conflicts & claim history
|
|
729
|
+
# ===========================================================================
|
|
730
|
+
#
|
|
731
|
+
# Rendering honors BoundaryBasis (BITEMPORAL_CONFLICT_DESIGN §2): boundaries
|
|
732
|
+
# whose basis is "observed" render as "by <date>" (via render.record_text),
|
|
733
|
+
# never "on/since <date>".
|
|
734
|
+
|
|
735
|
+
# Per-action argument contract for POST /v1/conflicts/{id}/resolve.
|
|
736
|
+
_RESOLVE_ACTIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
|
737
|
+
"pick": {"required": ("winner",), "optional": ("reason",)},
|
|
738
|
+
"split": {"required": ("boundary", "earlier", "later"), "optional": ()},
|
|
739
|
+
"new_information": {"required": ("record",), "optional": ()},
|
|
740
|
+
"dismiss": {"required": (), "optional": ("reason",)},
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _resolve_body(action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
745
|
+
"""Validate + build the resolve request body; raises ValueError with a
|
|
746
|
+
message the agent can act on (which field is missing/extraneous)."""
|
|
747
|
+
spec = _RESOLVE_ACTIONS.get(action)
|
|
748
|
+
if spec is None:
|
|
749
|
+
raise ValueError(
|
|
750
|
+
f"unknown action {action!r}; expected one of {sorted(_RESOLVE_ACTIONS)}"
|
|
751
|
+
)
|
|
752
|
+
allowed = set(spec["required"]) | set(spec["optional"])
|
|
753
|
+
provided = {k for k, v in kwargs.items() if v is not None}
|
|
754
|
+
missing = [k for k in spec["required"] if k not in provided]
|
|
755
|
+
if missing:
|
|
756
|
+
raise ValueError(f"action {action!r} requires {missing}")
|
|
757
|
+
extraneous = sorted(provided - allowed)
|
|
758
|
+
if extraneous:
|
|
759
|
+
raise ValueError(
|
|
760
|
+
f"action {action!r} does not accept {extraneous} (allowed: {sorted(allowed)})"
|
|
761
|
+
)
|
|
762
|
+
body: dict[str, Any] = {"action": action}
|
|
763
|
+
for k in allowed:
|
|
764
|
+
if kwargs.get(k) is not None:
|
|
765
|
+
body[k] = kwargs[k]
|
|
766
|
+
if action in ("pick", "dismiss"):
|
|
767
|
+
body.setdefault("reason", "")
|
|
768
|
+
return body
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@mcp.tool()
|
|
772
|
+
async def list_conflicts(
|
|
773
|
+
tenant_id: str | None = None,
|
|
774
|
+
status: str = "open",
|
|
775
|
+
limit: int = 20,
|
|
776
|
+
) -> str:
|
|
777
|
+
"""List detected memory conflicts (contradicting values for the same
|
|
778
|
+
claim). Maps to GET /v1/conflicts.
|
|
779
|
+
|
|
780
|
+
Parameters
|
|
781
|
+
----------
|
|
782
|
+
tenant_id:
|
|
783
|
+
Scope override (defaults to the configured scope).
|
|
784
|
+
status:
|
|
785
|
+
"open" (default) for unresolved conflicts only, or "all".
|
|
786
|
+
limit:
|
|
787
|
+
Max conflicts to return.
|
|
788
|
+
"""
|
|
789
|
+
scope = _scope_for(tenant_id)
|
|
790
|
+
result = await _request(
|
|
791
|
+
"GET",
|
|
792
|
+
"/v1/conflicts",
|
|
793
|
+
params={"scope": scope, "status": status, "limit": str(limit)},
|
|
794
|
+
)
|
|
795
|
+
conflicts = result.get("conflicts", [])
|
|
796
|
+
total = result.get("total", len(conflicts))
|
|
797
|
+
if not conflicts:
|
|
798
|
+
qualifier = "open " if status != "all" else ""
|
|
799
|
+
return f"No {qualifier}conflicts in this scope."
|
|
800
|
+
parts: list[str] = [f"Showing {len(conflicts)} of {total} conflict(s):\n"]
|
|
801
|
+
for c in conflicts:
|
|
802
|
+
parts.extend(_conflict_lines(c))
|
|
803
|
+
parts.append("")
|
|
804
|
+
next_cursor = result.get("next_cursor")
|
|
805
|
+
if next_cursor is not None:
|
|
806
|
+
parts.append(f"More available (cursor={next_cursor}).")
|
|
807
|
+
return "\n".join(parts).rstrip()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@mcp.tool()
|
|
811
|
+
async def resolve_conflict(
|
|
812
|
+
conflict_id: str,
|
|
813
|
+
action: str,
|
|
814
|
+
winner: str | None = None,
|
|
815
|
+
reason: str | None = None,
|
|
816
|
+
boundary: str | None = None,
|
|
817
|
+
earlier: str | None = None,
|
|
818
|
+
later: str | None = None,
|
|
819
|
+
record: str | None = None,
|
|
820
|
+
) -> str:
|
|
821
|
+
"""Resolve a memory conflict. Maps to POST /v1/conflicts/{id}/resolve.
|
|
822
|
+
|
|
823
|
+
Parameters
|
|
824
|
+
----------
|
|
825
|
+
conflict_id:
|
|
826
|
+
The conflict UUID (from list_conflicts).
|
|
827
|
+
action:
|
|
828
|
+
One of:
|
|
829
|
+
- "pick": one record wins outright — requires `winner` (record
|
|
830
|
+
UUID); `reason` optional.
|
|
831
|
+
- "split": both values were true at different times — requires
|
|
832
|
+
`boundary` (absolute date, e.g. "2024-03"), `earlier` and
|
|
833
|
+
`later` (record UUIDs).
|
|
834
|
+
- "new_information": the record supersedes the older value —
|
|
835
|
+
requires `record` (record UUID).
|
|
836
|
+
- "dismiss": not a real conflict — `reason` optional.
|
|
837
|
+
"""
|
|
838
|
+
try:
|
|
839
|
+
body = _resolve_body(
|
|
840
|
+
action,
|
|
841
|
+
{
|
|
842
|
+
"winner": winner,
|
|
843
|
+
"reason": reason,
|
|
844
|
+
"boundary": boundary,
|
|
845
|
+
"earlier": earlier,
|
|
846
|
+
"later": later,
|
|
847
|
+
"record": record,
|
|
848
|
+
},
|
|
849
|
+
)
|
|
850
|
+
except ValueError as exc:
|
|
851
|
+
return f"Invalid resolution: {exc}"
|
|
852
|
+
result = await _request(
|
|
853
|
+
"POST", f"/v1/conflicts/{conflict_id}/resolve", json_body=body
|
|
854
|
+
)
|
|
855
|
+
if not result.get("id"):
|
|
856
|
+
return f"Conflict {conflict_id} resolved ({action})."
|
|
857
|
+
parts = [f"Conflict resolved ({action}):", ""]
|
|
858
|
+
parts.extend(_conflict_lines(result))
|
|
859
|
+
return "\n".join(parts)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@mcp.tool()
|
|
863
|
+
async def claim_history(
|
|
864
|
+
subject: str,
|
|
865
|
+
predicate: str,
|
|
866
|
+
tenant_id: str | None = None,
|
|
867
|
+
) -> str:
|
|
868
|
+
"""Show the full bi-temporal change-log of one claim — every valid
|
|
869
|
+
interval and every record-time correction, plus any conflicts involving
|
|
870
|
+
it. Maps to GET /v1/claims/history.
|
|
871
|
+
|
|
872
|
+
Parameters
|
|
873
|
+
----------
|
|
874
|
+
subject:
|
|
875
|
+
The claim's subject entity (e.g. "Alice").
|
|
876
|
+
predicate:
|
|
877
|
+
The claim's predicate (e.g. "works_at").
|
|
878
|
+
tenant_id:
|
|
879
|
+
Scope override.
|
|
880
|
+
"""
|
|
881
|
+
scope = _scope_for(tenant_id)
|
|
882
|
+
result = await _request(
|
|
883
|
+
"GET",
|
|
884
|
+
"/v1/claims/history",
|
|
885
|
+
params={"scope": scope, "subject": subject, "predicate": predicate},
|
|
886
|
+
)
|
|
887
|
+
claim = result.get("claim", {}) or {}
|
|
888
|
+
history = result.get("history", []) or []
|
|
889
|
+
conflicts = result.get("conflicts", []) or []
|
|
890
|
+
if not history:
|
|
891
|
+
return f"No recorded history for claim '{subject} {predicate}'."
|
|
892
|
+
parts: list[str] = [
|
|
893
|
+
f"History of {claim.get('subject', subject)} "
|
|
894
|
+
f"{claim.get('predicate', predicate)} "
|
|
895
|
+
f"({len(history)} record(s)):\n"
|
|
896
|
+
]
|
|
897
|
+
for r in history:
|
|
898
|
+
parts.append(f" {_record_text(r)}")
|
|
899
|
+
if conflicts:
|
|
900
|
+
parts.append(f"\n{len(conflicts)} conflict(s) on this claim:")
|
|
901
|
+
for c in conflicts:
|
|
902
|
+
for line in _conflict_lines(c):
|
|
903
|
+
parts.append(f" {line}")
|
|
904
|
+
return "\n".join(parts)
|
|
905
|
+
|
|
906
|
+
|
|
725
907
|
# ===========================================================================
|
|
726
908
|
# TOOLS — Admin & Observability
|
|
727
909
|
# ===========================================================================
|
|
@@ -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,6 +34,7 @@ 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,
|
|
@@ -37,6 +42,7 @@ from cortexdb_mcp.server import (
|
|
|
37
42
|
memory_list,
|
|
38
43
|
memory_search,
|
|
39
44
|
memory_store,
|
|
45
|
+
resolve_conflict,
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
|
|
@@ -432,6 +438,252 @@ class TestEntities:
|
|
|
432
438
|
assert "evt_link" in out
|
|
433
439
|
|
|
434
440
|
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Bi-temporal conflicts & claim history
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _record(
|
|
447
|
+
obj="Google",
|
|
448
|
+
*,
|
|
449
|
+
valid_from="2020-01-01T00:00:00Z",
|
|
450
|
+
vf_basis="stated",
|
|
451
|
+
valid_to=None,
|
|
452
|
+
vt_basis="stated",
|
|
453
|
+
status="active",
|
|
454
|
+
rid="0197a3a0-0000-7000-8000-00000000000a",
|
|
455
|
+
):
|
|
456
|
+
return {
|
|
457
|
+
"id": rid,
|
|
458
|
+
"subject": "alice",
|
|
459
|
+
"predicate": "works_at",
|
|
460
|
+
"object": obj,
|
|
461
|
+
"redacted": False,
|
|
462
|
+
"valid_from": valid_from,
|
|
463
|
+
"valid_from_basis": vf_basis,
|
|
464
|
+
"valid_to": valid_to,
|
|
465
|
+
"valid_to_basis": vt_basis,
|
|
466
|
+
"recorded_from": "2026-06-15T12:00:00Z",
|
|
467
|
+
"recorded_to": None,
|
|
468
|
+
"status": status,
|
|
469
|
+
"supersedes": None,
|
|
470
|
+
"source_authority": "extraction",
|
|
471
|
+
"extractor": "llm",
|
|
472
|
+
"confidence": 0.9,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _conflict(status="open", records=None, cid="0197a3a0-0000-7000-8000-0000000000c1"):
|
|
477
|
+
return {
|
|
478
|
+
"id": cid,
|
|
479
|
+
"scope": "org:test/user:test",
|
|
480
|
+
"subject": "alice",
|
|
481
|
+
"predicate": "works_at",
|
|
482
|
+
"kind": "value_disagreement",
|
|
483
|
+
"status": status,
|
|
484
|
+
"records": records if records is not None else [
|
|
485
|
+
_record("Google"),
|
|
486
|
+
_record("Meta", rid="0197a3a0-0000-7000-8000-00000000000b"),
|
|
487
|
+
],
|
|
488
|
+
"detected_at": "2026-06-15T12:00:00Z",
|
|
489
|
+
"resolved_at": None,
|
|
490
|
+
"resolved_by": None,
|
|
491
|
+
"resolution": None,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class TestRecordRendering:
|
|
496
|
+
"""Lock the BoundaryBasis rule: observed boundaries render as
|
|
497
|
+
"by <date>", never "on <date>" / "since <date>" (§2)."""
|
|
498
|
+
|
|
499
|
+
def test_stated_boundaries_render_since_until(self):
|
|
500
|
+
r = _record(valid_from="2020-01-01T00:00:00Z",
|
|
501
|
+
valid_to="2022-06-01T00:00:00Z")
|
|
502
|
+
out = record_validity_text(r)
|
|
503
|
+
assert out == "since 2020-01-01, until 2022-06-01"
|
|
504
|
+
|
|
505
|
+
def test_observed_valid_from_renders_by_never_since(self):
|
|
506
|
+
r = _record(vf_basis="observed")
|
|
507
|
+
out = record_validity_text(r)
|
|
508
|
+
assert "held by 2020-01-01" in out
|
|
509
|
+
assert "since" not in out
|
|
510
|
+
assert "on 2020" not in out
|
|
511
|
+
|
|
512
|
+
def test_observed_valid_to_renders_ended_by_never_until(self):
|
|
513
|
+
r = _record(valid_to="2022-06-01T00:00:00Z", vt_basis="observed")
|
|
514
|
+
out = record_validity_text(r)
|
|
515
|
+
assert "ended by 2022-06-01" in out
|
|
516
|
+
assert "until" not in out
|
|
517
|
+
|
|
518
|
+
def test_unbounded_is_undated(self):
|
|
519
|
+
assert record_validity_text(_record(valid_from=None)) == "undated"
|
|
520
|
+
|
|
521
|
+
def test_record_text_value_and_metadata(self):
|
|
522
|
+
out = _record_text(_record("Google"))
|
|
523
|
+
assert out.startswith("Google (")
|
|
524
|
+
assert "recorded 2026-06-15" in out
|
|
525
|
+
assert "conf 90%" in out
|
|
526
|
+
assert "active" not in out # only non-active statuses shown
|
|
527
|
+
|
|
528
|
+
def test_record_text_redacted(self):
|
|
529
|
+
r = _record("secret")
|
|
530
|
+
r["redacted"] = True
|
|
531
|
+
assert _record_text(r).startswith("[redacted]")
|
|
532
|
+
|
|
533
|
+
def test_conflict_lines_header_and_members(self):
|
|
534
|
+
lines = _conflict_lines(_conflict())
|
|
535
|
+
assert "[value_disagreement] alice works_at — open" in lines[0]
|
|
536
|
+
assert any("Google" in line for line in lines)
|
|
537
|
+
assert any("Meta" in line for line in lines)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class TestListConflicts:
|
|
541
|
+
@pytest.mark.asyncio
|
|
542
|
+
async def test_list_hits_v1_conflicts_with_scope(self):
|
|
543
|
+
patcher, mock = _mock_request({
|
|
544
|
+
"conflicts": [_conflict()], "total": 1, "next_cursor": None,
|
|
545
|
+
})
|
|
546
|
+
with patcher:
|
|
547
|
+
out = await list_conflicts()
|
|
548
|
+
method, path = mock.call_args[0][:2]
|
|
549
|
+
assert method == "GET"
|
|
550
|
+
assert path == "/v1/conflicts"
|
|
551
|
+
params = mock.call_args[1]["params"]
|
|
552
|
+
assert params["scope"] == "org:test/user:test"
|
|
553
|
+
assert params["status"] == "open"
|
|
554
|
+
assert "value_disagreement" in out
|
|
555
|
+
assert "Google" in out and "Meta" in out
|
|
556
|
+
|
|
557
|
+
@pytest.mark.asyncio
|
|
558
|
+
async def test_observed_basis_renders_by_in_tool_output(self):
|
|
559
|
+
c = _conflict(records=[_record("Google", vf_basis="observed")])
|
|
560
|
+
patcher, _ = _mock_request({"conflicts": [c], "total": 1, "next_cursor": None})
|
|
561
|
+
with patcher:
|
|
562
|
+
out = await list_conflicts()
|
|
563
|
+
assert "by 2020-01-01" in out
|
|
564
|
+
assert "since 2020-01-01" not in out
|
|
565
|
+
|
|
566
|
+
@pytest.mark.asyncio
|
|
567
|
+
async def test_empty_queue(self):
|
|
568
|
+
patcher, _ = _mock_request({"conflicts": [], "total": 0, "next_cursor": None})
|
|
569
|
+
with patcher:
|
|
570
|
+
out = await list_conflicts()
|
|
571
|
+
assert "No open conflicts" in out
|
|
572
|
+
|
|
573
|
+
@pytest.mark.asyncio
|
|
574
|
+
async def test_pagination_hint(self):
|
|
575
|
+
patcher, mock = _mock_request({
|
|
576
|
+
"conflicts": [_conflict()], "total": 5, "next_cursor": 1,
|
|
577
|
+
})
|
|
578
|
+
with patcher:
|
|
579
|
+
out = await list_conflicts(status="all", limit=1)
|
|
580
|
+
assert mock.call_args[1]["params"]["status"] == "all"
|
|
581
|
+
assert "cursor=1" in out
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class TestResolveConflict:
|
|
585
|
+
@pytest.mark.asyncio
|
|
586
|
+
async def test_pick_posts_tagged_body(self):
|
|
587
|
+
patcher, mock = _mock_request(_conflict(status="manually_resolved"))
|
|
588
|
+
with patcher:
|
|
589
|
+
out = await resolve_conflict(
|
|
590
|
+
conflict_id="c1", action="pick",
|
|
591
|
+
winner="0197a3a0-0000-7000-8000-00000000000b",
|
|
592
|
+
reason="user confirmed",
|
|
593
|
+
)
|
|
594
|
+
method, path = mock.call_args[0][:2]
|
|
595
|
+
assert method == "POST"
|
|
596
|
+
assert path == "/v1/conflicts/c1/resolve"
|
|
597
|
+
body = mock.call_args[1]["json_body"]
|
|
598
|
+
assert body == {
|
|
599
|
+
"action": "pick",
|
|
600
|
+
"winner": "0197a3a0-0000-7000-8000-00000000000b",
|
|
601
|
+
"reason": "user confirmed",
|
|
602
|
+
}
|
|
603
|
+
assert "manually_resolved" in out
|
|
604
|
+
|
|
605
|
+
@pytest.mark.asyncio
|
|
606
|
+
async def test_split_requires_all_fields(self):
|
|
607
|
+
patcher, mock = _mock_request({})
|
|
608
|
+
with patcher:
|
|
609
|
+
out = await resolve_conflict(
|
|
610
|
+
conflict_id="c1", action="split", boundary="2024-03",
|
|
611
|
+
)
|
|
612
|
+
assert "Invalid resolution" in out
|
|
613
|
+
assert "earlier" in out
|
|
614
|
+
mock.assert_not_called() # nothing hit the wire
|
|
615
|
+
|
|
616
|
+
@pytest.mark.asyncio
|
|
617
|
+
async def test_unknown_action_and_extraneous_args(self):
|
|
618
|
+
patcher, mock = _mock_request({})
|
|
619
|
+
with patcher:
|
|
620
|
+
out1 = await resolve_conflict(conflict_id="c1", action="merge")
|
|
621
|
+
out2 = await resolve_conflict(
|
|
622
|
+
conflict_id="c1", action="dismiss", winner="r1",
|
|
623
|
+
)
|
|
624
|
+
assert "unknown action" in out1
|
|
625
|
+
assert "does not accept" in out2
|
|
626
|
+
mock.assert_not_called()
|
|
627
|
+
|
|
628
|
+
@pytest.mark.asyncio
|
|
629
|
+
async def test_dismiss_defaults_reason(self):
|
|
630
|
+
patcher, mock = _mock_request(_conflict(status="dismissed"))
|
|
631
|
+
with patcher:
|
|
632
|
+
await resolve_conflict(conflict_id="c1", action="dismiss")
|
|
633
|
+
assert mock.call_args[1]["json_body"] == {"action": "dismiss", "reason": ""}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class TestClaimHistory:
|
|
637
|
+
@pytest.mark.asyncio
|
|
638
|
+
async def test_history_hits_endpoint_and_renders_records(self):
|
|
639
|
+
patcher, mock = _mock_request({
|
|
640
|
+
"claim": {"scope": "org:test/user:test", "subject": "alice",
|
|
641
|
+
"predicate": "works_at"},
|
|
642
|
+
"history": [
|
|
643
|
+
_record("Google", valid_to="2022-06-01T00:00:00Z",
|
|
644
|
+
vt_basis="observed"),
|
|
645
|
+
_record("Meta", valid_from="2022-06-01T00:00:00Z",
|
|
646
|
+
vf_basis="observed",
|
|
647
|
+
rid="0197a3a0-0000-7000-8000-00000000000b"),
|
|
648
|
+
],
|
|
649
|
+
"conflicts": [],
|
|
650
|
+
})
|
|
651
|
+
with patcher:
|
|
652
|
+
out = await claim_history(subject="Alice", predicate="works_at")
|
|
653
|
+
method, path = mock.call_args[0][:2]
|
|
654
|
+
assert method == "GET"
|
|
655
|
+
assert path == "/v1/claims/history"
|
|
656
|
+
params = mock.call_args[1]["params"]
|
|
657
|
+
assert params == {
|
|
658
|
+
"scope": "org:test/user:test",
|
|
659
|
+
"subject": "Alice",
|
|
660
|
+
"predicate": "works_at",
|
|
661
|
+
}
|
|
662
|
+
# Observed boundaries render as "by", never "since"/"until".
|
|
663
|
+
assert "ended by 2022-06-01" in out
|
|
664
|
+
assert "held by 2022-06-01" in out
|
|
665
|
+
assert "2 record(s)" in out
|
|
666
|
+
|
|
667
|
+
@pytest.mark.asyncio
|
|
668
|
+
async def test_history_lists_conflicts(self):
|
|
669
|
+
patcher, _ = _mock_request({
|
|
670
|
+
"claim": {"subject": "alice", "predicate": "works_at"},
|
|
671
|
+
"history": [_record()],
|
|
672
|
+
"conflicts": [_conflict()],
|
|
673
|
+
})
|
|
674
|
+
with patcher:
|
|
675
|
+
out = await claim_history(subject="alice", predicate="works_at")
|
|
676
|
+
assert "1 conflict(s)" in out
|
|
677
|
+
assert "value_disagreement" in out
|
|
678
|
+
|
|
679
|
+
@pytest.mark.asyncio
|
|
680
|
+
async def test_history_empty(self):
|
|
681
|
+
patcher, _ = _mock_request({"claim": {}, "history": [], "conflicts": []})
|
|
682
|
+
with patcher:
|
|
683
|
+
out = await claim_history(subject="nobody", predicate="none")
|
|
684
|
+
assert "No recorded history" in out
|
|
685
|
+
|
|
686
|
+
|
|
435
687
|
# ---------------------------------------------------------------------------
|
|
436
688
|
# Admin tools
|
|
437
689
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|