cyrrus 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.
- cyrrus-0.1.0/PKG-INFO +236 -0
- cyrrus-0.1.0/README.md +223 -0
- cyrrus-0.1.0/cyrrus/__init__.py +27 -0
- cyrrus-0.1.0/cyrrus/compression.py +91 -0
- cyrrus-0.1.0/cyrrus/config_validation.py +138 -0
- cyrrus-0.1.0/cyrrus/data.py +15 -0
- cyrrus-0.1.0/cyrrus/embedding_router.py +206 -0
- cyrrus-0.1.0/cyrrus/extractor.py +94 -0
- cyrrus-0.1.0/cyrrus/knapsack.py +20 -0
- cyrrus-0.1.0/cyrrus/memory.py +253 -0
- cyrrus-0.1.0/cyrrus/negation.py +14 -0
- cyrrus-0.1.0/cyrrus/projector.py +399 -0
- cyrrus-0.1.0/cyrrus/providers.py +126 -0
- cyrrus-0.1.0/cyrrus/router.py +62 -0
- cyrrus-0.1.0/cyrrus/threshold_calibration.py +91 -0
- cyrrus-0.1.0/cyrrus/tray.py +58 -0
- cyrrus-0.1.0/cyrrus.egg-info/PKG-INFO +236 -0
- cyrrus-0.1.0/cyrrus.egg-info/SOURCES.txt +29 -0
- cyrrus-0.1.0/cyrrus.egg-info/dependency_links.txt +1 -0
- cyrrus-0.1.0/cyrrus.egg-info/requires.txt +4 -0
- cyrrus-0.1.0/cyrrus.egg-info/top_level.txt +1 -0
- cyrrus-0.1.0/pyproject.toml +31 -0
- cyrrus-0.1.0/setup.cfg +4 -0
- cyrrus-0.1.0/tests/test_adversarial.py +508 -0
- cyrrus-0.1.0/tests/test_compression.py +132 -0
- cyrrus-0.1.0/tests/test_config_validation.py +141 -0
- cyrrus-0.1.0/tests/test_edge_cases_simple.py +498 -0
- cyrrus-0.1.0/tests/test_embedding_router.py +184 -0
- cyrrus-0.1.0/tests/test_failsafe.py +471 -0
- cyrrus-0.1.0/tests/test_threshold_calibration.py +106 -0
- cyrrus-0.1.0/tests/test_v1.py +574 -0
cyrrus-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cyrrus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Context routing for LLM calls. Decides what goes into the prompt so you don't have to send everything every time.
|
|
5
|
+
License: AGPL-3.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/kikusuka/cyrrus
|
|
7
|
+
Project-URL: Issues, https://github.com/kikusuka/cyrrus/issues
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: embeddings
|
|
11
|
+
Requires-Dist: fastembed; extra == "embeddings"
|
|
12
|
+
Requires-Dist: numpy; extra == "embeddings"
|
|
13
|
+
|
|
14
|
+
# cyrrus
|
|
15
|
+
|
|
16
|
+
**Context routing for LLM calls.**
|
|
17
|
+
|
|
18
|
+
Instead of sending a full static system prompt every time, cyrrus decides what context is relevant *right now* and sends only that.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from cyrrus import Projector
|
|
22
|
+
from cyrrus.providers import ollama
|
|
23
|
+
|
|
24
|
+
bot = Projector.minimal(llm_call=ollama("llama3.2"))
|
|
25
|
+
reply = bot.ask("hello")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That's it. No config required to start.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
Every time a user sends a message, cyrrus:
|
|
35
|
+
|
|
36
|
+
1. **Routes** — matches the message against your slides to find what's relevant
|
|
37
|
+
2. **Remembers** — pulls stored facts about this user that relate to this message
|
|
38
|
+
3. **Packs** — fits the relevant context into your token budget, highest priority first
|
|
39
|
+
4. **Sends** — builds a proper messages array and calls your LLM
|
|
40
|
+
5. **Learns** — extracts new facts from the conversation and stores them for later
|
|
41
|
+
|
|
42
|
+
The LLM receives clean, relevant context. Not a wall of everything you've ever defined.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Numbers
|
|
47
|
+
|
|
48
|
+
- **51.6% token reduction** on average across mixed conversations (real tiktoken counts)
|
|
49
|
+
- **0.018 MB** core install — zero required dependencies
|
|
50
|
+
- **121.9 MB / 241.7 MB RAM** for the optional embeddings block
|
|
51
|
+
- **500 concurrent users** with zero errors
|
|
52
|
+
- Surviving every adversarial test thrown at it
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install cyrrus # core, zero dependencies
|
|
60
|
+
pip install cyrrus[embeddings] # + semantic routing and memory
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The import is always:
|
|
64
|
+
```python
|
|
65
|
+
from cyrrus import Projector
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Three ways to use it
|
|
71
|
+
|
|
72
|
+
### Zero config
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from cyrrus import Projector
|
|
76
|
+
from cyrrus.providers import ollama
|
|
77
|
+
|
|
78
|
+
bot = Projector.minimal(llm_call=ollama("llama3.2"))
|
|
79
|
+
reply = bot.ask("write me a python function")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### With context routing
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
config = {
|
|
86
|
+
"core_lamp": {
|
|
87
|
+
"content": "You are a helpful assistant.",
|
|
88
|
+
},
|
|
89
|
+
"code_lens": {
|
|
90
|
+
"content": "Output only clean code blocks, no filler.",
|
|
91
|
+
"triggers": ["code", "script", "python", "function", "bug"],
|
|
92
|
+
},
|
|
93
|
+
"casual_lens": {
|
|
94
|
+
"content": "Keep it short and natural.",
|
|
95
|
+
"triggers": ["hey", "hi", "hello"],
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
bot = Projector(config, llm_call=ollama("llama3.2"))
|
|
100
|
+
reply = await bot.process("write a sorting function", session_id=str(user_id))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`tokens` and `priority` are optional — cyrrus fills them in automatically.
|
|
104
|
+
|
|
105
|
+
### Individual components
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from cyrrus import MemoryVault, IntentRouter, SlideTray
|
|
109
|
+
|
|
110
|
+
# Use any component standalone
|
|
111
|
+
memory = MemoryVault()
|
|
112
|
+
facts = await memory.retrieve("user_123", "what is my name", limit=3)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Providers
|
|
118
|
+
|
|
119
|
+
Ready-made wrappers for common APIs. Pass them directly as `llm_call`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from cyrrus.providers import ollama, openai, anthropic, groq
|
|
123
|
+
|
|
124
|
+
# Local Ollama — free, no API key
|
|
125
|
+
bot = Projector(config, llm_call=ollama("llama3.2"))
|
|
126
|
+
|
|
127
|
+
# OpenAI
|
|
128
|
+
bot = Projector(config, llm_call=openai("gpt-4o-mini", api_key="sk-..."))
|
|
129
|
+
|
|
130
|
+
# Anthropic
|
|
131
|
+
bot = Projector(config, llm_call=anthropic("claude-haiku-4-5", api_key="sk-..."))
|
|
132
|
+
|
|
133
|
+
# Groq — fast, generous free tier
|
|
134
|
+
bot = Projector(config, llm_call=groq("llama-3.1-8b-instant", api_key="..."))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Or bring your own — any async function that takes a messages list and returns a string:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
async def my_llm(messages: list) -> str:
|
|
141
|
+
response = await client.chat.completions.create(
|
|
142
|
+
model="my-model",
|
|
143
|
+
messages=messages,
|
|
144
|
+
)
|
|
145
|
+
return response.choices[0].message.content
|
|
146
|
+
|
|
147
|
+
bot = Projector(config, llm_call=my_llm)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Memory
|
|
153
|
+
|
|
154
|
+
cyrrus remembers things users tell it. No setup needed — the default extractor runs automatically:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
User: "my name is Muratha" → stored: user_name = Muratha
|
|
158
|
+
User: "I'm building cyrrus" → stored: user_project = cyrrus
|
|
159
|
+
User: "I prefer Ollama" → stored: user_preference = Ollama
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
On the next message, relevant facts are retrieved and injected into the system prompt automatically.
|
|
163
|
+
|
|
164
|
+
**With `cyrrus[embeddings]`:** retrieval uses semantic similarity, so "what am I working on" finds `user_project: cyrrus` even without shared keywords.
|
|
165
|
+
|
|
166
|
+
**Without:** keyword overlap matching. Fast, zero dependencies, catches most cases.
|
|
167
|
+
|
|
168
|
+
Memory is per `session_id`. Facts never leak between users.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Conversation history
|
|
173
|
+
|
|
174
|
+
cyrrus maintains a sliding window of conversation history per session. The LLM always sees recent context, so follow-up questions work:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
User: "what's the capital of France?"
|
|
178
|
+
Bot: "Paris."
|
|
179
|
+
User: "what's the population there?" ← LLM knows "there" means Paris
|
|
180
|
+
Bot: "Around 2.1 million in the city proper."
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## What cyrrus is not
|
|
186
|
+
|
|
187
|
+
- **Not an agent framework.** It controls input, never output.
|
|
188
|
+
- **Not a vector database.** Memory is SQLite with semantic retrieval optional.
|
|
189
|
+
- **Not a replacement for LangChain.** It's a layer, not a framework.
|
|
190
|
+
- **Not magic.** Keyword routing misses natural phrasing. Install `cyrrus[embeddings]` if you need paraphrase matching.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## session_id
|
|
195
|
+
|
|
196
|
+
This is important. Every user needs a unique `session_id`:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
# Discord bot
|
|
200
|
+
reply = await bot.process(message.content, session_id=str(message.author.id))
|
|
201
|
+
|
|
202
|
+
# Web app
|
|
203
|
+
reply = await bot.process(user_input, session_id=request.session["user_id"])
|
|
204
|
+
|
|
205
|
+
# Single-user script — use ask() and don't worry about it
|
|
206
|
+
reply = bot.ask("hello")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Without a unique session_id, users share memory. That's a bug, not a feature.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Tracing
|
|
214
|
+
|
|
215
|
+
Every call stores a trace:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
await bot.process("write a script", session_id="u1")
|
|
219
|
+
|
|
220
|
+
trace = bot.last_trace
|
|
221
|
+
print(trace["routed_slide_ids"]) # which slides matched
|
|
222
|
+
print(trace["memory_slide_ids"]) # which facts were retrieved
|
|
223
|
+
print(trace["dropped_slide_ids"]) # what didn't fit the budget
|
|
224
|
+
print(trace["messages"]) # exact messages sent to LLM
|
|
225
|
+
print(trace["stats"]["history_turns"]) # how many turns of history
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
AGPLv3. If you run cyrrus as part of a network service, you must make your modifications available. See LICENSE.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
cyrrus — formerly known as slid3s
|
cyrrus-0.1.0/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# cyrrus
|
|
2
|
+
|
|
3
|
+
**Context routing for LLM calls.**
|
|
4
|
+
|
|
5
|
+
Instead of sending a full static system prompt every time, cyrrus decides what context is relevant *right now* and sends only that.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from cyrrus import Projector
|
|
9
|
+
from cyrrus.providers import ollama
|
|
10
|
+
|
|
11
|
+
bot = Projector.minimal(llm_call=ollama("llama3.2"))
|
|
12
|
+
reply = bot.ask("hello")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's it. No config required to start.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
Every time a user sends a message, cyrrus:
|
|
22
|
+
|
|
23
|
+
1. **Routes** — matches the message against your slides to find what's relevant
|
|
24
|
+
2. **Remembers** — pulls stored facts about this user that relate to this message
|
|
25
|
+
3. **Packs** — fits the relevant context into your token budget, highest priority first
|
|
26
|
+
4. **Sends** — builds a proper messages array and calls your LLM
|
|
27
|
+
5. **Learns** — extracts new facts from the conversation and stores them for later
|
|
28
|
+
|
|
29
|
+
The LLM receives clean, relevant context. Not a wall of everything you've ever defined.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Numbers
|
|
34
|
+
|
|
35
|
+
- **51.6% token reduction** on average across mixed conversations (real tiktoken counts)
|
|
36
|
+
- **0.018 MB** core install — zero required dependencies
|
|
37
|
+
- **121.9 MB / 241.7 MB RAM** for the optional embeddings block
|
|
38
|
+
- **500 concurrent users** with zero errors
|
|
39
|
+
- Surviving every adversarial test thrown at it
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install cyrrus # core, zero dependencies
|
|
47
|
+
pip install cyrrus[embeddings] # + semantic routing and memory
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The import is always:
|
|
51
|
+
```python
|
|
52
|
+
from cyrrus import Projector
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Three ways to use it
|
|
58
|
+
|
|
59
|
+
### Zero config
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from cyrrus import Projector
|
|
63
|
+
from cyrrus.providers import ollama
|
|
64
|
+
|
|
65
|
+
bot = Projector.minimal(llm_call=ollama("llama3.2"))
|
|
66
|
+
reply = bot.ask("write me a python function")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### With context routing
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
config = {
|
|
73
|
+
"core_lamp": {
|
|
74
|
+
"content": "You are a helpful assistant.",
|
|
75
|
+
},
|
|
76
|
+
"code_lens": {
|
|
77
|
+
"content": "Output only clean code blocks, no filler.",
|
|
78
|
+
"triggers": ["code", "script", "python", "function", "bug"],
|
|
79
|
+
},
|
|
80
|
+
"casual_lens": {
|
|
81
|
+
"content": "Keep it short and natural.",
|
|
82
|
+
"triggers": ["hey", "hi", "hello"],
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bot = Projector(config, llm_call=ollama("llama3.2"))
|
|
87
|
+
reply = await bot.process("write a sorting function", session_id=str(user_id))
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`tokens` and `priority` are optional — cyrrus fills them in automatically.
|
|
91
|
+
|
|
92
|
+
### Individual components
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from cyrrus import MemoryVault, IntentRouter, SlideTray
|
|
96
|
+
|
|
97
|
+
# Use any component standalone
|
|
98
|
+
memory = MemoryVault()
|
|
99
|
+
facts = await memory.retrieve("user_123", "what is my name", limit=3)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Providers
|
|
105
|
+
|
|
106
|
+
Ready-made wrappers for common APIs. Pass them directly as `llm_call`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from cyrrus.providers import ollama, openai, anthropic, groq
|
|
110
|
+
|
|
111
|
+
# Local Ollama — free, no API key
|
|
112
|
+
bot = Projector(config, llm_call=ollama("llama3.2"))
|
|
113
|
+
|
|
114
|
+
# OpenAI
|
|
115
|
+
bot = Projector(config, llm_call=openai("gpt-4o-mini", api_key="sk-..."))
|
|
116
|
+
|
|
117
|
+
# Anthropic
|
|
118
|
+
bot = Projector(config, llm_call=anthropic("claude-haiku-4-5", api_key="sk-..."))
|
|
119
|
+
|
|
120
|
+
# Groq — fast, generous free tier
|
|
121
|
+
bot = Projector(config, llm_call=groq("llama-3.1-8b-instant", api_key="..."))
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or bring your own — any async function that takes a messages list and returns a string:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
async def my_llm(messages: list) -> str:
|
|
128
|
+
response = await client.chat.completions.create(
|
|
129
|
+
model="my-model",
|
|
130
|
+
messages=messages,
|
|
131
|
+
)
|
|
132
|
+
return response.choices[0].message.content
|
|
133
|
+
|
|
134
|
+
bot = Projector(config, llm_call=my_llm)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Memory
|
|
140
|
+
|
|
141
|
+
cyrrus remembers things users tell it. No setup needed — the default extractor runs automatically:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
User: "my name is Muratha" → stored: user_name = Muratha
|
|
145
|
+
User: "I'm building cyrrus" → stored: user_project = cyrrus
|
|
146
|
+
User: "I prefer Ollama" → stored: user_preference = Ollama
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On the next message, relevant facts are retrieved and injected into the system prompt automatically.
|
|
150
|
+
|
|
151
|
+
**With `cyrrus[embeddings]`:** retrieval uses semantic similarity, so "what am I working on" finds `user_project: cyrrus` even without shared keywords.
|
|
152
|
+
|
|
153
|
+
**Without:** keyword overlap matching. Fast, zero dependencies, catches most cases.
|
|
154
|
+
|
|
155
|
+
Memory is per `session_id`. Facts never leak between users.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Conversation history
|
|
160
|
+
|
|
161
|
+
cyrrus maintains a sliding window of conversation history per session. The LLM always sees recent context, so follow-up questions work:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
User: "what's the capital of France?"
|
|
165
|
+
Bot: "Paris."
|
|
166
|
+
User: "what's the population there?" ← LLM knows "there" means Paris
|
|
167
|
+
Bot: "Around 2.1 million in the city proper."
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## What cyrrus is not
|
|
173
|
+
|
|
174
|
+
- **Not an agent framework.** It controls input, never output.
|
|
175
|
+
- **Not a vector database.** Memory is SQLite with semantic retrieval optional.
|
|
176
|
+
- **Not a replacement for LangChain.** It's a layer, not a framework.
|
|
177
|
+
- **Not magic.** Keyword routing misses natural phrasing. Install `cyrrus[embeddings]` if you need paraphrase matching.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## session_id
|
|
182
|
+
|
|
183
|
+
This is important. Every user needs a unique `session_id`:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
# Discord bot
|
|
187
|
+
reply = await bot.process(message.content, session_id=str(message.author.id))
|
|
188
|
+
|
|
189
|
+
# Web app
|
|
190
|
+
reply = await bot.process(user_input, session_id=request.session["user_id"])
|
|
191
|
+
|
|
192
|
+
# Single-user script — use ask() and don't worry about it
|
|
193
|
+
reply = bot.ask("hello")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Without a unique session_id, users share memory. That's a bug, not a feature.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Tracing
|
|
201
|
+
|
|
202
|
+
Every call stores a trace:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
await bot.process("write a script", session_id="u1")
|
|
206
|
+
|
|
207
|
+
trace = bot.last_trace
|
|
208
|
+
print(trace["routed_slide_ids"]) # which slides matched
|
|
209
|
+
print(trace["memory_slide_ids"]) # which facts were retrieved
|
|
210
|
+
print(trace["dropped_slide_ids"]) # what didn't fit the budget
|
|
211
|
+
print(trace["messages"]) # exact messages sent to LLM
|
|
212
|
+
print(trace["stats"]["history_turns"]) # how many turns of history
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
AGPLv3. If you run cyrrus as part of a network service, you must make your modifications available. See LICENSE.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
cyrrus — formerly known as slid3s
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .projector import Projector
|
|
2
|
+
from .data import Slide
|
|
3
|
+
from .memory import MemoryVault
|
|
4
|
+
from .router import IntentRouter
|
|
5
|
+
from .tray import SlideTray
|
|
6
|
+
from .knapsack import TokenKnapsack
|
|
7
|
+
from .extractor import extract_facts
|
|
8
|
+
from . import providers
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
def estimate_tokens(text: str) -> int:
|
|
13
|
+
"""Rough token count. Useful for setting 'tokens' in config manually."""
|
|
14
|
+
return max(1, len(text.split()))
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Projector",
|
|
18
|
+
"Slide",
|
|
19
|
+
"MemoryVault",
|
|
20
|
+
"IntentRouter",
|
|
21
|
+
"SlideTray",
|
|
22
|
+
"TokenKnapsack",
|
|
23
|
+
"extract_facts",
|
|
24
|
+
"providers",
|
|
25
|
+
"estimate_tokens",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extractive tool-result compression: split a tool result into
|
|
3
|
+
sentences, keep only the ones most relevant to the user's message,
|
|
4
|
+
drop the rest. Unlike summarize_tool_output (which calls the LLM and
|
|
5
|
+
costs an extra API call on your own key), this uses the same local
|
|
6
|
+
embedding model as EmbeddingRouter - zero extra API cost.
|
|
7
|
+
|
|
8
|
+
Testable in two layers, on purpose:
|
|
9
|
+
- The SELECTION algorithm (which sentences get kept, in what order)
|
|
10
|
+
is tested with a fake, injected embedder - no network needed, see
|
|
11
|
+
tests/test_compression.py.
|
|
12
|
+
- The actual semantic quality of what gets selected needs a real
|
|
13
|
+
model - verify in Colab, same as EmbeddingRouter.
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("cyrrus.compression")
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
|
21
|
+
|
|
22
|
+
_SENTENCE_SPLIT_RE = re.compile(r'(?<=[.!?])\s+')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def split_sentences(text: str) -> list:
|
|
26
|
+
"""Simple regex-based sentence splitting. Known limitation: will
|
|
27
|
+
mis-split on abbreviations (e.g. "Dr. Smith") - acceptable for
|
|
28
|
+
tool-result text (search snippets, DB rows), not recommended for
|
|
29
|
+
literary prose."""
|
|
30
|
+
if not text or not text.strip():
|
|
31
|
+
return []
|
|
32
|
+
sentences = _SENTENCE_SPLIT_RE.split(text.strip())
|
|
33
|
+
return [s.strip() for s in sentences if s.strip()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExtractiveCompressor:
|
|
37
|
+
def __init__(self, embedder: object = None, model_name: str = DEFAULT_MODEL):
|
|
38
|
+
"""embedder: anything with .embed(list[str]) -> list[vector],
|
|
39
|
+
matching fastembed's TextEmbedding interface. Pass a fake one
|
|
40
|
+
in tests to check the selection logic without network. If
|
|
41
|
+
None, tries to load the real local model; falls back to
|
|
42
|
+
returning text unmodified (compression becomes a no-op, not a
|
|
43
|
+
crash) if that fails."""
|
|
44
|
+
self.embedder = embedder
|
|
45
|
+
self._np = None
|
|
46
|
+
if embedder is None:
|
|
47
|
+
self._try_load_default_embedder(model_name)
|
|
48
|
+
else:
|
|
49
|
+
try:
|
|
50
|
+
import numpy as np
|
|
51
|
+
self._np = np
|
|
52
|
+
except ImportError:
|
|
53
|
+
log.warning("numpy not available - compression will no-op even with a custom embedder.")
|
|
54
|
+
|
|
55
|
+
def _try_load_default_embedder(self, model_name: str):
|
|
56
|
+
try:
|
|
57
|
+
from fastembed import TextEmbedding
|
|
58
|
+
import numpy as np
|
|
59
|
+
self.embedder = TextEmbedding(model_name=model_name)
|
|
60
|
+
self._np = np
|
|
61
|
+
except ImportError:
|
|
62
|
+
log.warning("fastembed not installed - tool-result compression will no-op "
|
|
63
|
+
"(results pass through unmodified). Install with: pip install fastembed")
|
|
64
|
+
except Exception as e:
|
|
65
|
+
log.warning("Embedding model failed to load (%s) - compression will no-op.", e)
|
|
66
|
+
|
|
67
|
+
def compress(self, text: str, query: str, max_sentences: int = 3) -> str:
|
|
68
|
+
if self.embedder is None or self._np is None:
|
|
69
|
+
return text # fail-safe: no-op, never crash the caller over this
|
|
70
|
+
|
|
71
|
+
sentences = split_sentences(text)
|
|
72
|
+
if len(sentences) <= max_sentences:
|
|
73
|
+
return text # nothing to trim
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
sentence_embeddings = self._np.array(list(self.embedder.embed(sentences)))
|
|
77
|
+
query_embedding = self._np.array(list(self.embedder.embed([query]))[0])
|
|
78
|
+
|
|
79
|
+
norms = self._np.linalg.norm(sentence_embeddings, axis=1) * self._np.linalg.norm(query_embedding)
|
|
80
|
+
norms[norms == 0] = 1e-9
|
|
81
|
+
scores = sentence_embeddings.dot(query_embedding) / norms
|
|
82
|
+
|
|
83
|
+
top_indices = sorted(
|
|
84
|
+
range(len(scores)), key=lambda i: scores[i], reverse=True
|
|
85
|
+
)[:max_sentences]
|
|
86
|
+
top_indices.sort() # restore original reading order, don't shuffle the sentences
|
|
87
|
+
|
|
88
|
+
return " ".join(sentences[i] for i in top_indices)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
log.warning("Compression failed (%s) - returning original text uncompressed.", e)
|
|
91
|
+
return text
|