context-bridge-memory 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.
- context_bridge/__init__.py +5 -0
- context_bridge/__main__.py +21 -0
- context_bridge/api/__init__.py +1 -0
- context_bridge/api/access.py +67 -0
- context_bridge/api/app.py +187 -0
- context_bridge/api/deps.py +142 -0
- context_bridge/api/metrics.py +58 -0
- context_bridge/api/routes/__init__.py +1 -0
- context_bridge/api/routes/conflicts.py +51 -0
- context_bridge/api/routes/graph.py +61 -0
- context_bridge/api/routes/health.py +49 -0
- context_bridge/api/routes/insights.py +66 -0
- context_bridge/api/routes/learning.py +95 -0
- context_bridge/api/routes/lessons.py +88 -0
- context_bridge/api/routes/maintenance.py +76 -0
- context_bridge/api/routes/memory.py +221 -0
- context_bridge/api/routes/quality.py +23 -0
- context_bridge/api/routes/sessions.py +68 -0
- context_bridge/api/schemas.py +370 -0
- context_bridge/api/security.py +137 -0
- context_bridge/api/tracing.py +42 -0
- context_bridge/benchmark.py +120 -0
- context_bridge/config.py +159 -0
- context_bridge/core/__init__.py +1 -0
- context_bridge/core/chunking/__init__.py +18 -0
- context_bridge/core/chunking/base.py +63 -0
- context_bridge/core/chunking/recursive.py +58 -0
- context_bridge/core/chunking/semantic.py +73 -0
- context_bridge/core/embeddings/__init__.py +46 -0
- context_bridge/core/embeddings/base.py +43 -0
- context_bridge/core/embeddings/cohere_embedder.py +59 -0
- context_bridge/core/embeddings/fastembed_embedder.py +63 -0
- context_bridge/core/embeddings/hashing.py +80 -0
- context_bridge/core/embeddings/openai_embedder.py +63 -0
- context_bridge/core/events.py +72 -0
- context_bridge/core/graph/__init__.py +12 -0
- context_bridge/core/graph/extractor.py +86 -0
- context_bridge/core/graph/resolver.py +22 -0
- context_bridge/core/memory/__init__.py +8 -0
- context_bridge/core/memory/consolidation.py +38 -0
- context_bridge/core/memory/contradiction.py +104 -0
- context_bridge/core/memory/manager.py +1104 -0
- context_bridge/core/memory/policy.py +40 -0
- context_bridge/core/memory/redaction.py +47 -0
- context_bridge/core/memory/salience.py +123 -0
- context_bridge/core/memory/summarizer.py +174 -0
- context_bridge/core/models.py +139 -0
- context_bridge/core/retrieval/__init__.py +8 -0
- context_bridge/core/retrieval/budget.py +73 -0
- context_bridge/core/retrieval/mmr.py +69 -0
- context_bridge/core/retrieval/reranker.py +96 -0
- context_bridge/core/retrieval/retriever.py +175 -0
- context_bridge/core/tracing.py +32 -0
- context_bridge/core/vectorstore/__init__.py +23 -0
- context_bridge/core/vectorstore/base.py +63 -0
- context_bridge/core/vectorstore/qdrant_store.py +333 -0
- context_bridge/core/working/__init__.py +22 -0
- context_bridge/core/working/base.py +26 -0
- context_bridge/core/working/memory_store.py +33 -0
- context_bridge/core/working/redis_store.py +34 -0
- context_bridge/db/__init__.py +51 -0
- context_bridge/db/models.py +298 -0
- context_bridge/db/repository.py +633 -0
- context_bridge/db/session.py +37 -0
- context_bridge/py.typed +0 -0
- context_bridge/sdk/__init__.py +7 -0
- context_bridge/sdk/client.py +341 -0
- context_bridge/tokenizer.py +52 -0
- context_bridge_memory-0.1.0.dist-info/METADATA +485 -0
- context_bridge_memory-0.1.0.dist-info/RECORD +74 -0
- context_bridge_memory-0.1.0.dist-info/WHEEL +4 -0
- context_bridge_memory-0.1.0.dist-info/entry_points.txt +2 -0
- context_bridge_memory-0.1.0.dist-info/licenses/LICENSE +201 -0
- context_bridge_memory-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Console entry point: run the API server with uvicorn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from context_bridge.config import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
settings = get_settings()
|
|
12
|
+
uvicorn.run(
|
|
13
|
+
"context_bridge.api.app:app",
|
|
14
|
+
host=settings.api_host,
|
|
15
|
+
port=settings.api_port,
|
|
16
|
+
log_level=settings.log_level,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API layer (FastAPI)."""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Role-based access control for namespaces.
|
|
2
|
+
|
|
3
|
+
Each API key resolves to a :class:`Rule`: a set of namespace glob patterns plus
|
|
4
|
+
the operations (``read`` / ``write``) it may perform. This is what makes the
|
|
5
|
+
shared pool safe for multi-tenant use — a key scoped to ``team-a*`` can never
|
|
6
|
+
read or write ``team-b`` memories.
|
|
7
|
+
|
|
8
|
+
Two configuration shapes feed it (rich overrides simple):
|
|
9
|
+
|
|
10
|
+
* ``API_KEY_NAMESPACES`` — ``{"key": ["ns-a", "ns-b"]}`` (implies read + write).
|
|
11
|
+
* ``API_KEY_POLICIES`` — ``{"key": {"namespaces": ["team-a*"],
|
|
12
|
+
"permissions": ["read"]}}``.
|
|
13
|
+
|
|
14
|
+
With no API keys configured, access is open.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from fnmatch import fnmatch
|
|
21
|
+
|
|
22
|
+
READ = "read"
|
|
23
|
+
WRITE = "write"
|
|
24
|
+
_ALL = ("*",)
|
|
25
|
+
_RW = frozenset({READ, WRITE})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Rule:
|
|
30
|
+
namespaces: tuple[str, ...]
|
|
31
|
+
permissions: frozenset[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AccessControl:
|
|
35
|
+
"""Resolves and enforces per-key namespace/operation rules."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, rules: dict[str, Rule], *, open_access: bool) -> None:
|
|
38
|
+
self.rules = rules
|
|
39
|
+
self.open_access = open_access
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def build(cls, settings) -> AccessControl:
|
|
43
|
+
keys = settings.api_key_set()
|
|
44
|
+
rules: dict[str, Rule] = {}
|
|
45
|
+
|
|
46
|
+
for key, namespaces in settings.api_key_namespace_map().items():
|
|
47
|
+
rules[key] = Rule(tuple(namespaces) or _ALL, _RW)
|
|
48
|
+
|
|
49
|
+
for key, policy in settings.api_key_policies_map().items():
|
|
50
|
+
rules[key] = Rule(
|
|
51
|
+
tuple(policy.get("namespaces") or _ALL),
|
|
52
|
+
frozenset(policy.get("permissions") or [READ, WRITE]),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Any configured key without an explicit rule gets full access.
|
|
56
|
+
for key in keys:
|
|
57
|
+
rules.setdefault(key, Rule(_ALL, _RW))
|
|
58
|
+
|
|
59
|
+
return cls(rules, open_access=not keys)
|
|
60
|
+
|
|
61
|
+
def allows(self, key: str | None, namespace: str, operation: str) -> bool:
|
|
62
|
+
if self.open_access:
|
|
63
|
+
return True
|
|
64
|
+
rule = self.rules.get(key or "")
|
|
65
|
+
if rule is None or operation not in rule.permissions:
|
|
66
|
+
return False
|
|
67
|
+
return any(fnmatch(namespace, pattern) for pattern in rule.namespaces)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, FastAPI, Request, Response
|
|
14
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from context_bridge import __version__
|
|
18
|
+
from context_bridge.api import metrics
|
|
19
|
+
from context_bridge.api.access import AccessControl
|
|
20
|
+
from context_bridge.api.deps import build_container
|
|
21
|
+
from context_bridge.api.routes import (
|
|
22
|
+
conflicts,
|
|
23
|
+
graph,
|
|
24
|
+
health,
|
|
25
|
+
insights,
|
|
26
|
+
learning,
|
|
27
|
+
lessons,
|
|
28
|
+
maintenance,
|
|
29
|
+
memory,
|
|
30
|
+
quality,
|
|
31
|
+
sessions,
|
|
32
|
+
)
|
|
33
|
+
from context_bridge.api.security import api_key_guard, build_rate_limiter, rate_limit_guard
|
|
34
|
+
from context_bridge.api.tracing import setup_tracing
|
|
35
|
+
from context_bridge.config import Settings, get_settings
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("context_bridge")
|
|
38
|
+
|
|
39
|
+
API_V1 = "/v1"
|
|
40
|
+
_REQUEST_ID_HEADER = "X-Request-ID"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _sweep_loop(app: FastAPI, interval: int) -> None:
|
|
44
|
+
"""Periodically purge TTL-expired memories until cancelled."""
|
|
45
|
+
while True:
|
|
46
|
+
await asyncio.sleep(interval)
|
|
47
|
+
try:
|
|
48
|
+
deleted = await asyncio.to_thread(app.state.container.manager.sweep_expired)
|
|
49
|
+
if deleted:
|
|
50
|
+
logger.info("ttl sweep removed %d expired memories", deleted)
|
|
51
|
+
except Exception: # pragma: no cover - defensive background task
|
|
52
|
+
logger.exception("ttl sweep failed")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _maintenance_loop(app: FastAPI, interval: int) -> None:
|
|
56
|
+
"""Periodically run a full housekeeping cycle until cancelled."""
|
|
57
|
+
from context_bridge.api.routes.maintenance import _run_maintenance
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
await asyncio.sleep(interval)
|
|
61
|
+
try:
|
|
62
|
+
settings = app.state.settings
|
|
63
|
+
result = await asyncio.to_thread(
|
|
64
|
+
_run_maintenance, app.state.container.manager, settings
|
|
65
|
+
)
|
|
66
|
+
logger.info("maintenance cycle: %s", result)
|
|
67
|
+
except Exception: # pragma: no cover - defensive background task
|
|
68
|
+
logger.exception("maintenance cycle failed")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
73
|
+
"""Build the component graph once at startup, tear down at shutdown."""
|
|
74
|
+
settings: Settings = app.state.settings
|
|
75
|
+
app.state.container = build_container(settings)
|
|
76
|
+
|
|
77
|
+
tasks: list[asyncio.Task] = []
|
|
78
|
+
if settings.sweep_interval_seconds > 0:
|
|
79
|
+
tasks.append(asyncio.create_task(_sweep_loop(app, settings.sweep_interval_seconds)))
|
|
80
|
+
if settings.maintenance_interval_seconds > 0:
|
|
81
|
+
tasks.append(
|
|
82
|
+
asyncio.create_task(_maintenance_loop(app, settings.maintenance_interval_seconds))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
yield
|
|
87
|
+
finally:
|
|
88
|
+
for task in tasks:
|
|
89
|
+
task.cancel()
|
|
90
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
91
|
+
await task
|
|
92
|
+
app.state.container = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
96
|
+
"""Create and configure the Context Bridge API application."""
|
|
97
|
+
settings = settings or get_settings()
|
|
98
|
+
app = FastAPI(
|
|
99
|
+
title="Context Bridge",
|
|
100
|
+
version=__version__,
|
|
101
|
+
summary="Shared neural memory middleware for multi-agent systems.",
|
|
102
|
+
lifespan=lifespan,
|
|
103
|
+
)
|
|
104
|
+
app.state.settings = settings
|
|
105
|
+
app.state.rate_limiter = build_rate_limiter(settings)
|
|
106
|
+
app.state.access = AccessControl.build(settings)
|
|
107
|
+
|
|
108
|
+
setup_tracing(app, settings)
|
|
109
|
+
_install_middleware(app, settings)
|
|
110
|
+
_install_error_handler(app)
|
|
111
|
+
|
|
112
|
+
@app.get("/", tags=["meta"])
|
|
113
|
+
def root() -> dict:
|
|
114
|
+
"""Service banner with links to docs, health and metrics."""
|
|
115
|
+
return {
|
|
116
|
+
"name": "Context Bridge",
|
|
117
|
+
"description": "Shared neural memory middleware for multi-agent systems.",
|
|
118
|
+
"version": __version__,
|
|
119
|
+
"docs": "/docs",
|
|
120
|
+
"health": "/health",
|
|
121
|
+
"metrics": "/metrics" if settings.metrics_enabled else None,
|
|
122
|
+
"api": API_V1,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
guarded = [Depends(api_key_guard), Depends(rate_limit_guard)]
|
|
126
|
+
app.include_router(health.router)
|
|
127
|
+
if settings.metrics_enabled:
|
|
128
|
+
app.include_router(metrics.router)
|
|
129
|
+
app.include_router(memory.router, prefix=API_V1, dependencies=guarded)
|
|
130
|
+
app.include_router(sessions.router, prefix=API_V1, dependencies=guarded)
|
|
131
|
+
app.include_router(maintenance.router, prefix=API_V1, dependencies=guarded)
|
|
132
|
+
app.include_router(conflicts.router, prefix=API_V1, dependencies=guarded)
|
|
133
|
+
app.include_router(graph.router, prefix=API_V1, dependencies=guarded)
|
|
134
|
+
app.include_router(learning.router, prefix=API_V1, dependencies=guarded)
|
|
135
|
+
app.include_router(lessons.router, prefix=API_V1, dependencies=guarded)
|
|
136
|
+
app.include_router(quality.router, prefix=API_V1, dependencies=guarded)
|
|
137
|
+
app.include_router(insights.router, prefix=API_V1, dependencies=guarded)
|
|
138
|
+
return app
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _install_middleware(app: FastAPI, settings: Settings) -> None:
|
|
142
|
+
origins = settings.cors_origin_list()
|
|
143
|
+
# Browsers reject (and it is unsafe to send) credentials with a "*" origin,
|
|
144
|
+
# so only enable credentialed CORS when explicit origins are configured.
|
|
145
|
+
allow_all = origins == ["*"]
|
|
146
|
+
app.add_middleware(
|
|
147
|
+
CORSMiddleware,
|
|
148
|
+
allow_origins=origins,
|
|
149
|
+
allow_credentials=not allow_all,
|
|
150
|
+
allow_methods=["*"],
|
|
151
|
+
allow_headers=["*"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@app.middleware("http")
|
|
155
|
+
async def _observability(
|
|
156
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
157
|
+
) -> Response:
|
|
158
|
+
request_id = request.headers.get(_REQUEST_ID_HEADER) or uuid.uuid4().hex
|
|
159
|
+
request.state.request_id = request_id
|
|
160
|
+
start = time.perf_counter()
|
|
161
|
+
response = await call_next(request)
|
|
162
|
+
elapsed = time.perf_counter() - start
|
|
163
|
+
response.headers[_REQUEST_ID_HEADER] = request_id
|
|
164
|
+
if settings.metrics_enabled:
|
|
165
|
+
route = request.scope.get("route")
|
|
166
|
+
endpoint = getattr(route, "path", request.url.path)
|
|
167
|
+
metrics.REQUEST_LATENCY.labels(
|
|
168
|
+
method=request.method, endpoint=endpoint, status=str(response.status_code)
|
|
169
|
+
).observe(elapsed)
|
|
170
|
+
return response
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _install_error_handler(app: FastAPI) -> None:
|
|
174
|
+
@app.exception_handler(Exception)
|
|
175
|
+
async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
|
|
176
|
+
request_id = getattr(request.state, "request_id", None)
|
|
177
|
+
logger.exception("unhandled error (request_id=%s)", request_id)
|
|
178
|
+
return JSONResponse(
|
|
179
|
+
status_code=500,
|
|
180
|
+
content={
|
|
181
|
+
"error": {"type": "internal_error", "message": "internal server error"},
|
|
182
|
+
"request_id": request_id,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
app = create_app()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Dependency wiring: build the component graph from settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
|
|
9
|
+
from context_bridge.config import Settings
|
|
10
|
+
from context_bridge.core.chunking import build_chunker
|
|
11
|
+
from context_bridge.core.embeddings import build_embedder
|
|
12
|
+
from context_bridge.core.embeddings.base import Embedder
|
|
13
|
+
from context_bridge.core.events import CompositeEmitter, EventEmitter, build_emitter
|
|
14
|
+
from context_bridge.core.graph.extractor import build_extractor
|
|
15
|
+
from context_bridge.core.memory.contradiction import build_detector
|
|
16
|
+
from context_bridge.core.memory.manager import CognitiveServices, MemoryManager
|
|
17
|
+
from context_bridge.core.memory.policy import WritePolicy
|
|
18
|
+
from context_bridge.core.memory.redaction import build_redactor
|
|
19
|
+
from context_bridge.core.memory.summarizer import build_summarizer
|
|
20
|
+
from context_bridge.core.retrieval import Retriever, build_reranker
|
|
21
|
+
from context_bridge.core.retrieval.retriever import RetrievalParams
|
|
22
|
+
from context_bridge.core.vectorstore import build_vector_store
|
|
23
|
+
from context_bridge.core.vectorstore.base import VectorStore
|
|
24
|
+
from context_bridge.core.working import build_working_memory
|
|
25
|
+
from context_bridge.db import (
|
|
26
|
+
AgentProfileRepository,
|
|
27
|
+
ConflictRepository,
|
|
28
|
+
Database,
|
|
29
|
+
EpisodeRepository,
|
|
30
|
+
FeedbackRepository,
|
|
31
|
+
GraphRepository,
|
|
32
|
+
LessonRepository,
|
|
33
|
+
ParentRepository,
|
|
34
|
+
ProcedureRepository,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Container:
|
|
40
|
+
"""Holds the constructed singletons for the lifetime of the process."""
|
|
41
|
+
|
|
42
|
+
settings: Settings
|
|
43
|
+
embedder: Embedder
|
|
44
|
+
store: VectorStore
|
|
45
|
+
db: Database
|
|
46
|
+
manager: MemoryManager
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_container(settings: Settings) -> Container:
|
|
50
|
+
"""Construct every component from configuration and wire the manager."""
|
|
51
|
+
embedder = build_embedder(settings)
|
|
52
|
+
store = build_vector_store(
|
|
53
|
+
settings, dim=embedder.dense_dim, supports_sparse=embedder.supports_sparse
|
|
54
|
+
)
|
|
55
|
+
working = build_working_memory(settings)
|
|
56
|
+
db = Database(settings.database_url)
|
|
57
|
+
db.create_all()
|
|
58
|
+
episodes = EpisodeRepository(db)
|
|
59
|
+
parents = ParentRepository(db)
|
|
60
|
+
feedback = FeedbackRepository(db)
|
|
61
|
+
conflicts = ConflictRepository(db)
|
|
62
|
+
graph = GraphRepository(db)
|
|
63
|
+
agents = AgentProfileRepository(db)
|
|
64
|
+
procedures = ProcedureRepository(db)
|
|
65
|
+
lessons = LessonRepository(db)
|
|
66
|
+
|
|
67
|
+
reranker = build_reranker(settings)
|
|
68
|
+
retriever = Retriever(
|
|
69
|
+
embedder=embedder,
|
|
70
|
+
store=store,
|
|
71
|
+
reranker=reranker,
|
|
72
|
+
parent_lookup=parents.get_texts,
|
|
73
|
+
feedback_lookup=feedback.scores,
|
|
74
|
+
feedback_weight=settings.feedback_weight,
|
|
75
|
+
confidence_weight=settings.confidence_weight,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
policy = WritePolicy(
|
|
79
|
+
dedup_threshold=settings.dedup_threshold,
|
|
80
|
+
min_confidence=settings.min_confidence,
|
|
81
|
+
)
|
|
82
|
+
defaults = RetrievalParams(
|
|
83
|
+
top_k=settings.default_top_k,
|
|
84
|
+
token_budget=settings.default_token_budget,
|
|
85
|
+
candidate_pool=settings.prefetch_limit,
|
|
86
|
+
mmr_lambda=settings.mmr_lambda,
|
|
87
|
+
)
|
|
88
|
+
cognitive = CognitiveServices(
|
|
89
|
+
redactor=build_redactor(settings),
|
|
90
|
+
detector=build_detector(settings),
|
|
91
|
+
extractor=build_extractor(settings),
|
|
92
|
+
feedback=feedback,
|
|
93
|
+
conflicts=conflicts,
|
|
94
|
+
graph=graph,
|
|
95
|
+
agents=agents,
|
|
96
|
+
procedures=procedures,
|
|
97
|
+
lessons=lessons,
|
|
98
|
+
graph_extraction=settings.graph_extraction,
|
|
99
|
+
contradiction_similarity=settings.contradiction_similarity,
|
|
100
|
+
lessons_enabled=settings.lessons_enabled,
|
|
101
|
+
lessons_top_k=settings.lessons_top_k,
|
|
102
|
+
lessons_min_score=settings.lessons_min_score,
|
|
103
|
+
belief_revision=settings.belief_revision,
|
|
104
|
+
conflict_loser_decay=settings.conflict_loser_decay,
|
|
105
|
+
)
|
|
106
|
+
manager = MemoryManager(
|
|
107
|
+
chunker=build_chunker(settings, embedder=embedder),
|
|
108
|
+
embedder=embedder,
|
|
109
|
+
store=store,
|
|
110
|
+
retriever=retriever,
|
|
111
|
+
working=working,
|
|
112
|
+
episodes=episodes,
|
|
113
|
+
parents=parents,
|
|
114
|
+
policy=policy,
|
|
115
|
+
defaults=defaults,
|
|
116
|
+
summarizer=build_summarizer(settings),
|
|
117
|
+
cognitive=cognitive,
|
|
118
|
+
events=_build_events(settings),
|
|
119
|
+
)
|
|
120
|
+
return Container(settings=settings, embedder=embedder, store=store, db=db, manager=manager)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_events(settings: Settings) -> EventEmitter:
|
|
124
|
+
"""Compose the event sinks: Prometheus metrics (if enabled) plus webhooks."""
|
|
125
|
+
from context_bridge.api.metrics import MetricsEmitter
|
|
126
|
+
|
|
127
|
+
sinks: list[EventEmitter] = []
|
|
128
|
+
if settings.metrics_enabled:
|
|
129
|
+
sinks.append(MetricsEmitter())
|
|
130
|
+
webhooks = build_emitter(settings.webhook_url_list(), timeout=settings.webhook_timeout_seconds)
|
|
131
|
+
sinks.append(webhooks)
|
|
132
|
+
return CompositeEmitter(sinks)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_container(request: Request) -> Container:
|
|
136
|
+
"""FastAPI dependency: the process-wide container from app state."""
|
|
137
|
+
return request.app.state.container
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_manager(request: Request) -> MemoryManager:
|
|
141
|
+
"""FastAPI dependency: the shared :class:`MemoryManager`."""
|
|
142
|
+
return request.app.state.container.manager
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Prometheus metrics and the /metrics exposition endpoint.
|
|
2
|
+
|
|
3
|
+
Counters and histograms are module-level singletons (registered once against
|
|
4
|
+
the default registry) and are incremented from the route handlers — the core
|
|
5
|
+
domain layer stays free of any metrics dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Response
|
|
11
|
+
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
|
12
|
+
|
|
13
|
+
WRITES = Counter("cb_memory_writes_total", "Total memory write requests")
|
|
14
|
+
CHUNKS_STORED = Counter("cb_chunks_stored_total", "Chunks persisted to the store")
|
|
15
|
+
CHUNKS_DEDUPED = Counter("cb_chunks_deduped_total", "Chunks suppressed as duplicates")
|
|
16
|
+
|
|
17
|
+
QUERIES = Counter("cb_queries_total", "Total memory query requests")
|
|
18
|
+
QUERY_TOKENS = Histogram(
|
|
19
|
+
"cb_query_tokens_used",
|
|
20
|
+
"Tokens in the assembled context per query",
|
|
21
|
+
buckets=(0, 64, 128, 256, 512, 1024, 2048, 4096, 8192),
|
|
22
|
+
)
|
|
23
|
+
QUERY_CHUNKS = Histogram(
|
|
24
|
+
"cb_query_chunks_returned",
|
|
25
|
+
"Chunks returned per query",
|
|
26
|
+
buckets=(0, 1, 2, 4, 8, 16, 32),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SWEEP_DELETED = Counter("cb_sweep_deleted_total", "Expired memories removed by sweeps")
|
|
30
|
+
MAINTENANCE_RUNS = Counter("cb_maintenance_runs_total", "Maintenance cycles executed")
|
|
31
|
+
|
|
32
|
+
EVENTS = Counter("cb_events_total", "Notable memory events emitted", labelnames=("type",))
|
|
33
|
+
|
|
34
|
+
REQUEST_LATENCY = Histogram(
|
|
35
|
+
"cb_request_latency_seconds",
|
|
36
|
+
"HTTP request latency",
|
|
37
|
+
labelnames=("method", "endpoint", "status"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MetricsEmitter:
|
|
42
|
+
"""An :class:`EventEmitter` that records each event as a Prometheus counter.
|
|
43
|
+
|
|
44
|
+
This keeps the core domain layer free of any metrics dependency: events are
|
|
45
|
+
emitted there, and the wiring layer composes this emitter to observe them.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def emit(self, event_type: str, namespace: str, data: dict) -> None:
|
|
49
|
+
EVENTS.labels(type=event_type).inc()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
router = APIRouter(tags=["metrics"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/metrics")
|
|
56
|
+
def metrics() -> Response:
|
|
57
|
+
"""Expose metrics in the Prometheus text exposition format."""
|
|
58
|
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API route modules."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Truth-maintenance endpoints: inspect and resolve detected contradictions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
6
|
+
|
|
7
|
+
from context_bridge.api.deps import get_manager
|
|
8
|
+
from context_bridge.api.schemas import AutoResolveResponse, ConflictResolveRequest
|
|
9
|
+
from context_bridge.api.security import authorize
|
|
10
|
+
from context_bridge.core.memory.manager import MemoryManager
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/conflicts", tags=["conflicts"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("")
|
|
16
|
+
def list_conflicts(
|
|
17
|
+
request: Request,
|
|
18
|
+
namespace: str = Query(default="default"),
|
|
19
|
+
status_filter: str | None = Query(default=None, alias="status"),
|
|
20
|
+
manager: MemoryManager = Depends(get_manager),
|
|
21
|
+
) -> dict:
|
|
22
|
+
authorize(request, namespace, "read")
|
|
23
|
+
return {"conflicts": manager.list_conflicts(namespace=namespace, status=status_filter)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post("/auto-resolve", response_model=AutoResolveResponse)
|
|
27
|
+
def auto_resolve(
|
|
28
|
+
request: Request,
|
|
29
|
+
namespace: str = Query(default="default"),
|
|
30
|
+
manager: MemoryManager = Depends(get_manager),
|
|
31
|
+
) -> AutoResolveResponse:
|
|
32
|
+
"""Auto-close contradictions where the evidence is decisive (belief revision)."""
|
|
33
|
+
authorize(request, namespace, "write")
|
|
34
|
+
settings = request.app.state.settings
|
|
35
|
+
result = manager.auto_resolve_conflicts(
|
|
36
|
+
namespace=namespace, min_gap=settings.auto_resolve_min_gap
|
|
37
|
+
)
|
|
38
|
+
return AutoResolveResponse(**result)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/{conflict_id}/resolve", status_code=status.HTTP_204_NO_CONTENT)
|
|
42
|
+
def resolve_conflict(
|
|
43
|
+
conflict_id: str,
|
|
44
|
+
req: ConflictResolveRequest,
|
|
45
|
+
request: Request,
|
|
46
|
+
namespace: str = Query(default="default"),
|
|
47
|
+
manager: MemoryManager = Depends(get_manager),
|
|
48
|
+
) -> None:
|
|
49
|
+
authorize(request, namespace, "write")
|
|
50
|
+
if not manager.resolve_conflict(conflict_id, winner_id=req.winner_id):
|
|
51
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conflict not found")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Knowledge-graph endpoints: traverse entities and relations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
6
|
+
|
|
7
|
+
from context_bridge.api.deps import get_manager
|
|
8
|
+
from context_bridge.api.schemas import (
|
|
9
|
+
AliasRequest,
|
|
10
|
+
AliasResponse,
|
|
11
|
+
AlignRequest,
|
|
12
|
+
AlignResponse,
|
|
13
|
+
GraphEdgeOut,
|
|
14
|
+
GraphResponse,
|
|
15
|
+
)
|
|
16
|
+
from context_bridge.api.security import authorize
|
|
17
|
+
from context_bridge.core.memory.manager import MemoryManager
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/graph", tags=["graph"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/neighbors", response_model=GraphResponse)
|
|
23
|
+
def neighbors(
|
|
24
|
+
request: Request,
|
|
25
|
+
entity: str = Query(..., min_length=1),
|
|
26
|
+
namespace: str = Query(default="default"),
|
|
27
|
+
hops: int = Query(default=1, ge=1, le=4),
|
|
28
|
+
manager: MemoryManager = Depends(get_manager),
|
|
29
|
+
) -> GraphResponse:
|
|
30
|
+
"""Return relations reachable from ``entity`` within ``hops`` edges."""
|
|
31
|
+
authorize(request, namespace, "read")
|
|
32
|
+
edges = manager.graph_neighbors(namespace=namespace, entity=entity, hops=hops)
|
|
33
|
+
return GraphResponse(entity=entity, edges=[GraphEdgeOut(**e) for e in edges])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/aliases", response_model=AliasResponse)
|
|
37
|
+
def add_alias(
|
|
38
|
+
request: Request,
|
|
39
|
+
body: AliasRequest,
|
|
40
|
+
manager: MemoryManager = Depends(get_manager),
|
|
41
|
+
) -> AliasResponse:
|
|
42
|
+
"""Declare that ``alias`` refers to the same entity as ``canonical``."""
|
|
43
|
+
authorize(request, body.namespace, "write")
|
|
44
|
+
registered = manager.add_alias(
|
|
45
|
+
namespace=body.namespace, alias=body.alias, canonical=body.canonical
|
|
46
|
+
)
|
|
47
|
+
return AliasResponse(
|
|
48
|
+
registered=registered, aliases=manager.list_aliases(namespace=body.namespace)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/align", response_model=AlignResponse)
|
|
53
|
+
def align(
|
|
54
|
+
request: Request,
|
|
55
|
+
body: AlignRequest,
|
|
56
|
+
manager: MemoryManager = Depends(get_manager),
|
|
57
|
+
) -> AlignResponse:
|
|
58
|
+
"""Auto-merge surface variants of the same entity onto one canonical name."""
|
|
59
|
+
authorize(request, body.namespace, "write")
|
|
60
|
+
result = manager.align_graph(namespace=body.namespace)
|
|
61
|
+
return AlignResponse(**result)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Liveness and readiness endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
|
|
8
|
+
from context_bridge.api.deps import Container, get_container
|
|
9
|
+
from context_bridge.api.schemas import HealthResponse
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["health"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/health", response_model=HealthResponse)
|
|
15
|
+
def health() -> HealthResponse:
|
|
16
|
+
"""Liveness check — the process is up and serving."""
|
|
17
|
+
return HealthResponse(status="ok", components={})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/healthz", response_model=HealthResponse)
|
|
21
|
+
def readiness(container: Container = Depends(get_container)) -> HealthResponse:
|
|
22
|
+
"""Readiness check — verify backing services are reachable."""
|
|
23
|
+
components: dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with container.db.session() as session:
|
|
27
|
+
session.execute(text("SELECT 1"))
|
|
28
|
+
components["database"] = "ok"
|
|
29
|
+
except Exception as exc: # pragma: no cover - depends on live infra
|
|
30
|
+
components["database"] = f"error: {exc}"
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
container.store.get("readiness-probe")
|
|
34
|
+
components["vector_store"] = "ok"
|
|
35
|
+
except Exception as exc: # pragma: no cover - depends on live infra
|
|
36
|
+
components["vector_store"] = f"error: {exc}"
|
|
37
|
+
|
|
38
|
+
settings = container.settings
|
|
39
|
+
if "redis" in (settings.working_provider, settings.rate_limit_backend):
|
|
40
|
+
try: # pragma: no cover - depends on live infra
|
|
41
|
+
import redis
|
|
42
|
+
|
|
43
|
+
redis.Redis.from_url(settings.redis_url).ping()
|
|
44
|
+
components["redis"] = "ok"
|
|
45
|
+
except Exception as exc: # pragma: no cover - depends on live infra
|
|
46
|
+
components["redis"] = f"error: {exc}"
|
|
47
|
+
|
|
48
|
+
status = "ok" if all(v == "ok" for v in components.values()) else "degraded"
|
|
49
|
+
return HealthResponse(status=status, components=components)
|