rootmemory 0.1.0__py3-none-any.whl
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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- rootmemory-0.1.0.dist-info/top_level.txt +1 -0
rootmemory/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""RootMemory - provenance-aware memory for multi-agent AI systems.
|
|
2
|
+
|
|
3
|
+
It tells corroboration apart from repetition. Every belief records what it was
|
|
4
|
+
derived from, so three agents echoing one document are counted as one piece of
|
|
5
|
+
evidence rather than three.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from rootmemory import RootMemory, open_memory
|
|
10
|
+
|
|
11
|
+
with open_memory("sqlite+pysqlite:///memory.db") as session:
|
|
12
|
+
memory = RootMemory(session)
|
|
13
|
+
agent = memory.register_agent("ResearchAgent")
|
|
14
|
+
|
|
15
|
+
article = memory.create_observation("news", "Revenue may have slipped.")
|
|
16
|
+
belief = memory.create_belief(
|
|
17
|
+
agent.id, "The supplier is struggling.", "supplier_risk", 0.6,
|
|
18
|
+
derived_from=[article], # required: cite what you read
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
claim = memory.get_or_create_claim("supplier_risk", "Supplier is struggling.")
|
|
22
|
+
memory.support_claim(claim.id, belief.id)
|
|
23
|
+
|
|
24
|
+
verdict = memory.evaluate_claim(claim.id)
|
|
25
|
+
print(verdict.independent_support_count) # 1, not 3
|
|
26
|
+
print(verdict.explanation)
|
|
27
|
+
|
|
28
|
+
The async client and the framework adapters live in ``rootmemory.integrations``
|
|
29
|
+
and are imported on demand, so nothing here pulls in FastAPI or LangGraph.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from rootmemory.client import RootMemory
|
|
35
|
+
from rootmemory.db.session import create_isolated_session, open_memory, session_scope
|
|
36
|
+
from rootmemory.errors import (
|
|
37
|
+
ImmutableRecordError,
|
|
38
|
+
NodeNotFoundError,
|
|
39
|
+
PromotionForbiddenError,
|
|
40
|
+
ProvenanceCycleError,
|
|
41
|
+
ProvenanceRequiredError,
|
|
42
|
+
RootMemoryError,
|
|
43
|
+
)
|
|
44
|
+
from rootmemory.models.enums import (
|
|
45
|
+
ClaimStatus,
|
|
46
|
+
DecisionStatus,
|
|
47
|
+
EdgeType,
|
|
48
|
+
EpistemicStatus,
|
|
49
|
+
NodeType,
|
|
50
|
+
ValidityStatus,
|
|
51
|
+
Visibility,
|
|
52
|
+
)
|
|
53
|
+
from rootmemory.models.node_ref import NodeRef
|
|
54
|
+
from rootmemory.services.promotion_service import PromotionEvaluation, PromotionPolicy
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# the client you will actually use
|
|
60
|
+
"RootMemory",
|
|
61
|
+
"open_memory",
|
|
62
|
+
"session_scope",
|
|
63
|
+
"create_isolated_session",
|
|
64
|
+
# the promotion policy and its verdict
|
|
65
|
+
"PromotionPolicy",
|
|
66
|
+
"PromotionEvaluation",
|
|
67
|
+
# errors worth catching
|
|
68
|
+
"RootMemoryError",
|
|
69
|
+
"NodeNotFoundError",
|
|
70
|
+
"ProvenanceRequiredError",
|
|
71
|
+
"ProvenanceCycleError",
|
|
72
|
+
"ImmutableRecordError",
|
|
73
|
+
"PromotionForbiddenError",
|
|
74
|
+
# vocabulary
|
|
75
|
+
"NodeRef",
|
|
76
|
+
"NodeType",
|
|
77
|
+
"EdgeType",
|
|
78
|
+
"ValidityStatus",
|
|
79
|
+
"EpistemicStatus",
|
|
80
|
+
"Visibility",
|
|
81
|
+
"ClaimStatus",
|
|
82
|
+
"DecisionStatus",
|
|
83
|
+
"__version__",
|
|
84
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""API layer: routing and serialization only, no epistemic rules."""
|
|
2
|
+
|
|
3
|
+
from rootmemory.api import (
|
|
4
|
+
agents,
|
|
5
|
+
beliefs,
|
|
6
|
+
claims,
|
|
7
|
+
graph,
|
|
8
|
+
invalidation,
|
|
9
|
+
observations,
|
|
10
|
+
promotion,
|
|
11
|
+
provenance,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
ROUTERS = [
|
|
15
|
+
agents.router,
|
|
16
|
+
observations.router,
|
|
17
|
+
beliefs.router,
|
|
18
|
+
claims.router,
|
|
19
|
+
promotion.router,
|
|
20
|
+
provenance.router,
|
|
21
|
+
invalidation.router,
|
|
22
|
+
graph.router,
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__all__ = ["ROUTERS"]
|
rootmemory/api/agents.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Agent endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, status
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.errors import NodeNotFoundError
|
|
11
|
+
from rootmemory.schemas.agent import AgentCreate, AgentRead
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("", response_model=AgentRead, status_code=status.HTTP_201_CREATED)
|
|
17
|
+
def create_agent(payload: AgentCreate, memory: MemoryDep) -> AgentRead:
|
|
18
|
+
"""Register an agent. Names are unique, so this is idempotent."""
|
|
19
|
+
agent = memory.register_agent(
|
|
20
|
+
name=payload.name,
|
|
21
|
+
role=payload.role,
|
|
22
|
+
description=payload.description,
|
|
23
|
+
trust_score=payload.trust_score,
|
|
24
|
+
)
|
|
25
|
+
return AgentRead.model_validate(agent)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("", response_model=list[AgentRead])
|
|
29
|
+
def list_agents(memory: MemoryDep) -> list[AgentRead]:
|
|
30
|
+
return [AgentRead.model_validate(a) for a in memory.agents.list()]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/{agent_id}", response_model=AgentRead)
|
|
34
|
+
def get_agent(agent_id: uuid.UUID, memory: MemoryDep) -> AgentRead:
|
|
35
|
+
agent = memory.agents.get(agent_id)
|
|
36
|
+
if agent is None:
|
|
37
|
+
raise NodeNotFoundError(f"agent {agent_id} does not exist")
|
|
38
|
+
return AgentRead.model_validate(agent)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Belief endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, status
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.models.node_ref import NodeRef
|
|
11
|
+
from rootmemory.schemas.belief import BeliefCreate, BeliefRead, BeliefWithProvenance
|
|
12
|
+
from rootmemory.schemas.common import NodeReference
|
|
13
|
+
from rootmemory.services.belief_service import ParentSpec
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/beliefs", tags=["beliefs"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("", response_model=BeliefRead, status_code=status.HTTP_201_CREATED)
|
|
19
|
+
def create_belief(payload: BeliefCreate, memory: MemoryDep) -> BeliefRead:
|
|
20
|
+
"""Create a belief.
|
|
21
|
+
|
|
22
|
+
Returns 422 when no parent is given (a belief must be traceable) and 409
|
|
23
|
+
when the requested provenance would create a cycle.
|
|
24
|
+
"""
|
|
25
|
+
parents = [ParentSpec(id=node.id, type=node.type) for node in payload.parent_nodes]
|
|
26
|
+
belief = memory.beliefs.create_belief(
|
|
27
|
+
agent_id=payload.agent_id,
|
|
28
|
+
claim_text=payload.claim_text,
|
|
29
|
+
normalized_claim_key=payload.normalized_claim_key,
|
|
30
|
+
confidence=payload.confidence,
|
|
31
|
+
parents=parents,
|
|
32
|
+
llm_generated=payload.llm_generated,
|
|
33
|
+
meta=payload.metadata,
|
|
34
|
+
)
|
|
35
|
+
return BeliefRead.model_validate(belief)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/{belief_id}", response_model=BeliefWithProvenance)
|
|
39
|
+
def get_belief(belief_id: uuid.UUID, memory: MemoryDep) -> BeliefWithProvenance:
|
|
40
|
+
belief = memory.get_belief(belief_id)
|
|
41
|
+
roots = memory.provenance.find_evidence_root_observations(NodeRef.belief(belief_id))
|
|
42
|
+
return BeliefWithProvenance(
|
|
43
|
+
**BeliefRead.model_validate(belief).model_dump(),
|
|
44
|
+
evidence_roots=[o.id for o in roots],
|
|
45
|
+
independent_source_count=len({o.independence_key for o in roots}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post("/{belief_id}/parents", status_code=status.HTTP_204_NO_CONTENT)
|
|
50
|
+
def add_parent(belief_id: uuid.UUID, parent: NodeReference, memory: MemoryDep) -> None:
|
|
51
|
+
"""Attach another causal parent to an existing belief.
|
|
52
|
+
|
|
53
|
+
Returns 409 if the edge would make the belief its own ancestor
|
|
54
|
+
(spec section 28).
|
|
55
|
+
"""
|
|
56
|
+
memory.beliefs.add_parent(belief_id, ParentSpec(id=parent.id, type=parent.type))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/{belief_id}/propose", response_model=BeliefRead)
|
|
60
|
+
def propose_shared_belief(belief_id: uuid.UUID, memory: MemoryDep) -> BeliefRead:
|
|
61
|
+
"""Move a private belief to candidate_shared.
|
|
62
|
+
|
|
63
|
+
This is as far as an agent can push its own belief; entering shared memory
|
|
64
|
+
requires the promotion gate.
|
|
65
|
+
"""
|
|
66
|
+
return BeliefRead.model_validate(memory.propose_shared_belief(belief_id))
|
rootmemory/api/claims.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Claim endpoints: creation, support, contradiction and honest reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, status
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.errors import NodeNotFoundError
|
|
11
|
+
from rootmemory.models.enums import ClaimStatus
|
|
12
|
+
from rootmemory.schemas.claim import (
|
|
13
|
+
ClaimContextResponse,
|
|
14
|
+
ClaimCreate,
|
|
15
|
+
ClaimLinkRequest,
|
|
16
|
+
ClaimRead,
|
|
17
|
+
EvidenceSide,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/claims", tags=["claims"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.post("", response_model=ClaimRead, status_code=status.HTTP_201_CREATED)
|
|
24
|
+
def create_claim(payload: ClaimCreate, memory: MemoryDep) -> ClaimRead:
|
|
25
|
+
"""Create (or fetch) a claim.
|
|
26
|
+
|
|
27
|
+
A new claim always starts unconfirmed. Shared/confirmed state can only be
|
|
28
|
+
reached through the promotion gate (spec section 46).
|
|
29
|
+
"""
|
|
30
|
+
claim = memory.get_or_create_claim(payload.claim_key, payload.canonical_text)
|
|
31
|
+
return ClaimRead.model_validate(claim)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("", response_model=list[ClaimRead])
|
|
35
|
+
def list_claims(memory: MemoryDep) -> list[ClaimRead]:
|
|
36
|
+
return [ClaimRead.model_validate(c) for c in memory.claims.list()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/{claim_id}", response_model=ClaimRead)
|
|
40
|
+
def get_claim(claim_id: uuid.UUID, memory: MemoryDep) -> ClaimRead:
|
|
41
|
+
claim = memory.claims.get(claim_id)
|
|
42
|
+
if claim is None:
|
|
43
|
+
raise NodeNotFoundError(f"claim {claim_id} does not exist")
|
|
44
|
+
return ClaimRead.model_validate(claim)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/{claim_id}/support", status_code=status.HTTP_204_NO_CONTENT)
|
|
48
|
+
def register_support(claim_id: uuid.UUID, payload: ClaimLinkRequest, memory: MemoryDep) -> None:
|
|
49
|
+
memory.support_claim(claim_id, payload.belief_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/{claim_id}/contradict", status_code=status.HTTP_204_NO_CONTENT)
|
|
53
|
+
def register_contradiction(
|
|
54
|
+
claim_id: uuid.UUID, payload: ClaimLinkRequest, memory: MemoryDep
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Record a belief that argues against the claim.
|
|
57
|
+
|
|
58
|
+
Nothing is overwritten or deleted: both sides stay in memory and both are
|
|
59
|
+
reported back (spec section 23).
|
|
60
|
+
"""
|
|
61
|
+
memory.contradict_claim(claim_id, payload.belief_id)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/{claim_id}/context", response_model=ClaimContextResponse)
|
|
65
|
+
def get_claim_context(claim_id: uuid.UUID, memory: MemoryDep) -> ClaimContextResponse:
|
|
66
|
+
"""Read a claim the way an agent should: both sides, independence counted."""
|
|
67
|
+
context = memory.get_claim_context(claim_id)
|
|
68
|
+
return ClaimContextResponse(
|
|
69
|
+
claim_id=context.claim.id,
|
|
70
|
+
claim_key=context.claim.claim_key,
|
|
71
|
+
claim=context.claim.canonical_text,
|
|
72
|
+
status=ClaimStatus(context.claim.status),
|
|
73
|
+
support=EvidenceSide(
|
|
74
|
+
belief_count=context.support.belief_count,
|
|
75
|
+
agreeing_agents=context.support.agreeing_agent_count,
|
|
76
|
+
independent_sources=context.support.independent_source_count,
|
|
77
|
+
confidence=context.support.aggregate_confidence,
|
|
78
|
+
root_observation_ids=context.support.root_observation_ids,
|
|
79
|
+
),
|
|
80
|
+
contradictions=EvidenceSide(
|
|
81
|
+
belief_count=context.contradiction.belief_count,
|
|
82
|
+
agreeing_agents=context.contradiction.agreeing_agent_count,
|
|
83
|
+
independent_sources=context.contradiction.independent_source_count,
|
|
84
|
+
confidence=context.contradiction.aggregate_confidence,
|
|
85
|
+
root_observation_ids=context.contradiction.root_observation_ids,
|
|
86
|
+
),
|
|
87
|
+
)
|
rootmemory/api/deps.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""FastAPI dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from rootmemory.client import RootMemory
|
|
11
|
+
from rootmemory.db.session import get_db
|
|
12
|
+
|
|
13
|
+
SessionDep = Annotated[Session, Depends(get_db)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_memory_client(session: SessionDep) -> RootMemory:
|
|
17
|
+
"""One memory client per request."""
|
|
18
|
+
return RootMemory(session)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MemoryDep = Annotated[RootMemory, Depends(get_memory_client)]
|
rootmemory/api/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Mapping of domain errors onto HTTP status codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from rootmemory.errors import (
|
|
9
|
+
ImmutableRecordError,
|
|
10
|
+
NodeNotFoundError,
|
|
11
|
+
PromotionForbiddenError,
|
|
12
|
+
ProvenanceCycleError,
|
|
13
|
+
ProvenanceRequiredError,
|
|
14
|
+
RootMemoryError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
STATUS_BY_ERROR: list[tuple[type[RootMemoryError], int]] = [
|
|
18
|
+
(NodeNotFoundError, 404),
|
|
19
|
+
(ProvenanceCycleError, 409),
|
|
20
|
+
(ProvenanceRequiredError, 422),
|
|
21
|
+
(PromotionForbiddenError, 403),
|
|
22
|
+
(ImmutableRecordError, 409),
|
|
23
|
+
(RootMemoryError, 400),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_error_handlers(app: FastAPI) -> None:
|
|
28
|
+
@app.exception_handler(RootMemoryError)
|
|
29
|
+
async def handle_domain_error(_: Request, exc: RootMemoryError) -> JSONResponse:
|
|
30
|
+
status = next(
|
|
31
|
+
(code for error_type, code in STATUS_BY_ERROR if isinstance(exc, error_type)), 400
|
|
32
|
+
)
|
|
33
|
+
return JSONResponse(
|
|
34
|
+
status_code=status,
|
|
35
|
+
content={"error": type(exc).__name__, "detail": str(exc)},
|
|
36
|
+
)
|
rootmemory/api/graph.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Graph snapshot endpoint, used by the portal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep, SessionDep
|
|
10
|
+
from rootmemory.errors import NodeNotFoundError
|
|
11
|
+
from rootmemory.services.graph_service import GraphService
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["graph"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/graph")
|
|
17
|
+
def get_graph(session: SessionDep) -> dict:
|
|
18
|
+
"""The whole memory graph in one call: nodes, edges and headline counts.
|
|
19
|
+
|
|
20
|
+
Each belief carries its evidence roots and each claim carries both the
|
|
21
|
+
agreement count and the independent-source count, so the portal never has
|
|
22
|
+
to infer the difference for itself.
|
|
23
|
+
"""
|
|
24
|
+
return GraphService(session).snapshot().as_dict()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/graph/claims/{claim_id}/sides")
|
|
28
|
+
def get_claim_sides(claim_id: uuid.UUID, session: SessionDep, memory: MemoryDep) -> dict:
|
|
29
|
+
"""Which beliefs support and which contradict a claim."""
|
|
30
|
+
if memory.claims.get(claim_id) is None:
|
|
31
|
+
raise NodeNotFoundError(f"claim {claim_id} does not exist")
|
|
32
|
+
return GraphService(session).claim_supporters(claim_id)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Invalidation endpoints: retracting a source and repairing what it touched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.schemas.observation import (
|
|
11
|
+
InvalidationRequest,
|
|
12
|
+
InvalidationResponse,
|
|
13
|
+
RepairReportRead,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(tags=["invalidation"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/observations/{observation_id}/invalidate", response_model=InvalidationResponse)
|
|
20
|
+
def invalidate_observation(
|
|
21
|
+
observation_id: uuid.UUID, payload: InvalidationRequest, memory: MemoryDep
|
|
22
|
+
) -> InvalidationResponse:
|
|
23
|
+
"""Mark a source invalid and repair everything downstream of it.
|
|
24
|
+
|
|
25
|
+
The observation itself is not edited or deleted; only its validity moves,
|
|
26
|
+
and the traversal is recorded as a repair report.
|
|
27
|
+
"""
|
|
28
|
+
summary = memory.invalidate_observation(observation_id, payload.reason)
|
|
29
|
+
return InvalidationResponse(
|
|
30
|
+
repair_report_id=summary.report_id,
|
|
31
|
+
trigger_node_id=summary.trigger_node_id,
|
|
32
|
+
trigger_reason=summary.trigger_reason,
|
|
33
|
+
affected_beliefs=summary.affected_beliefs,
|
|
34
|
+
affected_claims=summary.affected_claims,
|
|
35
|
+
affected_decisions=summary.affected_decisions,
|
|
36
|
+
details=summary.details,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/repair-reports", response_model=list[RepairReportRead])
|
|
41
|
+
def list_repair_reports(memory: MemoryDep) -> list[RepairReportRead]:
|
|
42
|
+
return [RepairReportRead.model_validate(r) for r in memory.invalidation.audits.list_repairs()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/repair-reports/{report_id}", response_model=RepairReportRead)
|
|
46
|
+
def get_repair_report(report_id: uuid.UUID, memory: MemoryDep) -> RepairReportRead:
|
|
47
|
+
return RepairReportRead.model_validate(memory.invalidation.get_report(report_id))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Observation endpoints.
|
|
2
|
+
|
|
3
|
+
There is deliberately no DELETE and no PUT: observations are immutable
|
|
4
|
+
historical records. The only thing that can change is validity, and that goes
|
|
5
|
+
through the invalidation endpoint so a repair report is always produced.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, status
|
|
13
|
+
|
|
14
|
+
from rootmemory.api.deps import MemoryDep
|
|
15
|
+
from rootmemory.errors import NodeNotFoundError
|
|
16
|
+
from rootmemory.schemas.observation import ObservationCreate, ObservationRead
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/observations", tags=["observations"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("", response_model=ObservationRead, status_code=status.HTTP_201_CREATED)
|
|
22
|
+
def create_observation(payload: ObservationCreate, memory: MemoryDep) -> ObservationRead:
|
|
23
|
+
observation = memory.create_observation(
|
|
24
|
+
source_type=payload.source_type,
|
|
25
|
+
content=payload.content,
|
|
26
|
+
source_uri=payload.source_uri,
|
|
27
|
+
source_actor=payload.source_actor,
|
|
28
|
+
created_by_agent_id=payload.created_by_agent_id,
|
|
29
|
+
reliability_score=payload.reliability_score,
|
|
30
|
+
source_family_id=payload.source_family_id,
|
|
31
|
+
meta=payload.metadata,
|
|
32
|
+
)
|
|
33
|
+
return ObservationRead.model_validate(observation)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("", response_model=list[ObservationRead])
|
|
37
|
+
def list_observations(memory: MemoryDep) -> list[ObservationRead]:
|
|
38
|
+
return [ObservationRead.model_validate(o) for o in memory.observations.list()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/{observation_id}", response_model=ObservationRead)
|
|
42
|
+
def get_observation(observation_id: uuid.UUID, memory: MemoryDep) -> ObservationRead:
|
|
43
|
+
observation = memory.observations.get(observation_id)
|
|
44
|
+
if observation is None:
|
|
45
|
+
raise NodeNotFoundError(f"observation {observation_id} does not exist")
|
|
46
|
+
return ObservationRead.model_validate(observation)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Promotion endpoints: the only route into shared memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.schemas.promotion import PromotionAuditRead, PromotionEvaluationResponse
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/claims", tags=["promotion"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post("/{claim_id}/evaluate-promotion", response_model=PromotionEvaluationResponse)
|
|
16
|
+
def evaluate_promotion(claim_id: uuid.UUID, memory: MemoryDep) -> PromotionEvaluationResponse:
|
|
17
|
+
"""Run the gate and report the verdict without changing the claim."""
|
|
18
|
+
evaluation = memory.evaluate_claim(claim_id)
|
|
19
|
+
return PromotionEvaluationResponse.model_validate(evaluation.as_dict())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/{claim_id}/promote", response_model=PromotionEvaluationResponse)
|
|
23
|
+
def promote_claim(claim_id: uuid.UUID, memory: MemoryDep) -> PromotionEvaluationResponse:
|
|
24
|
+
"""Attempt to promote a claim into shared memory.
|
|
25
|
+
|
|
26
|
+
The gate is re-run here from scratch: a caller's earlier evaluation is
|
|
27
|
+
never trusted (spec section 27.8).
|
|
28
|
+
"""
|
|
29
|
+
evaluation = memory.promote_claim(claim_id)
|
|
30
|
+
return PromotionEvaluationResponse.model_validate(evaluation.as_dict())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/{claim_id}/promotion-audits", response_model=list[PromotionAuditRead])
|
|
34
|
+
def list_promotion_audits(claim_id: uuid.UUID, memory: MemoryDep) -> list[PromotionAuditRead]:
|
|
35
|
+
"""Every promotion decision ever made about this claim, in order."""
|
|
36
|
+
audits = memory.promotion.audits.promotions_for_claim(claim_id)
|
|
37
|
+
return [PromotionAuditRead.model_validate(a) for a in audits]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Provenance inspection and decision endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query, status
|
|
8
|
+
|
|
9
|
+
from rootmemory.api.deps import MemoryDep
|
|
10
|
+
from rootmemory.errors import NodeNotFoundError
|
|
11
|
+
from rootmemory.models.enums import NodeType
|
|
12
|
+
from rootmemory.models.node_ref import NodeRef
|
|
13
|
+
from rootmemory.schemas.provenance import (
|
|
14
|
+
DecisionCreate,
|
|
15
|
+
DecisionRead,
|
|
16
|
+
EvidenceRootRead,
|
|
17
|
+
ProvenanceResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(tags=["provenance"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/provenance/{node_id}", response_model=ProvenanceResponse)
|
|
24
|
+
def inspect_provenance(
|
|
25
|
+
node_id: uuid.UUID,
|
|
26
|
+
memory: MemoryDep,
|
|
27
|
+
node_type: NodeType = Query(
|
|
28
|
+
default=NodeType.BELIEF, description="Which table the node lives in."
|
|
29
|
+
),
|
|
30
|
+
include_paths: bool = Query(default=True),
|
|
31
|
+
) -> ProvenanceResponse:
|
|
32
|
+
"""Answer 'where did this come from?' for any node in the graph."""
|
|
33
|
+
ref = NodeRef(node_id, node_type)
|
|
34
|
+
if not memory.provenance.node_exists(ref):
|
|
35
|
+
raise NodeNotFoundError(f"{node_type}:{node_id} does not exist")
|
|
36
|
+
|
|
37
|
+
roots = memory.provenance.find_evidence_roots(ref)
|
|
38
|
+
observations = memory.observations.get_many(roots.roots)
|
|
39
|
+
return ProvenanceResponse(
|
|
40
|
+
node_id=node_id,
|
|
41
|
+
node_type=node_type,
|
|
42
|
+
evidence_roots=sorted(roots.roots, key=str),
|
|
43
|
+
excluded_roots={str(k): v for k, v in roots.excluded_roots.items()},
|
|
44
|
+
independent_source_count=len({o.independence_key for o in observations}),
|
|
45
|
+
root_details=[
|
|
46
|
+
EvidenceRootRead(
|
|
47
|
+
id=o.id,
|
|
48
|
+
source_type=o.source_type,
|
|
49
|
+
source_uri=o.source_uri,
|
|
50
|
+
validity_status=o.validity_status,
|
|
51
|
+
reliability_score=o.reliability_score,
|
|
52
|
+
source_family_id=o.source_family_id,
|
|
53
|
+
independence_key=o.independence_key,
|
|
54
|
+
)
|
|
55
|
+
for o in observations
|
|
56
|
+
],
|
|
57
|
+
paths=memory.provenance.enumerate_paths(ref) if include_paths else [],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/decisions", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
|
|
62
|
+
def create_decision(payload: DecisionCreate, memory: MemoryDep) -> DecisionRead:
|
|
63
|
+
"""Record a decision and the claims it depends on.
|
|
64
|
+
|
|
65
|
+
Those dependencies are what let cascade repair flag the decision if the
|
|
66
|
+
evidence underneath it is later retracted.
|
|
67
|
+
"""
|
|
68
|
+
decision = memory.decision_service.create_decision(
|
|
69
|
+
agent_id=payload.agent_id,
|
|
70
|
+
decision_type=payload.decision_type,
|
|
71
|
+
content=payload.content,
|
|
72
|
+
claim_ids=payload.depends_on_claim_ids,
|
|
73
|
+
meta=payload.metadata,
|
|
74
|
+
)
|
|
75
|
+
return DecisionRead.model_validate(decision)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/decisions", response_model=list[DecisionRead])
|
|
79
|
+
def list_decisions(memory: MemoryDep) -> list[DecisionRead]:
|
|
80
|
+
return [DecisionRead.model_validate(d) for d in memory.decisions.list()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/decisions/{decision_id}", response_model=DecisionRead)
|
|
84
|
+
def get_decision(decision_id: uuid.UUID, memory: MemoryDep) -> DecisionRead:
|
|
85
|
+
decision = memory.decisions.get(decision_id)
|
|
86
|
+
if decision is None:
|
|
87
|
+
raise NodeNotFoundError(f"decision {decision_id} does not exist")
|
|
88
|
+
return DecisionRead.model_validate(decision)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""API key authentication.
|
|
2
|
+
|
|
3
|
+
Off by default so a fresh clone and the test-suite still work with no setup.
|
|
4
|
+
Set ``API_KEYS`` and it turns on for every route except ``/health``.
|
|
5
|
+
|
|
6
|
+
Why this matters here specifically: ``POST /observations/{id}/invalidate``
|
|
7
|
+
cascades through the graph, downgrading claims and flagging decisions. On an
|
|
8
|
+
open port that is a one-request way to poison shared memory, so an instance
|
|
9
|
+
reachable by anything other than localhost must set ``API_KEYS``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
|
|
16
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
17
|
+
from fastapi.security import APIKeyHeader
|
|
18
|
+
|
|
19
|
+
from rootmemory.config import Settings, get_settings
|
|
20
|
+
|
|
21
|
+
API_KEY_HEADER = "X-API-Key"
|
|
22
|
+
|
|
23
|
+
#: Routes that never require a key: liveness, and the portal shell itself
|
|
24
|
+
#: (the page prompts for a key and then calls the API with it).
|
|
25
|
+
OPEN_PATHS = frozenset({"/health", "/ui", "/", "/docs", "/openapi.json", "/redoc"})
|
|
26
|
+
|
|
27
|
+
_header_scheme = APIKeyHeader(name=API_KEY_HEADER, auto_error=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_open(path: str) -> bool:
|
|
31
|
+
return path in OPEN_PATHS or path.startswith("/static/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require_api_key(
|
|
35
|
+
request: Request,
|
|
36
|
+
provided: str | None = Depends(_header_scheme),
|
|
37
|
+
settings: Settings = Depends(get_settings),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Dependency enforcing the API key when one is configured."""
|
|
40
|
+
if not settings.auth_enabled or _is_open(request.url.path):
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
# Compared with hmac.compare_digest so a wrong key cannot be found by
|
|
44
|
+
# timing the response.
|
|
45
|
+
for candidate in settings.api_key_list:
|
|
46
|
+
if provided is not None and hmac.compare_digest(provided, candidate):
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail=f"a valid {API_KEY_HEADER} header is required",
|
|
52
|
+
headers={"WWW-Authenticate": API_KEY_HEADER},
|
|
53
|
+
)
|