rotalabs-context 1.0.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.
- rotalabs_context/__init__.py +36 -0
- rotalabs_context/_telemetry.py +34 -0
- rotalabs_context/_version.py +1 -0
- rotalabs_context/adapters/__init__.py +1 -0
- rotalabs_context/adapters/airflow.py +81 -0
- rotalabs_context/backends/__init__.py +1 -0
- rotalabs_context/backends/base.py +34 -0
- rotalabs_context/backends/memory.py +136 -0
- rotalabs_context/backends/platform.py +99 -0
- rotalabs_context/engine.py +247 -0
- rotalabs_context/models.py +99 -0
- rotalabs_context-1.0.0.dist-info/METADATA +126 -0
- rotalabs_context-1.0.0.dist-info/RECORD +15 -0
- rotalabs_context-1.0.0.dist-info/WHEEL +4 -0
- rotalabs_context-1.0.0.dist-info/licenses/LICENSE +3 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rotalabs-context: Context intelligence for AI agents.
|
|
3
|
+
|
|
4
|
+
Provides tools to ingest, search, and subscribe to shared context
|
|
5
|
+
across AI systems. Works standalone (local mode) or connected to
|
|
6
|
+
the Rotascale platform (managed mode).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from rotalabs_context import ContextEngine
|
|
10
|
+
|
|
11
|
+
ctx = ContextEngine()
|
|
12
|
+
ctx.ingest(records=[{"content": "hello"}], source="test")
|
|
13
|
+
results = ctx.search("hello")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from rotalabs_context._version import __version__
|
|
17
|
+
from rotalabs_context.engine import ContextEngine
|
|
18
|
+
from rotalabs_context.models import (
|
|
19
|
+
ContextEntry,
|
|
20
|
+
IngestResult,
|
|
21
|
+
SearchHit,
|
|
22
|
+
SearchResult,
|
|
23
|
+
Scope,
|
|
24
|
+
Sensitivity,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"ContextEngine",
|
|
30
|
+
"ContextEntry",
|
|
31
|
+
"IngestResult",
|
|
32
|
+
"SearchHit",
|
|
33
|
+
"SearchResult",
|
|
34
|
+
"Scope",
|
|
35
|
+
"Sensitivity",
|
|
36
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Telemetry integration for rotalabs-context.
|
|
2
|
+
|
|
3
|
+
Safe wrapper around the rotalabs telemetry system.
|
|
4
|
+
Opt-in only, gracefully degrades if not configured.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from rotalabs._telemetry import is_enabled, report_event
|
|
11
|
+
except ImportError:
|
|
12
|
+
def report_event(*args: Any, **kwargs: Any) -> None:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def is_enabled() -> bool:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def report_context_init(backend: str) -> None:
|
|
20
|
+
if not is_enabled():
|
|
21
|
+
return
|
|
22
|
+
report_event("context.engine.init", {"backend": backend})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def report_context_ingest(source: str, count: int) -> None:
|
|
26
|
+
if not is_enabled():
|
|
27
|
+
return
|
|
28
|
+
report_event("context.ingest", {"source": source, "count": count})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def report_context_search(mode: str, total: int) -> None:
|
|
32
|
+
if not is_enabled():
|
|
33
|
+
return
|
|
34
|
+
report_event("context.search", {"mode": mode, "total": total})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pipeline adapters for integrating context into existing ETL workflows."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Apache Airflow adapter for rotalabs-context.
|
|
2
|
+
|
|
3
|
+
Provides an Airflow operator that ingests context during DAG execution.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from rotalabs_context.adapters.airflow import AirflowContextOperator
|
|
7
|
+
|
|
8
|
+
ingest_task = AirflowContextOperator(
|
|
9
|
+
task_id="ingest_context",
|
|
10
|
+
source="my-etl-pipeline",
|
|
11
|
+
records_callable=lambda: [{"content": "data", "title": "record"}],
|
|
12
|
+
tags=["daily", "production"],
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from airflow.models import BaseOperator
|
|
22
|
+
except ImportError:
|
|
23
|
+
# Stub for when airflow is not installed
|
|
24
|
+
class BaseOperator: # type: ignore[no-redef]
|
|
25
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def execute(self, context: Any) -> Any:
|
|
29
|
+
raise ImportError("apache-airflow is required. Install with: pip install rotalabs-context[airflow]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AirflowContextOperator(BaseOperator):
|
|
33
|
+
"""Airflow operator that ingests records into the context engine.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
source : str
|
|
38
|
+
Source identifier for the ingested context.
|
|
39
|
+
records_callable : callable
|
|
40
|
+
Function that returns a list of record dicts.
|
|
41
|
+
tags : list[str]
|
|
42
|
+
Tags to apply.
|
|
43
|
+
scope : str
|
|
44
|
+
Visibility scope.
|
|
45
|
+
api_key : str, optional
|
|
46
|
+
Rotascale API key. If not provided, uses ROTASCALE_API_KEY env var.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
source: str,
|
|
52
|
+
records_callable: Callable[..., list[dict[str, Any]]],
|
|
53
|
+
tags: list[str] | None = None,
|
|
54
|
+
scope: str = "global",
|
|
55
|
+
api_key: str | None = None,
|
|
56
|
+
base_url: str = "https://api.rotascale.com",
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(**kwargs)
|
|
60
|
+
self.source = source
|
|
61
|
+
self.records_callable = records_callable
|
|
62
|
+
self.tags = tags or []
|
|
63
|
+
self.scope = scope
|
|
64
|
+
self.api_key = api_key
|
|
65
|
+
self.base_url = base_url
|
|
66
|
+
|
|
67
|
+
def execute(self, context: Any) -> dict[str, Any]:
|
|
68
|
+
import os
|
|
69
|
+
from rotalabs_context import ContextEngine
|
|
70
|
+
|
|
71
|
+
api_key = self.api_key or os.environ.get("ROTASCALE_API_KEY")
|
|
72
|
+
ctx = ContextEngine(api_key=api_key, base_url=self.base_url)
|
|
73
|
+
|
|
74
|
+
records = self.records_callable()
|
|
75
|
+
result = ctx.ingest(
|
|
76
|
+
records=records,
|
|
77
|
+
source=self.source,
|
|
78
|
+
tags=self.tags,
|
|
79
|
+
scope=self.scope,
|
|
80
|
+
)
|
|
81
|
+
return result.model_dump()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pluggable storage backends for rotalabs-context."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Abstract backend interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
|
|
7
|
+
from rotalabs_context.models import IngestRecord, IngestResult, SearchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Backend(abc.ABC):
|
|
11
|
+
"""Abstract storage backend for the context engine."""
|
|
12
|
+
|
|
13
|
+
@abc.abstractmethod
|
|
14
|
+
def ingest(
|
|
15
|
+
self,
|
|
16
|
+
records: list[IngestRecord],
|
|
17
|
+
source: str,
|
|
18
|
+
tags: list[str],
|
|
19
|
+
scope: str,
|
|
20
|
+
sensitivity: str,
|
|
21
|
+
generate_embeddings: bool,
|
|
22
|
+
extract_entities: bool,
|
|
23
|
+
) -> IngestResult:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
def search(
|
|
28
|
+
self,
|
|
29
|
+
query: str,
|
|
30
|
+
top_k: int,
|
|
31
|
+
mode: str,
|
|
32
|
+
filters: dict,
|
|
33
|
+
) -> SearchResult:
|
|
34
|
+
...
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""In-memory backend for local development and testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from rotalabs_context.backends.base import Backend
|
|
11
|
+
from rotalabs_context.models import (
|
|
12
|
+
ContextEntry,
|
|
13
|
+
IngestRecord,
|
|
14
|
+
IngestResult,
|
|
15
|
+
SearchHit,
|
|
16
|
+
SearchResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MemoryBackend(Backend):
|
|
21
|
+
"""In-memory context store. Data is lost when process exits.
|
|
22
|
+
|
|
23
|
+
Good for testing, prototyping, and local development.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._entries: list[ContextEntry] = []
|
|
28
|
+
self._counter = 0
|
|
29
|
+
|
|
30
|
+
def ingest(
|
|
31
|
+
self,
|
|
32
|
+
records: list[IngestRecord],
|
|
33
|
+
source: str,
|
|
34
|
+
tags: list[str],
|
|
35
|
+
scope: str,
|
|
36
|
+
sensitivity: str,
|
|
37
|
+
generate_embeddings: bool,
|
|
38
|
+
extract_entities: bool,
|
|
39
|
+
) -> IngestResult:
|
|
40
|
+
entry_ids: list[str] = []
|
|
41
|
+
for record in records:
|
|
42
|
+
self._counter += 1
|
|
43
|
+
entry_id = f"ctx_{self._counter:06d}"
|
|
44
|
+
content_hash = hashlib.sha256(record.content.encode()).hexdigest()
|
|
45
|
+
|
|
46
|
+
entry = ContextEntry(
|
|
47
|
+
id=entry_id,
|
|
48
|
+
source=source,
|
|
49
|
+
source_id=record.source_id,
|
|
50
|
+
content_type=record.content_type,
|
|
51
|
+
title=record.title,
|
|
52
|
+
content=record.content,
|
|
53
|
+
content_hash=content_hash,
|
|
54
|
+
tags=tags,
|
|
55
|
+
scope=scope,
|
|
56
|
+
sensitivity=sensitivity,
|
|
57
|
+
metadata=record.metadata,
|
|
58
|
+
created_at=datetime.now(timezone.utc),
|
|
59
|
+
)
|
|
60
|
+
self._entries.append(entry)
|
|
61
|
+
entry_ids.append(entry_id)
|
|
62
|
+
|
|
63
|
+
return IngestResult(
|
|
64
|
+
entries_created=len(records),
|
|
65
|
+
embeddings_generated=0, # No embeddings in memory mode
|
|
66
|
+
entities_extracted=0,
|
|
67
|
+
entry_ids=entry_ids,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def search(
|
|
71
|
+
self,
|
|
72
|
+
query: str,
|
|
73
|
+
top_k: int,
|
|
74
|
+
mode: str,
|
|
75
|
+
filters: dict,
|
|
76
|
+
) -> SearchResult:
|
|
77
|
+
start = time.monotonic()
|
|
78
|
+
query_lower = query.lower()
|
|
79
|
+
query_tokens = set(re.findall(r"[a-z0-9]+", query_lower))
|
|
80
|
+
|
|
81
|
+
scored: list[SearchHit] = []
|
|
82
|
+
for entry in self._entries:
|
|
83
|
+
# Apply filters
|
|
84
|
+
if filters.get("tags"):
|
|
85
|
+
if not any(t in entry.tags for t in filters["tags"]):
|
|
86
|
+
continue
|
|
87
|
+
if filters.get("sources"):
|
|
88
|
+
if entry.source not in filters["sources"]:
|
|
89
|
+
continue
|
|
90
|
+
if filters.get("scopes"):
|
|
91
|
+
if entry.scope not in filters["scopes"]:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Keyword matching
|
|
95
|
+
text = f"{entry.title or ''} {entry.content}".lower()
|
|
96
|
+
text_tokens = set(re.findall(r"[a-z0-9]+", text))
|
|
97
|
+
|
|
98
|
+
if not query_tokens:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
overlap = query_tokens & text_tokens
|
|
102
|
+
if not overlap:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
score = len(overlap) / len(query_tokens)
|
|
106
|
+
|
|
107
|
+
# Extract highlights
|
|
108
|
+
highlights: list[str] = []
|
|
109
|
+
for sentence in re.split(r"[.!?\n]+", entry.content):
|
|
110
|
+
sentence = sentence.strip()
|
|
111
|
+
if not sentence:
|
|
112
|
+
continue
|
|
113
|
+
sent_tokens = set(re.findall(r"[a-z0-9]+", sentence.lower()))
|
|
114
|
+
if query_tokens & sent_tokens:
|
|
115
|
+
highlights.append(sentence[:200])
|
|
116
|
+
if len(highlights) >= 3:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
scored.append(SearchHit(
|
|
120
|
+
entry=entry,
|
|
121
|
+
score=round(score, 4),
|
|
122
|
+
highlights=highlights,
|
|
123
|
+
match_type="keyword",
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
scored.sort(key=lambda x: x.score, reverse=True)
|
|
127
|
+
results = scored[:top_k]
|
|
128
|
+
took_ms = round((time.monotonic() - start) * 1000, 2)
|
|
129
|
+
|
|
130
|
+
return SearchResult(
|
|
131
|
+
query=query,
|
|
132
|
+
mode=mode,
|
|
133
|
+
results=results,
|
|
134
|
+
total=len(results),
|
|
135
|
+
took_ms=took_ms,
|
|
136
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Rotascale platform backend — connects to the managed API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rotalabs_context.backends.base import Backend
|
|
6
|
+
from rotalabs_context.models import (
|
|
7
|
+
ContextEntry,
|
|
8
|
+
IngestRecord,
|
|
9
|
+
IngestResult,
|
|
10
|
+
SearchHit,
|
|
11
|
+
SearchResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlatformBackend(Backend):
|
|
16
|
+
"""Backend that connects to the Rotascale platform API.
|
|
17
|
+
|
|
18
|
+
Requires ``httpx`` (install with ``pip install rotalabs-context[platform]``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: str | None, base_url: str) -> None:
|
|
22
|
+
try:
|
|
23
|
+
import httpx
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"httpx is required for platform mode. "
|
|
27
|
+
"Install with: pip install rotalabs-context[platform]"
|
|
28
|
+
)
|
|
29
|
+
self._client = httpx.Client(
|
|
30
|
+
base_url=base_url,
|
|
31
|
+
headers={
|
|
32
|
+
"Authorization": f"Bearer {api_key}",
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"User-Agent": "rotalabs-context/1.0.0",
|
|
35
|
+
},
|
|
36
|
+
timeout=30.0,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def ingest(
|
|
40
|
+
self,
|
|
41
|
+
records: list[IngestRecord],
|
|
42
|
+
source: str,
|
|
43
|
+
tags: list[str],
|
|
44
|
+
scope: str,
|
|
45
|
+
sensitivity: str,
|
|
46
|
+
generate_embeddings: bool,
|
|
47
|
+
extract_entities: bool,
|
|
48
|
+
) -> IngestResult:
|
|
49
|
+
resp = self._client.post(
|
|
50
|
+
"/v1/context-engine/ingest",
|
|
51
|
+
json={
|
|
52
|
+
"records": [r.model_dump() for r in records],
|
|
53
|
+
"source": source,
|
|
54
|
+
"tags": tags,
|
|
55
|
+
"scope": scope,
|
|
56
|
+
"sensitivity": sensitivity,
|
|
57
|
+
"generate_embeddings": generate_embeddings,
|
|
58
|
+
"extract_entities": extract_entities,
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
data = resp.json()
|
|
63
|
+
return IngestResult(**data)
|
|
64
|
+
|
|
65
|
+
def search(
|
|
66
|
+
self,
|
|
67
|
+
query: str,
|
|
68
|
+
top_k: int,
|
|
69
|
+
mode: str,
|
|
70
|
+
filters: dict,
|
|
71
|
+
) -> SearchResult:
|
|
72
|
+
resp = self._client.post(
|
|
73
|
+
"/v1/context-engine/search",
|
|
74
|
+
json={
|
|
75
|
+
"query": query,
|
|
76
|
+
"top_k": top_k,
|
|
77
|
+
"mode": mode,
|
|
78
|
+
"filters": filters or None,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
data = resp.json()
|
|
83
|
+
|
|
84
|
+
results = []
|
|
85
|
+
for hit in data.get("results", []):
|
|
86
|
+
results.append(SearchHit(
|
|
87
|
+
entry=ContextEntry(**hit["entry"]),
|
|
88
|
+
score=hit["score"],
|
|
89
|
+
highlights=hit.get("highlights", []),
|
|
90
|
+
match_type=hit.get("match_type", mode),
|
|
91
|
+
))
|
|
92
|
+
|
|
93
|
+
return SearchResult(
|
|
94
|
+
query=data["query"],
|
|
95
|
+
mode=data["mode"],
|
|
96
|
+
results=results,
|
|
97
|
+
total=data["total"],
|
|
98
|
+
took_ms=data.get("took_ms", 0),
|
|
99
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""ContextEngine — main entry point for rotalabs-context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from rotalabs_context.models import (
|
|
10
|
+
ContextEntry,
|
|
11
|
+
IngestRecord,
|
|
12
|
+
IngestResult,
|
|
13
|
+
SearchHit,
|
|
14
|
+
SearchResult,
|
|
15
|
+
Subscription,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContextEngine:
|
|
22
|
+
"""Context intelligence engine.
|
|
23
|
+
|
|
24
|
+
Provides ingest, search, and subscribe capabilities. Works in two modes:
|
|
25
|
+
|
|
26
|
+
- **Local mode** (default): Uses an in-memory or SQLite backend for
|
|
27
|
+
zero-config local development.
|
|
28
|
+
- **Platform mode**: Connects to the Rotascale platform API for
|
|
29
|
+
managed context intelligence with embedding generation, entity
|
|
30
|
+
extraction, ACLs, and real-time subscriptions.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
api_key : str, optional
|
|
35
|
+
Rotascale API key. If provided, enables platform mode.
|
|
36
|
+
base_url : str
|
|
37
|
+
Base URL for the Rotascale API.
|
|
38
|
+
backend : str
|
|
39
|
+
Backend to use: "memory" (default), "sqlite", or "platform".
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
>>> ctx = ContextEngine()
|
|
44
|
+
>>> ctx.ingest(records=[{"content": "hello"}], source="test")
|
|
45
|
+
>>> results = ctx.search("hello")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
base_url: str = "https://api.rotascale.com",
|
|
52
|
+
backend: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.api_key = api_key
|
|
55
|
+
self.base_url = base_url.rstrip("/")
|
|
56
|
+
self._event_handlers: dict[str, list[dict]] = {}
|
|
57
|
+
|
|
58
|
+
# Select backend
|
|
59
|
+
if backend:
|
|
60
|
+
self._backend_type = backend
|
|
61
|
+
elif api_key:
|
|
62
|
+
self._backend_type = "platform"
|
|
63
|
+
else:
|
|
64
|
+
self._backend_type = "memory"
|
|
65
|
+
|
|
66
|
+
# Initialize backend
|
|
67
|
+
if self._backend_type == "platform":
|
|
68
|
+
from rotalabs_context.backends.platform import PlatformBackend
|
|
69
|
+
self._backend = PlatformBackend(api_key=api_key, base_url=self.base_url)
|
|
70
|
+
else:
|
|
71
|
+
from rotalabs_context.backends.memory import MemoryBackend
|
|
72
|
+
self._backend = MemoryBackend()
|
|
73
|
+
|
|
74
|
+
# Report telemetry
|
|
75
|
+
_report_init(self._backend_type)
|
|
76
|
+
|
|
77
|
+
def ingest(
|
|
78
|
+
self,
|
|
79
|
+
records: list[dict[str, Any]],
|
|
80
|
+
source: str,
|
|
81
|
+
tags: list[str] | None = None,
|
|
82
|
+
scope: str = "global",
|
|
83
|
+
sensitivity: str = "internal",
|
|
84
|
+
generate_embeddings: bool = True,
|
|
85
|
+
extract_entities: bool = True,
|
|
86
|
+
) -> IngestResult:
|
|
87
|
+
"""Ingest a batch of records into the context store.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
records : list[dict]
|
|
92
|
+
Records to ingest. Each must have a "content" key.
|
|
93
|
+
source : str
|
|
94
|
+
Source identifier (e.g., "airflow-pipeline", "webhook", "manual").
|
|
95
|
+
tags : list[str], optional
|
|
96
|
+
Tags to apply to all records.
|
|
97
|
+
scope : str
|
|
98
|
+
Visibility scope (default: "global").
|
|
99
|
+
sensitivity : str
|
|
100
|
+
Sensitivity level (default: "internal").
|
|
101
|
+
generate_embeddings : bool
|
|
102
|
+
Whether to generate embeddings (platform mode only).
|
|
103
|
+
extract_entities : bool
|
|
104
|
+
Whether to extract entities (platform mode only).
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
IngestResult
|
|
109
|
+
Summary of the ingestion.
|
|
110
|
+
"""
|
|
111
|
+
parsed = [IngestRecord(**r) if isinstance(r, dict) else r for r in records]
|
|
112
|
+
result = self._backend.ingest(
|
|
113
|
+
records=parsed,
|
|
114
|
+
source=source,
|
|
115
|
+
tags=tags or [],
|
|
116
|
+
scope=scope,
|
|
117
|
+
sensitivity=sensitivity,
|
|
118
|
+
generate_embeddings=generate_embeddings,
|
|
119
|
+
extract_entities=extract_entities,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Fire event handlers
|
|
123
|
+
self._fire_event("context.created", {
|
|
124
|
+
"source": source,
|
|
125
|
+
"entries_created": result.entries_created,
|
|
126
|
+
"entry_ids": result.entry_ids,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
_report_ingest(source, result.entries_created)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def search(
|
|
133
|
+
self,
|
|
134
|
+
query: str,
|
|
135
|
+
top_k: int = 20,
|
|
136
|
+
mode: str = "hybrid",
|
|
137
|
+
filters: dict[str, Any] | None = None,
|
|
138
|
+
) -> SearchResult:
|
|
139
|
+
"""Search the context store.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
query : str
|
|
144
|
+
Search query text.
|
|
145
|
+
top_k : int
|
|
146
|
+
Maximum results to return.
|
|
147
|
+
mode : str
|
|
148
|
+
Search mode: "semantic", "keyword", or "hybrid".
|
|
149
|
+
filters : dict, optional
|
|
150
|
+
Filters: tags, sources, scopes, sensitivity, date_from, date_to.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
SearchResult
|
|
155
|
+
Search results with scores and highlights.
|
|
156
|
+
"""
|
|
157
|
+
result = self._backend.search(
|
|
158
|
+
query=query,
|
|
159
|
+
top_k=top_k,
|
|
160
|
+
mode=mode,
|
|
161
|
+
filters=filters or {},
|
|
162
|
+
)
|
|
163
|
+
_report_search(query, mode, result.total)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def on(
|
|
167
|
+
self,
|
|
168
|
+
event_type: str,
|
|
169
|
+
callback: Callable[..., Any],
|
|
170
|
+
filter: dict[str, Any] | None = None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Subscribe to context events.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
event_type : str
|
|
177
|
+
Event type to listen for (e.g., "context.created").
|
|
178
|
+
callback : callable
|
|
179
|
+
Function to call when event occurs.
|
|
180
|
+
filter : dict, optional
|
|
181
|
+
Filter criteria (e.g., {"tags": ["support"]}).
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
str
|
|
186
|
+
Subscription ID.
|
|
187
|
+
"""
|
|
188
|
+
import uuid
|
|
189
|
+
sub_id = str(uuid.uuid4())[:8]
|
|
190
|
+
if event_type not in self._event_handlers:
|
|
191
|
+
self._event_handlers[event_type] = []
|
|
192
|
+
self._event_handlers[event_type].append({
|
|
193
|
+
"id": sub_id,
|
|
194
|
+
"callback": callback,
|
|
195
|
+
"filter": filter or {},
|
|
196
|
+
})
|
|
197
|
+
return sub_id
|
|
198
|
+
|
|
199
|
+
def off(self, event_type: str, subscription_id: str) -> None:
|
|
200
|
+
"""Remove an event subscription."""
|
|
201
|
+
if event_type in self._event_handlers:
|
|
202
|
+
self._event_handlers[event_type] = [
|
|
203
|
+
h for h in self._event_handlers[event_type]
|
|
204
|
+
if h["id"] != subscription_id
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
def _fire_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
208
|
+
"""Fire event handlers matching the event type and filters."""
|
|
209
|
+
for handler in self._event_handlers.get(event_type, []):
|
|
210
|
+
filter_spec = handler.get("filter", {})
|
|
211
|
+
# Check tag filter
|
|
212
|
+
if "tags" in filter_spec and "tags" in data:
|
|
213
|
+
if not any(t in data.get("tags", []) for t in filter_spec["tags"]):
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
handler["callback"](data)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.exception("Error in event handler for %s", event_type)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── Telemetry helpers ──────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def _report_init(backend_type: str) -> None:
|
|
224
|
+
try:
|
|
225
|
+
from rotalabs._telemetry import is_enabled, report_event
|
|
226
|
+
if is_enabled():
|
|
227
|
+
report_event("context.engine.init", {"backend": backend_type})
|
|
228
|
+
except ImportError:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _report_ingest(source: str, count: int) -> None:
|
|
233
|
+
try:
|
|
234
|
+
from rotalabs._telemetry import is_enabled, report_event
|
|
235
|
+
if is_enabled():
|
|
236
|
+
report_event("context.ingest", {"source": source, "count": count})
|
|
237
|
+
except ImportError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _report_search(query: str, mode: str, total: int) -> None:
|
|
242
|
+
try:
|
|
243
|
+
from rotalabs._telemetry import is_enabled, report_event
|
|
244
|
+
if is_enabled():
|
|
245
|
+
report_event("context.search", {"mode": mode, "total": total})
|
|
246
|
+
except ImportError:
|
|
247
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Data models for rotalabs-context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Scope(str, enum.Enum):
|
|
13
|
+
"""Visibility scope for context entries."""
|
|
14
|
+
GLOBAL = "global"
|
|
15
|
+
TEAM = "team"
|
|
16
|
+
AGENT = "agent"
|
|
17
|
+
PROJECT = "project"
|
|
18
|
+
USER = "user"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Sensitivity(str, enum.Enum):
|
|
22
|
+
"""Sensitivity classification for context entries."""
|
|
23
|
+
PUBLIC = "public"
|
|
24
|
+
INTERNAL = "internal"
|
|
25
|
+
CONFIDENTIAL = "confidential"
|
|
26
|
+
RESTRICTED = "restricted"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ContextEntry(BaseModel):
|
|
30
|
+
"""A single context entry in the store."""
|
|
31
|
+
id: str
|
|
32
|
+
source: str
|
|
33
|
+
source_id: str | None = None
|
|
34
|
+
content_type: str = "text"
|
|
35
|
+
title: str | None = None
|
|
36
|
+
content: str
|
|
37
|
+
content_hash: str
|
|
38
|
+
tags: list[str] = Field(default_factory=list)
|
|
39
|
+
scope: str = "global"
|
|
40
|
+
sensitivity: str = "internal"
|
|
41
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
42
|
+
created_at: datetime | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SearchHit(BaseModel):
|
|
46
|
+
"""A single search result."""
|
|
47
|
+
entry: ContextEntry
|
|
48
|
+
score: float
|
|
49
|
+
highlights: list[str] = Field(default_factory=list)
|
|
50
|
+
match_type: str = "hybrid"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SearchResult(BaseModel):
|
|
54
|
+
"""Search response containing hits."""
|
|
55
|
+
query: str
|
|
56
|
+
mode: str
|
|
57
|
+
results: list[SearchHit]
|
|
58
|
+
total: int
|
|
59
|
+
took_ms: float = 0.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IngestResult(BaseModel):
|
|
63
|
+
"""Result of a batch ingest operation."""
|
|
64
|
+
entries_created: int
|
|
65
|
+
embeddings_generated: int
|
|
66
|
+
entities_extracted: int
|
|
67
|
+
entry_ids: list[str] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IngestRecord(BaseModel):
|
|
71
|
+
"""A single record to ingest."""
|
|
72
|
+
content: str
|
|
73
|
+
title: str | None = None
|
|
74
|
+
content_type: str = "text"
|
|
75
|
+
source_id: str | None = None
|
|
76
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Subscription(BaseModel):
|
|
80
|
+
"""A context subscription."""
|
|
81
|
+
id: str
|
|
82
|
+
subscriber_id: str
|
|
83
|
+
subscriber_type: str
|
|
84
|
+
filter_tags: list[str] = Field(default_factory=list)
|
|
85
|
+
filter_scopes: list[str] = Field(default_factory=list)
|
|
86
|
+
filter_sources: list[str] = Field(default_factory=list)
|
|
87
|
+
delivery_method: str
|
|
88
|
+
webhook_url: str | None = None
|
|
89
|
+
is_active: bool = True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Entity(BaseModel):
|
|
93
|
+
"""An extracted entity."""
|
|
94
|
+
id: str
|
|
95
|
+
name: str
|
|
96
|
+
entity_type: str
|
|
97
|
+
source_entry_id: str | None = None
|
|
98
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
99
|
+
mentions_count: int = 1
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rotalabs-context
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Context intelligence for AI agents - Ingest, search, and subscribe to shared context across your AI systems
|
|
5
|
+
Project-URL: Homepage, https://rotalabs.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/rotalabs/rotalabs-context
|
|
7
|
+
Project-URL: Documentation, https://rotalabs.ai/docs/context
|
|
8
|
+
Author-email: Subhadip Mitra <subhadip@rotalabs.ai>, Rotalabs Research <research@rotalabs.ai>
|
|
9
|
+
License-Expression: AGPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,context,context-engine,embeddings,etl,knowledge-graph,rag,semantic-search
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: pydantic>=2.0.0
|
|
24
|
+
Provides-Extra: airflow
|
|
25
|
+
Requires-Dist: apache-airflow>=2.5.0; extra == 'airflow'
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: httpx>=0.25.0; extra == 'all'
|
|
28
|
+
Requires-Dist: numpy>=1.24.0; extra == 'all'
|
|
29
|
+
Requires-Dist: rotalabs[telemetry]>=1.0.0; extra == 'all'
|
|
30
|
+
Requires-Dist: sentence-transformers>=2.2.0; extra == 'all'
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: mypy>=1.5.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
37
|
+
Provides-Extra: local
|
|
38
|
+
Requires-Dist: numpy>=1.24.0; extra == 'local'
|
|
39
|
+
Requires-Dist: sentence-transformers>=2.2.0; extra == 'local'
|
|
40
|
+
Provides-Extra: platform
|
|
41
|
+
Requires-Dist: httpx>=0.25.0; extra == 'platform'
|
|
42
|
+
Provides-Extra: telemetry
|
|
43
|
+
Requires-Dist: rotalabs[telemetry]>=1.0.0; extra == 'telemetry'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# rotalabs-context
|
|
47
|
+
|
|
48
|
+
> Context intelligence for AI agents — Ingest, search, and subscribe to shared context across your AI systems.
|
|
49
|
+
|
|
50
|
+
Part of the [Rotalabs](https://rotalabs.ai) trust intelligence research ecosystem.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install rotalabs-context
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
With local embedding support:
|
|
59
|
+
```bash
|
|
60
|
+
pip install rotalabs-context[local]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
With Rotascale platform integration:
|
|
64
|
+
```bash
|
|
65
|
+
pip install rotalabs-context[platform]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from rotalabs_context import ContextEngine
|
|
72
|
+
|
|
73
|
+
# Local mode (zero config, SQLite backend)
|
|
74
|
+
ctx = ContextEngine()
|
|
75
|
+
|
|
76
|
+
# Platform mode (connects to Rotascale)
|
|
77
|
+
ctx = ContextEngine(
|
|
78
|
+
api_key="rot_...",
|
|
79
|
+
base_url="https://api.rotascale.com",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Ingest context
|
|
83
|
+
result = ctx.ingest(
|
|
84
|
+
records=[
|
|
85
|
+
{"content": "User reported login issue", "title": "Support ticket #1234"},
|
|
86
|
+
{"content": "Auth service latency spike at 2pm UTC", "title": "Incident report"},
|
|
87
|
+
],
|
|
88
|
+
source="helpdesk",
|
|
89
|
+
tags=["support", "auth"],
|
|
90
|
+
scope="team:support",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Search
|
|
94
|
+
results = ctx.search("login issues", top_k=5, mode="semantic")
|
|
95
|
+
for hit in results:
|
|
96
|
+
print(f"{hit.score:.2f} — {hit.title}")
|
|
97
|
+
|
|
98
|
+
# Subscribe to context updates
|
|
99
|
+
ctx.on("context.created", filter={"tags": ["support"]}, callback=my_handler)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Features
|
|
103
|
+
|
|
104
|
+
- **Ingest**: Push data from any pipeline; we add the "C" (embeddings, entities, relationships)
|
|
105
|
+
- **Search**: Semantic + keyword + hybrid search across all ingested context
|
|
106
|
+
- **Subscribe**: Real-time context propagation via events
|
|
107
|
+
- **ACLs**: Scope-based access control (global, team, agent, project, user)
|
|
108
|
+
- **Pluggable backends**: SQLite (local), PostgreSQL (self-hosted), Rotascale Platform (managed)
|
|
109
|
+
|
|
110
|
+
## Pipeline Adapters
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Airflow
|
|
114
|
+
from rotalabs_context.adapters import AirflowContextOperator
|
|
115
|
+
|
|
116
|
+
# In your DAG
|
|
117
|
+
ingest_task = AirflowContextOperator(
|
|
118
|
+
task_id="ingest_to_context",
|
|
119
|
+
source="airflow-etl",
|
|
120
|
+
tags=["daily-batch"],
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
AGPL-3.0 — See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
rotalabs_context/__init__.py,sha256=NyCfzb7QDx6WCsLSk6tPqWqzHy4D9wi_t6fk61lMDKQ,813
|
|
2
|
+
rotalabs_context/_telemetry.py,sha256=_orcKtb2a0Wz923O_68_ctggcStRufaE-UBrlPD4bXk,879
|
|
3
|
+
rotalabs_context/_version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
4
|
+
rotalabs_context/engine.py,sha256=kl_esUPUrtjkqBzt8suHkK7066c8wyk7IzH2J6DNJtg,7691
|
|
5
|
+
rotalabs_context/models.py,sha256=5xkkZVPiwOc6ol4AfMY7kOEKogzKA2sMSxegyv7gyjE,2460
|
|
6
|
+
rotalabs_context/adapters/__init__.py,sha256=ufd8HaKQAk03sxkPIMs-6YOXCOpsZ00bxDObdW627Qs,77
|
|
7
|
+
rotalabs_context/adapters/airflow.py,sha256=vkeiVBCxjPeSEgJtOPBC_tE35laXAw0xZpxCtNrSLHk,2451
|
|
8
|
+
rotalabs_context/backends/__init__.py,sha256=mFsVHMQSSYaXuUfmB_0kJw9iS99QiMJV-afdZFj8aR8,55
|
|
9
|
+
rotalabs_context/backends/base.py,sha256=Uo7VM1zXi1d8wk2bI8wOjXFBgf2n0ANxWJhK5a68-KE,703
|
|
10
|
+
rotalabs_context/backends/memory.py,sha256=jP7IBXa_u-4sFfiJe3B15VHDqPaKRKGAurj30kn4u3A,4072
|
|
11
|
+
rotalabs_context/backends/platform.py,sha256=7R0Y_3sP-yrMNKIUwHD6a4bnYmWKzV0huvaBb7IxL78,2835
|
|
12
|
+
rotalabs_context-1.0.0.dist-info/METADATA,sha256=zfwpu_3y9oFOWdy6WrrD6XBAyzlk9DXLiRZ22wn6rPg,4110
|
|
13
|
+
rotalabs_context-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
rotalabs_context-1.0.0.dist-info/licenses/LICENSE,sha256=NHBABKRQussR78sGiLyZiA98rt-Q2SAvaMwSwFkctGg,105
|
|
15
|
+
rotalabs_context-1.0.0.dist-info/RECORD,,
|