powerailabs-contextkit 0.1.0__tar.gz → 0.2.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.
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/PKG-INFO +1 -1
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/pyproject.toml +1 -1
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/src/powerailabs/contextkit/__init__.py +54 -8
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/tests/test_contextkit.py +42 -0
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/.gitignore +0 -0
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/README.md +0 -0
- {powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/src/powerailabs/contextkit/py.typed +0 -0
{powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/src/powerailabs/contextkit/__init__.py
RENAMED
|
@@ -65,10 +65,11 @@ class AssemblyReport:
|
|
|
65
65
|
reserved_output: int
|
|
66
66
|
model: str
|
|
67
67
|
decisions: list[BlockDecision] = field(default_factory=list)
|
|
68
|
+
order: str = "default"
|
|
68
69
|
|
|
69
70
|
def __str__(self) -> str:
|
|
70
71
|
lines = [
|
|
71
|
-
f"AssemblyReport(model={self.model}) "
|
|
72
|
+
f"AssemblyReport(model={self.model}, order={self.order}) "
|
|
72
73
|
f"budget={self.budget} reserved_output={self.reserved_output} "
|
|
73
74
|
f"used={self.used}/{self.budget - self.reserved_output}",
|
|
74
75
|
]
|
|
@@ -79,12 +80,22 @@ class AssemblyReport:
|
|
|
79
80
|
return "\n".join(lines)
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
#
|
|
83
|
+
# Default render order: system first, conversational/context middle, the user turn last.
|
|
83
84
|
_ROLE_RANK = {"system": 0, "tool": 1, "assistant": 2, "user": 3}
|
|
85
|
+
_ORDERS = ("default", "attention", "cache")
|
|
84
86
|
|
|
85
87
|
|
|
86
88
|
class Context:
|
|
87
|
-
"""A token-budgeted, declarative context assembler. See docs/contextkit.md §3, §5.
|
|
89
|
+
"""A token-budgeted, declarative context assembler. See docs/contextkit.md §3, §5.
|
|
90
|
+
|
|
91
|
+
``order`` controls how kept blocks are arranged in the final messages (docs/contextkit.md §2):
|
|
92
|
+
|
|
93
|
+
- ``"default"`` — role-grouped: system → context/history → the user turn.
|
|
94
|
+
- ``"attention"`` — "lost-in-the-middle": highest-priority context blocks ride the edges
|
|
95
|
+
(just after system / just before the user turn), weakest in the dead center.
|
|
96
|
+
- ``"cache"`` — stable prefix first (pinned, high-priority blocks lead) to maximize provider
|
|
97
|
+
prompt-cache / KV-cache hits across calls.
|
|
98
|
+
"""
|
|
88
99
|
|
|
89
100
|
def __init__(
|
|
90
101
|
self,
|
|
@@ -92,11 +103,15 @@ class Context:
|
|
|
92
103
|
model: str,
|
|
93
104
|
reserve_output: int = 0,
|
|
94
105
|
compressor: Any = None,
|
|
106
|
+
order: str = "default",
|
|
95
107
|
) -> None:
|
|
108
|
+
if order not in _ORDERS:
|
|
109
|
+
raise ValueError(f"order must be one of {_ORDERS}, got {order!r}")
|
|
96
110
|
self.budget_tokens = budget_tokens
|
|
97
111
|
self.model = model
|
|
98
112
|
self.reserve_output = reserve_output
|
|
99
113
|
self._compressor = compressor
|
|
114
|
+
self.order = order
|
|
100
115
|
self._blocks: list[Block] = []
|
|
101
116
|
self._report: AssemblyReport | None = None
|
|
102
117
|
self._messages: list[dict] = []
|
|
@@ -151,7 +166,7 @@ class Context:
|
|
|
151
166
|
|
|
152
167
|
used = 0
|
|
153
168
|
decisions: list[BlockDecision] = []
|
|
154
|
-
kept: list[tuple[int,
|
|
169
|
+
kept: list[tuple[int, Block, str]] = [] # (insertion_index, block, rendered_content)
|
|
155
170
|
|
|
156
171
|
for idx, block in order:
|
|
157
172
|
text = block.content if isinstance(block.content, str) else str(block.content)
|
|
@@ -160,7 +175,7 @@ class Context:
|
|
|
160
175
|
|
|
161
176
|
if before <= remaining:
|
|
162
177
|
used += before
|
|
163
|
-
kept.append((idx, block
|
|
178
|
+
kept.append((idx, block, text))
|
|
164
179
|
decisions.append(BlockDecision(block.role, "kept", before, before))
|
|
165
180
|
continue
|
|
166
181
|
|
|
@@ -176,17 +191,18 @@ class Context:
|
|
|
176
191
|
continue
|
|
177
192
|
after = tokens.count(new_text, self.model)
|
|
178
193
|
used += after
|
|
179
|
-
kept.append((idx, block
|
|
194
|
+
kept.append((idx, block, new_text))
|
|
180
195
|
decisions.append(BlockDecision(block.role, action, before, after, note))
|
|
181
196
|
|
|
182
|
-
|
|
183
|
-
messages = [{"role": role, "content": content} for _,
|
|
197
|
+
ordered = _order_blocks(kept, self.order)
|
|
198
|
+
messages = [{"role": block.role, "content": content} for _, block, content in ordered]
|
|
184
199
|
report = AssemblyReport(
|
|
185
200
|
budget=budget_tokens,
|
|
186
201
|
used=used,
|
|
187
202
|
reserved_output=self.reserve_output,
|
|
188
203
|
model=self.model,
|
|
189
204
|
decisions=decisions,
|
|
205
|
+
order=self.order,
|
|
190
206
|
)
|
|
191
207
|
if emit:
|
|
192
208
|
bus.emit(report)
|
|
@@ -243,6 +259,36 @@ class Context:
|
|
|
243
259
|
return None
|
|
244
260
|
|
|
245
261
|
|
|
262
|
+
def _order_blocks(kept: list[tuple[int, Block, str]], mode: str) -> list[tuple[int, Block, str]]:
|
|
263
|
+
"""Arrange kept blocks for rendering per the chosen strategy. Deterministic."""
|
|
264
|
+
if mode == "cache":
|
|
265
|
+
# Stable prefix: pinned, high-priority blocks lead so the prompt prefix is reused.
|
|
266
|
+
return sorted(kept, key=lambda k: (not k[1].pin, -k[1].priority, k[0]))
|
|
267
|
+
if mode == "attention":
|
|
268
|
+
systems = sorted(
|
|
269
|
+
(k for k in kept if k[1].role == "system"), key=lambda k: (-k[1].priority, k[0])
|
|
270
|
+
)
|
|
271
|
+
finals = sorted(
|
|
272
|
+
(k for k in kept if k[1].role == "user"), key=lambda k: (k[1].priority, k[0])
|
|
273
|
+
) # ascending -> highest-priority user turn ends up last (strongest end position)
|
|
274
|
+
middles = sorted(
|
|
275
|
+
(k for k in kept if k[1].role not in ("system", "user")),
|
|
276
|
+
key=lambda k: (-k[1].priority, k[0]),
|
|
277
|
+
)
|
|
278
|
+
return [*systems, *_edge_load(middles), *finals]
|
|
279
|
+
# default: role-grouped, insertion order within a role.
|
|
280
|
+
return sorted(kept, key=lambda k: (_ROLE_RANK.get(k[1].role, 1), k[0]))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _edge_load(items: list) -> list:
|
|
284
|
+
"""Edge-load a priority-descending list: highest at both edges, lowest in the center."""
|
|
285
|
+
left: list = []
|
|
286
|
+
right: list = []
|
|
287
|
+
for i, item in enumerate(items):
|
|
288
|
+
(left if i % 2 == 0 else right).append(item)
|
|
289
|
+
return left + right[::-1]
|
|
290
|
+
|
|
291
|
+
|
|
246
292
|
def _call_compressor(compressor: Any, text: str, target: int, model: str) -> str:
|
|
247
293
|
"""Call either a Compressor-protocol object or a ``squeeze.compress``-style callable."""
|
|
248
294
|
if hasattr(compressor, "compress"):
|
|
@@ -104,6 +104,48 @@ def test_report_emitted_on_bus():
|
|
|
104
104
|
assert len(seen) == 1 and seen[0].model == "gpt-4o"
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def test_attention_order_edge_loads_priority():
|
|
108
|
+
# Lost-in-the-middle: highest-priority context blocks ride the edges, weakest in the center.
|
|
109
|
+
ctx = Context(budget_tokens=1000, model="gpt-4o", order="attention")
|
|
110
|
+
ctx.add(Block("SYS", priority=100, pin=True, role="system"))
|
|
111
|
+
ctx.add(Block("p9", priority=9, role="assistant"))
|
|
112
|
+
ctx.add(Block("p1", priority=1, role="assistant"))
|
|
113
|
+
ctx.add(Block("p5", priority=5, role="assistant"))
|
|
114
|
+
ctx.add(Block("p7", priority=7, role="assistant"))
|
|
115
|
+
ctx.add(Block("USER", priority=10, pin=True, role="user"))
|
|
116
|
+
msgs = ctx.assemble()
|
|
117
|
+
assert msgs[0]["content"] == "SYS" # system anchored first
|
|
118
|
+
assert msgs[-1]["content"] == "USER" # user turn anchored last
|
|
119
|
+
middle = [m["content"] for m in msgs[1:-1]]
|
|
120
|
+
# desc by priority = [p9,p7,p5,p1] -> edge-loaded -> [p9,p5,p1,p7]: strongest on the edges
|
|
121
|
+
assert middle[0] == "p9" and middle[-1] == "p7"
|
|
122
|
+
assert middle[len(middle) // 2] == "p1" # weakest in the center
|
|
123
|
+
assert ctx.report().order == "attention"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_cache_order_puts_pinned_prefix_first():
|
|
127
|
+
ctx = Context(budget_tokens=1000, model="gpt-4o", order="cache")
|
|
128
|
+
ctx.add(Block("volatile", priority=8, pin=False, role="user"))
|
|
129
|
+
ctx.add(Block("stable-hi", priority=10, pin=True, role="system"))
|
|
130
|
+
ctx.add(Block("stable-lo", priority=2, pin=True, role="assistant"))
|
|
131
|
+
msgs = ctx.assemble()
|
|
132
|
+
# pinned blocks form the stable prefix (priority desc), volatile trails
|
|
133
|
+
assert [m["content"] for m in msgs] == ["stable-hi", "stable-lo", "volatile"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_invalid_order_rejected():
|
|
137
|
+
with pytest.raises(ValueError):
|
|
138
|
+
Context(budget_tokens=10, model="gpt-4o", order="bogus")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_default_order_unchanged():
|
|
142
|
+
ctx = Context(budget_tokens=1000, model="gpt-4o") # default
|
|
143
|
+
ctx.add(Block("u", priority=9, role="user"))
|
|
144
|
+
ctx.add(Block("s", priority=1, role="system"))
|
|
145
|
+
assert [m["role"] for m in ctx.assemble()] == ["system", "user"]
|
|
146
|
+
assert ctx.report().order == "default"
|
|
147
|
+
|
|
148
|
+
|
|
107
149
|
def test_for_anthropic_splits_system():
|
|
108
150
|
ctx = Context(budget_tokens=1000, model="claude-opus-4-8")
|
|
109
151
|
ctx.add(Block("you are helpful", priority=10, pin=True, role="system"))
|
|
File without changes
|
|
File without changes
|
{powerailabs_contextkit-0.1.0 → powerailabs_contextkit-0.2.0}/src/powerailabs/contextkit/py.typed
RENAMED
|
File without changes
|