cfabric-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cfabric_mcp-0.1.0/PKG-INFO +164 -0
- cfabric_mcp-0.1.0/README.md +145 -0
- cfabric_mcp-0.1.0/cfabric_mcp/__init__.py +38 -0
- cfabric_mcp-0.1.0/cfabric_mcp/cache.py +298 -0
- cfabric_mcp-0.1.0/cfabric_mcp/corpus_manager.py +155 -0
- cfabric_mcp-0.1.0/cfabric_mcp/resources.py +85 -0
- cfabric_mcp-0.1.0/cfabric_mcp/server.py +491 -0
- cfabric_mcp-0.1.0/cfabric_mcp/tools.py +762 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/PKG-INFO +164 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/SOURCES.txt +16 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/dependency_links.txt +1 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/entry_points.txt +2 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/requires.txt +7 -0
- cfabric_mcp-0.1.0/cfabric_mcp.egg-info/top_level.txt +1 -0
- cfabric_mcp-0.1.0/pyproject.toml +39 -0
- cfabric_mcp-0.1.0/setup.cfg +4 -0
- cfabric_mcp-0.1.0/tests/test_cache.py +297 -0
- cfabric_mcp-0.1.0/tests/test_tools.py +50 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cfabric-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Context Fabric corpus operations
|
|
5
|
+
Author: Cody Kingham
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: context-fabric>=0.2.0
|
|
14
|
+
Requires-Dist: mcp<2,>=1.0
|
|
15
|
+
Requires-Dist: uvicorn>=0.32.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Context-Fabric MCP Server
|
|
21
|
+
|
|
22
|
+
Query Context-Fabric corpora via MCP (Model Context Protocol).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install cfabric-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Transports
|
|
31
|
+
|
|
32
|
+
The server supports three transport modes:
|
|
33
|
+
|
|
34
|
+
| Transport | Flag | Use Case |
|
|
35
|
+
|-----------|------|----------|
|
|
36
|
+
| stdio | (default) | Claude Desktop, local MCP clients |
|
|
37
|
+
| SSE | `--sse PORT` | Cursor, remote MCP clients |
|
|
38
|
+
| Streamable HTTP | `--http PORT` | Production deployments |
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# stdio transport (Claude Desktop)
|
|
44
|
+
cfabric-mcp --corpus /path/to/bhsa
|
|
45
|
+
|
|
46
|
+
# SSE transport (Cursor, remote clients)
|
|
47
|
+
cfabric-mcp --corpus /path/to/bhsa --sse 8000
|
|
48
|
+
|
|
49
|
+
# Streamable HTTP transport (production)
|
|
50
|
+
cfabric-mcp --corpus /path/to/bhsa --http 8000
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
--corpus PATH # Corpus to load (required, repeatable)
|
|
57
|
+
--corpus name=PATH # Named corpus
|
|
58
|
+
--sse PORT # SSE transport on port (endpoint: /sse)
|
|
59
|
+
--http PORT # Streamable HTTP transport on port (endpoint: /mcp)
|
|
60
|
+
--host HOST # Host to bind (default: 0.0.0.0)
|
|
61
|
+
--features "sp lex" # Load only specific features
|
|
62
|
+
--verbose # Debug logging
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Tools
|
|
66
|
+
|
|
67
|
+
The server exposes 10 MCP tools organized in layers for efficient discovery.
|
|
68
|
+
|
|
69
|
+
> **Note:** A usage guide is automatically provided to clients via the MCP `instructions` field at connection time.
|
|
70
|
+
|
|
71
|
+
### Discovery Tools
|
|
72
|
+
|
|
73
|
+
| Tool | Description |
|
|
74
|
+
|------|-------------|
|
|
75
|
+
| `list_corpora` | List loaded corpora |
|
|
76
|
+
| `describe_corpus` | Get corpus structure (node types, sections) |
|
|
77
|
+
| `list_features` | Browse features with optional node_type filter |
|
|
78
|
+
| `describe_feature` | Get feature details with sample values |
|
|
79
|
+
| `get_text_formats` | Get text encoding samples (cached) |
|
|
80
|
+
|
|
81
|
+
### Search Tools
|
|
82
|
+
|
|
83
|
+
| Tool | Description |
|
|
84
|
+
|------|-------------|
|
|
85
|
+
| `search` | Pattern search (results/count/statistics/passages) |
|
|
86
|
+
| `search_continue` | Paginate search results |
|
|
87
|
+
| `search_syntax_guide` | Search syntax docs (section-based) |
|
|
88
|
+
|
|
89
|
+
### Data Access Tools
|
|
90
|
+
|
|
91
|
+
| Tool | Description |
|
|
92
|
+
|------|-------------|
|
|
93
|
+
| `get_passages` | Get text by section references |
|
|
94
|
+
| `get_node_features` | Get feature values for nodes |
|
|
95
|
+
|
|
96
|
+
## Discovery Flow
|
|
97
|
+
|
|
98
|
+
The tools are designed for hierarchical, iterative discovery:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
describe_corpus()
|
|
102
|
+
│
|
|
103
|
+
├─► list_features() → Browse all features
|
|
104
|
+
│ └─► describe_feature("sp") → Full details + samples
|
|
105
|
+
│
|
|
106
|
+
├─► list_features(node_types=["word"]) → Filter by node type
|
|
107
|
+
│ └─► describe_feature(...)
|
|
108
|
+
│
|
|
109
|
+
└─► get_text_formats() → When encoding matters
|
|
110
|
+
|
|
111
|
+
↓
|
|
112
|
+
search() → Execute queries
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Typical workflow:**
|
|
116
|
+
1. `describe_corpus()` - Get structure overview
|
|
117
|
+
2. `list_features()` or `list_features(node_types=["word"])` - Browse available features
|
|
118
|
+
3. `describe_feature("sp")` - Deep dive into specific feature with sample values
|
|
119
|
+
4. `search_syntax_guide()` - Get search syntax help (section-based)
|
|
120
|
+
5. `search()` - Execute search queries
|
|
121
|
+
|
|
122
|
+
### Search Return Types
|
|
123
|
+
|
|
124
|
+
| Type | Description |
|
|
125
|
+
|------|-------------|
|
|
126
|
+
| `results` | Paginated node info with cursor (default) |
|
|
127
|
+
| `count` | Total count only |
|
|
128
|
+
| `statistics` | Feature value distributions |
|
|
129
|
+
| `passages` | Formatted text passages |
|
|
130
|
+
|
|
131
|
+
## Client Configuration
|
|
132
|
+
|
|
133
|
+
### Claude Desktop (stdio)
|
|
134
|
+
|
|
135
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"context-fabric": {
|
|
141
|
+
"command": "/path/to/venv/bin/cfabric-mcp",
|
|
142
|
+
"args": ["--corpus", "/path/to/bhsa"]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Cursor (SSE)
|
|
149
|
+
|
|
150
|
+
Start server:
|
|
151
|
+
```bash
|
|
152
|
+
cfabric-mcp --corpus /path/to/bhsa --sse 8000
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Configure Cursor to connect to: `http://localhost:8000/sse`
|
|
156
|
+
|
|
157
|
+
### Custom MCP Clients
|
|
158
|
+
|
|
159
|
+
For SSE transport, connect to `http://host:port/sse`
|
|
160
|
+
For Streamable HTTP, connect to `http://host:port/mcp`
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Context-Fabric MCP Server
|
|
2
|
+
|
|
3
|
+
Query Context-Fabric corpora via MCP (Model Context Protocol).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cfabric-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Transports
|
|
12
|
+
|
|
13
|
+
The server supports three transport modes:
|
|
14
|
+
|
|
15
|
+
| Transport | Flag | Use Case |
|
|
16
|
+
|-----------|------|----------|
|
|
17
|
+
| stdio | (default) | Claude Desktop, local MCP clients |
|
|
18
|
+
| SSE | `--sse PORT` | Cursor, remote MCP clients |
|
|
19
|
+
| Streamable HTTP | `--http PORT` | Production deployments |
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# stdio transport (Claude Desktop)
|
|
25
|
+
cfabric-mcp --corpus /path/to/bhsa
|
|
26
|
+
|
|
27
|
+
# SSE transport (Cursor, remote clients)
|
|
28
|
+
cfabric-mcp --corpus /path/to/bhsa --sse 8000
|
|
29
|
+
|
|
30
|
+
# Streamable HTTP transport (production)
|
|
31
|
+
cfabric-mcp --corpus /path/to/bhsa --http 8000
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
--corpus PATH # Corpus to load (required, repeatable)
|
|
38
|
+
--corpus name=PATH # Named corpus
|
|
39
|
+
--sse PORT # SSE transport on port (endpoint: /sse)
|
|
40
|
+
--http PORT # Streamable HTTP transport on port (endpoint: /mcp)
|
|
41
|
+
--host HOST # Host to bind (default: 0.0.0.0)
|
|
42
|
+
--features "sp lex" # Load only specific features
|
|
43
|
+
--verbose # Debug logging
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
The server exposes 10 MCP tools organized in layers for efficient discovery.
|
|
49
|
+
|
|
50
|
+
> **Note:** A usage guide is automatically provided to clients via the MCP `instructions` field at connection time.
|
|
51
|
+
|
|
52
|
+
### Discovery Tools
|
|
53
|
+
|
|
54
|
+
| Tool | Description |
|
|
55
|
+
|------|-------------|
|
|
56
|
+
| `list_corpora` | List loaded corpora |
|
|
57
|
+
| `describe_corpus` | Get corpus structure (node types, sections) |
|
|
58
|
+
| `list_features` | Browse features with optional node_type filter |
|
|
59
|
+
| `describe_feature` | Get feature details with sample values |
|
|
60
|
+
| `get_text_formats` | Get text encoding samples (cached) |
|
|
61
|
+
|
|
62
|
+
### Search Tools
|
|
63
|
+
|
|
64
|
+
| Tool | Description |
|
|
65
|
+
|------|-------------|
|
|
66
|
+
| `search` | Pattern search (results/count/statistics/passages) |
|
|
67
|
+
| `search_continue` | Paginate search results |
|
|
68
|
+
| `search_syntax_guide` | Search syntax docs (section-based) |
|
|
69
|
+
|
|
70
|
+
### Data Access Tools
|
|
71
|
+
|
|
72
|
+
| Tool | Description |
|
|
73
|
+
|------|-------------|
|
|
74
|
+
| `get_passages` | Get text by section references |
|
|
75
|
+
| `get_node_features` | Get feature values for nodes |
|
|
76
|
+
|
|
77
|
+
## Discovery Flow
|
|
78
|
+
|
|
79
|
+
The tools are designed for hierarchical, iterative discovery:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
describe_corpus()
|
|
83
|
+
│
|
|
84
|
+
├─► list_features() → Browse all features
|
|
85
|
+
│ └─► describe_feature("sp") → Full details + samples
|
|
86
|
+
│
|
|
87
|
+
├─► list_features(node_types=["word"]) → Filter by node type
|
|
88
|
+
│ └─► describe_feature(...)
|
|
89
|
+
│
|
|
90
|
+
└─► get_text_formats() → When encoding matters
|
|
91
|
+
|
|
92
|
+
↓
|
|
93
|
+
search() → Execute queries
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Typical workflow:**
|
|
97
|
+
1. `describe_corpus()` - Get structure overview
|
|
98
|
+
2. `list_features()` or `list_features(node_types=["word"])` - Browse available features
|
|
99
|
+
3. `describe_feature("sp")` - Deep dive into specific feature with sample values
|
|
100
|
+
4. `search_syntax_guide()` - Get search syntax help (section-based)
|
|
101
|
+
5. `search()` - Execute search queries
|
|
102
|
+
|
|
103
|
+
### Search Return Types
|
|
104
|
+
|
|
105
|
+
| Type | Description |
|
|
106
|
+
|------|-------------|
|
|
107
|
+
| `results` | Paginated node info with cursor (default) |
|
|
108
|
+
| `count` | Total count only |
|
|
109
|
+
| `statistics` | Feature value distributions |
|
|
110
|
+
| `passages` | Formatted text passages |
|
|
111
|
+
|
|
112
|
+
## Client Configuration
|
|
113
|
+
|
|
114
|
+
### Claude Desktop (stdio)
|
|
115
|
+
|
|
116
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"mcpServers": {
|
|
121
|
+
"context-fabric": {
|
|
122
|
+
"command": "/path/to/venv/bin/cfabric-mcp",
|
|
123
|
+
"args": ["--corpus", "/path/to/bhsa"]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Cursor (SSE)
|
|
130
|
+
|
|
131
|
+
Start server:
|
|
132
|
+
```bash
|
|
133
|
+
cfabric-mcp --corpus /path/to/bhsa --sse 8000
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Configure Cursor to connect to: `http://localhost:8000/sse`
|
|
137
|
+
|
|
138
|
+
### Custom MCP Clients
|
|
139
|
+
|
|
140
|
+
For SSE transport, connect to `http://host:port/sse`
|
|
141
|
+
For Streamable HTTP, connect to `http://host:port/mcp`
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Context-Fabric MCP Server.
|
|
2
|
+
|
|
3
|
+
This package provides an MCP (Model Context Protocol) server that exposes
|
|
4
|
+
Context-Fabric corpus operations to AI agents.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
# Run as CLI
|
|
8
|
+
cfabric-mcp
|
|
9
|
+
|
|
10
|
+
# Or import and run programmatically
|
|
11
|
+
from cfabric_mcp import mcp
|
|
12
|
+
mcp.run(transport="stdio")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
# Configure logging for MCP server
|
|
19
|
+
# Use stderr since stdout is reserved for MCP protocol messages
|
|
20
|
+
_handler = logging.StreamHandler(sys.stderr)
|
|
21
|
+
_handler.setFormatter(
|
|
22
|
+
logging.Formatter(
|
|
23
|
+
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
24
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Create package logger
|
|
29
|
+
logger = logging.getLogger("cfabric_mcp")
|
|
30
|
+
logger.addHandler(_handler)
|
|
31
|
+
logger.setLevel(logging.INFO)
|
|
32
|
+
|
|
33
|
+
from cfabric_mcp.server import mcp, main
|
|
34
|
+
from cfabric_mcp.corpus_manager import corpus_manager, CorpusManager
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = ["mcp", "main", "corpus_manager", "CorpusManager", "logger", "__version__"]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Search result caching for Context-Fabric MCP server.
|
|
2
|
+
|
|
3
|
+
Caches search results by (corpus, template) to avoid re-running expensive queries.
|
|
4
|
+
All return_types (results, count, statistics, passages) can draw from the same cache.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("cfabric_mcp.cache")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CachedSearchResult:
|
|
21
|
+
"""A cached search result.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
cursor_id: UUID for pagination/lookup
|
|
25
|
+
corpus: The corpus name
|
|
26
|
+
template: The search template
|
|
27
|
+
results: Raw search results (list of node ID tuples)
|
|
28
|
+
created_at: Unix timestamp when cached
|
|
29
|
+
expires_at: Unix timestamp when cache entry expires
|
|
30
|
+
last_accessed: Unix timestamp of last access (for LRU)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
cursor_id: str
|
|
34
|
+
corpus: str
|
|
35
|
+
template: str
|
|
36
|
+
results: list[tuple[int, ...]]
|
|
37
|
+
created_at: float
|
|
38
|
+
expires_at: float
|
|
39
|
+
last_accessed: float = field(default_factory=time.time)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_expired(self) -> bool:
|
|
43
|
+
"""Check if this cache entry has expired."""
|
|
44
|
+
return time.time() > self.expires_at
|
|
45
|
+
|
|
46
|
+
def touch(self) -> None:
|
|
47
|
+
"""Update last accessed time."""
|
|
48
|
+
self.last_accessed = time.time()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SearchCache:
|
|
52
|
+
"""In-memory cache for search results.
|
|
53
|
+
|
|
54
|
+
Caches by (corpus, template) key. Different return_types and parameters
|
|
55
|
+
can reuse the same cached results.
|
|
56
|
+
|
|
57
|
+
Thread-safe for concurrent access.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
default_ttl: int = 300, # 5 minutes
|
|
63
|
+
max_entries: int = 100,
|
|
64
|
+
max_results_per_entry: int = 10000,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize the search cache.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
default_ttl: Default time-to-live in seconds
|
|
70
|
+
max_entries: Maximum number of cached searches
|
|
71
|
+
max_results_per_entry: Maximum results to cache per search
|
|
72
|
+
"""
|
|
73
|
+
self.default_ttl = default_ttl
|
|
74
|
+
self.max_entries = max_entries
|
|
75
|
+
self.max_results_per_entry = max_results_per_entry
|
|
76
|
+
|
|
77
|
+
# Cache storage: key -> CachedSearchResult
|
|
78
|
+
self._cache: dict[tuple[str, str], CachedSearchResult] = {}
|
|
79
|
+
|
|
80
|
+
# Also index by cursor_id for pagination
|
|
81
|
+
self._by_cursor: dict[str, CachedSearchResult] = {}
|
|
82
|
+
|
|
83
|
+
# Lock for thread safety
|
|
84
|
+
self._lock = threading.RLock()
|
|
85
|
+
|
|
86
|
+
def get_or_execute(
|
|
87
|
+
self,
|
|
88
|
+
corpus: str,
|
|
89
|
+
template: str,
|
|
90
|
+
search_fn: Callable[[], list[tuple[int, ...]]],
|
|
91
|
+
ttl: int | None = None,
|
|
92
|
+
) -> CachedSearchResult:
|
|
93
|
+
"""Get cached result or execute search and cache it.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
corpus: Corpus name
|
|
97
|
+
template: Search template
|
|
98
|
+
search_fn: Function to execute search if not cached
|
|
99
|
+
ttl: Optional TTL override
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
CachedSearchResult with the search results
|
|
103
|
+
"""
|
|
104
|
+
key = (corpus, template.strip())
|
|
105
|
+
ttl = ttl or self.default_ttl
|
|
106
|
+
|
|
107
|
+
with self._lock:
|
|
108
|
+
# Check cache
|
|
109
|
+
cached = self._cache.get(key)
|
|
110
|
+
if cached and not cached.is_expired:
|
|
111
|
+
logger.debug(
|
|
112
|
+
"Cache hit for template: %s...",
|
|
113
|
+
template[:50].replace("\n", " ")
|
|
114
|
+
)
|
|
115
|
+
cached.touch()
|
|
116
|
+
return cached
|
|
117
|
+
|
|
118
|
+
# Cache miss or expired - execute search
|
|
119
|
+
logger.debug(
|
|
120
|
+
"Cache miss for template: %s...",
|
|
121
|
+
template[:50].replace("\n", " ")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Run the search
|
|
125
|
+
results = search_fn()
|
|
126
|
+
|
|
127
|
+
# Sort by node-tuples for consistent ordering
|
|
128
|
+
results = sorted(results)
|
|
129
|
+
|
|
130
|
+
# Limit results to max_results_per_entry
|
|
131
|
+
if len(results) > self.max_results_per_entry:
|
|
132
|
+
logger.warning(
|
|
133
|
+
"Truncating cache entry from %d to %d results",
|
|
134
|
+
len(results),
|
|
135
|
+
self.max_results_per_entry,
|
|
136
|
+
)
|
|
137
|
+
results = results[: self.max_results_per_entry]
|
|
138
|
+
|
|
139
|
+
# Create cache entry
|
|
140
|
+
now = time.time()
|
|
141
|
+
cursor_id = str(uuid.uuid4())
|
|
142
|
+
entry = CachedSearchResult(
|
|
143
|
+
cursor_id=cursor_id,
|
|
144
|
+
corpus=corpus,
|
|
145
|
+
template=template,
|
|
146
|
+
results=results,
|
|
147
|
+
created_at=now,
|
|
148
|
+
expires_at=now + ttl,
|
|
149
|
+
last_accessed=now,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Evict old entries if needed
|
|
153
|
+
self._evict_if_needed()
|
|
154
|
+
|
|
155
|
+
# Store in cache
|
|
156
|
+
self._cache[key] = entry
|
|
157
|
+
self._by_cursor[cursor_id] = entry
|
|
158
|
+
|
|
159
|
+
return entry
|
|
160
|
+
|
|
161
|
+
def get_by_cursor(self, cursor_id: str) -> CachedSearchResult | None:
|
|
162
|
+
"""Get cached result by cursor ID.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
cursor_id: The cursor ID from a previous search
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
CachedSearchResult if found and not expired, None otherwise
|
|
169
|
+
"""
|
|
170
|
+
with self._lock:
|
|
171
|
+
entry = self._by_cursor.get(cursor_id)
|
|
172
|
+
if entry and not entry.is_expired:
|
|
173
|
+
entry.touch()
|
|
174
|
+
return entry
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def get_page(
|
|
178
|
+
self,
|
|
179
|
+
cursor_id: str,
|
|
180
|
+
offset: int,
|
|
181
|
+
limit: int,
|
|
182
|
+
) -> tuple[list[tuple[int, ...]], bool, int] | None:
|
|
183
|
+
"""Get a page of results from a cached search.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
cursor_id: The cursor ID
|
|
187
|
+
offset: Number of results to skip
|
|
188
|
+
limit: Maximum results to return
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Tuple of (results, has_more, total_count) or None if not found
|
|
192
|
+
"""
|
|
193
|
+
entry = self.get_by_cursor(cursor_id)
|
|
194
|
+
if not entry:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
total = len(entry.results)
|
|
198
|
+
page = entry.results[offset : offset + limit]
|
|
199
|
+
has_more = offset + limit < total
|
|
200
|
+
|
|
201
|
+
return page, has_more, total
|
|
202
|
+
|
|
203
|
+
def _evict_if_needed(self) -> None:
|
|
204
|
+
"""Evict entries if cache is at capacity.
|
|
205
|
+
|
|
206
|
+
Uses LRU eviction strategy.
|
|
207
|
+
"""
|
|
208
|
+
# First, remove expired entries
|
|
209
|
+
self.cleanup_expired()
|
|
210
|
+
|
|
211
|
+
# If still at capacity, evict LRU entries
|
|
212
|
+
while len(self._cache) >= self.max_entries:
|
|
213
|
+
# Find least recently used entry
|
|
214
|
+
lru_key = None
|
|
215
|
+
lru_time = float("inf")
|
|
216
|
+
|
|
217
|
+
for key, entry in self._cache.items():
|
|
218
|
+
if entry.last_accessed < lru_time:
|
|
219
|
+
lru_time = entry.last_accessed
|
|
220
|
+
lru_key = key
|
|
221
|
+
|
|
222
|
+
if lru_key:
|
|
223
|
+
entry = self._cache.pop(lru_key)
|
|
224
|
+
self._by_cursor.pop(entry.cursor_id, None)
|
|
225
|
+
logger.debug("Evicted LRU cache entry: %s", lru_key[1][:30])
|
|
226
|
+
|
|
227
|
+
def cleanup_expired(self) -> int:
|
|
228
|
+
"""Remove all expired cache entries.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Number of entries removed
|
|
232
|
+
"""
|
|
233
|
+
with self._lock:
|
|
234
|
+
expired_keys = [
|
|
235
|
+
key for key, entry in self._cache.items() if entry.is_expired
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
for key in expired_keys:
|
|
239
|
+
entry = self._cache.pop(key)
|
|
240
|
+
self._by_cursor.pop(entry.cursor_id, None)
|
|
241
|
+
|
|
242
|
+
if expired_keys:
|
|
243
|
+
logger.debug("Cleaned up %d expired cache entries", len(expired_keys))
|
|
244
|
+
|
|
245
|
+
return len(expired_keys)
|
|
246
|
+
|
|
247
|
+
def clear(self) -> None:
|
|
248
|
+
"""Clear all cache entries."""
|
|
249
|
+
with self._lock:
|
|
250
|
+
self._cache.clear()
|
|
251
|
+
self._by_cursor.clear()
|
|
252
|
+
logger.debug("Cache cleared")
|
|
253
|
+
|
|
254
|
+
def stats(self) -> dict[str, Any]:
|
|
255
|
+
"""Get cache statistics.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Dictionary with cache statistics
|
|
259
|
+
"""
|
|
260
|
+
with self._lock:
|
|
261
|
+
now = time.time()
|
|
262
|
+
entries = list(self._cache.values())
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"total_entries": len(entries),
|
|
266
|
+
"max_entries": self.max_entries,
|
|
267
|
+
"expired_entries": sum(1 for e in entries if e.is_expired),
|
|
268
|
+
"total_results_cached": sum(len(e.results) for e in entries),
|
|
269
|
+
"oldest_entry_age": (
|
|
270
|
+
max(now - e.created_at for e in entries) if entries else 0
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Global cache instance
|
|
276
|
+
_cache: SearchCache | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_cache() -> SearchCache:
|
|
280
|
+
"""Get the global search cache instance.
|
|
281
|
+
|
|
282
|
+
Creates a new instance if none exists.
|
|
283
|
+
"""
|
|
284
|
+
global _cache
|
|
285
|
+
if _cache is None:
|
|
286
|
+
_cache = SearchCache()
|
|
287
|
+
return _cache
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def reset_cache() -> None:
|
|
291
|
+
"""Reset the global cache instance.
|
|
292
|
+
|
|
293
|
+
Useful for testing.
|
|
294
|
+
"""
|
|
295
|
+
global _cache
|
|
296
|
+
if _cache:
|
|
297
|
+
_cache.clear()
|
|
298
|
+
_cache = None
|