arcus-cli 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.
- arcus/__init__.py +0 -0
- arcus/adapters/__init__.py +0 -0
- arcus/adapters/arc_adapter.py +68 -0
- arcus/cache/__init__.py +0 -0
- arcus/cache/benchmark.py +159 -0
- arcus/cache/semantic_cache.py +168 -0
- arcus/cli.py +335 -0
- arcus/config.py +46 -0
- arcus/embeddings.py +34 -0
- arcus/eval/__init__.py +0 -0
- arcus/eval/offline.py +219 -0
- arcus/eval/regret.py +96 -0
- arcus/quality/__init__.py +0 -0
- arcus/quality/gate.py +225 -0
- arcus/routing/__init__.py +0 -0
- arcus/routing/bandit.py +226 -0
- arcus/routing/context.py +218 -0
- arcus/routing/reward.py +91 -0
- arcus/routing/warm_start.py +35 -0
- arcus/storage/__init__.py +0 -0
- arcus/storage/db.py +117 -0
- arcus/storage/stats.py +51 -0
- arcus_cli-0.1.0.dist-info/METADATA +308 -0
- arcus_cli-0.1.0.dist-info/RECORD +27 -0
- arcus_cli-0.1.0.dist-info/WHEEL +4 -0
- arcus_cli-0.1.0.dist-info/entry_points.txt +2 -0
- arcus_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
arcus/routing/context.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskType(str, Enum):
|
|
7
|
+
CODE = "code"
|
|
8
|
+
REASONING_MATH = "reasoning_math"
|
|
9
|
+
WRITING = "writing"
|
|
10
|
+
LONG_DOCUMENT = "long_document"
|
|
11
|
+
GENERAL = "general"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LengthBucket(str, Enum):
|
|
15
|
+
SHORT = "short"
|
|
16
|
+
MEDIUM = "medium"
|
|
17
|
+
LONG = "long"
|
|
18
|
+
VERY_LONG = "very_long"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_CODE_MARKERS = re.compile(
|
|
22
|
+
r"```"
|
|
23
|
+
r"|traceback \(most recent call last\)"
|
|
24
|
+
r"|\b(syntax|type|name|index|key|attribute|value|indentation)error\b"
|
|
25
|
+
r"|\bexception in thread\b"
|
|
26
|
+
r"|\bnpm err!"
|
|
27
|
+
r"|^\s*(def|class|import|from|function|public|private|const|let)\s",
|
|
28
|
+
re.IGNORECASE | re.MULTILINE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_MATH_MARKERS = re.compile(
|
|
32
|
+
r"\b(solve|prove|derivative|integral|equation|theorem|factorize|simplify)\b"
|
|
33
|
+
r"|\btime complexity\b|\bbig[- ]o\b"
|
|
34
|
+
r"|solve for [a-z]\b",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_DOC_MARKERS = re.compile(
|
|
39
|
+
r"\bsummariz(e|ing)\b"
|
|
40
|
+
r"|\btl;?dr\b"
|
|
41
|
+
r"|\bthe following (document|article|text|paper)\b"
|
|
42
|
+
r"|\bgiven the (text|document) below\b",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_WRITING_MARKERS = re.compile(
|
|
47
|
+
r"\b(write|draft|compose)\s+(a|an|me a)\b"
|
|
48
|
+
r"|\b(essay|blog post|cover letter|short story|poem)\b",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# anchor prompts for the embedding fallback below. GENERAL gets its own
|
|
53
|
+
# examples too, not just the other four, otherwise the fallback would
|
|
54
|
+
# always pick one of code/math/writing/doc even for plain small talk
|
|
55
|
+
# since there'd be nothing else in the running.
|
|
56
|
+
_SIMILARITY_THRESHOLD = 0.35
|
|
57
|
+
|
|
58
|
+
_TASK_TYPE_EXAMPLES: dict[TaskType, list[str]] = {
|
|
59
|
+
TaskType.CODE: [
|
|
60
|
+
"why is my for loop not terminating",
|
|
61
|
+
"getting a null pointer exception in this function",
|
|
62
|
+
"how do I fix this compiler error",
|
|
63
|
+
"review this pull request for bugs",
|
|
64
|
+
"my api call keeps returning a 500",
|
|
65
|
+
],
|
|
66
|
+
TaskType.REASONING_MATH: [
|
|
67
|
+
"walk me through the proof of this theorem",
|
|
68
|
+
"what's the expected value of this dice roll",
|
|
69
|
+
"help me figure out the recurrence relation for this algorithm",
|
|
70
|
+
"is this argument logically valid",
|
|
71
|
+
"explain why this inequality holds",
|
|
72
|
+
],
|
|
73
|
+
TaskType.WRITING: [
|
|
74
|
+
"help me phrase this paragraph better",
|
|
75
|
+
"can you make this sound more persuasive",
|
|
76
|
+
"give me a catchy title for this post",
|
|
77
|
+
"rewrite this in a more formal tone",
|
|
78
|
+
"I need a few opening lines for a speech",
|
|
79
|
+
],
|
|
80
|
+
TaskType.LONG_DOCUMENT: [
|
|
81
|
+
"here's a long report, pull out the key takeaways",
|
|
82
|
+
"go through this contract and flag anything unusual",
|
|
83
|
+
"condense this research paper into a few bullet points",
|
|
84
|
+
"what are the main arguments made across these pages",
|
|
85
|
+
],
|
|
86
|
+
TaskType.GENERAL: [
|
|
87
|
+
"what's a good movie to watch tonight",
|
|
88
|
+
"how's the weather looking this weekend",
|
|
89
|
+
"tell me something interesting",
|
|
90
|
+
"what time zone is Tokyo in",
|
|
91
|
+
"hey, how are you",
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_embedding_classifier_cache = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_embedding_classifier():
|
|
99
|
+
# unlike the db engine, this one is worth caching as a real
|
|
100
|
+
# singleton. loading the model is the expensive part (a few hundred
|
|
101
|
+
# ms to a couple seconds), so paying it once per process is the
|
|
102
|
+
# whole point. the model itself comes from arcus.embeddings, shared
|
|
103
|
+
# with the semantic cache, so a request that needs both this fallback
|
|
104
|
+
# and a cache lookup only pays that load cost once, not twice.
|
|
105
|
+
global _embedding_classifier_cache
|
|
106
|
+
if _embedding_classifier_cache is not None:
|
|
107
|
+
return _embedding_classifier_cache
|
|
108
|
+
|
|
109
|
+
from arcus.embeddings import embed
|
|
110
|
+
|
|
111
|
+
labels: list[TaskType] = []
|
|
112
|
+
examples: list[str] = []
|
|
113
|
+
for task_type, prompts in _TASK_TYPE_EXAMPLES.items():
|
|
114
|
+
for prompt in prompts:
|
|
115
|
+
labels.append(task_type)
|
|
116
|
+
examples.append(prompt)
|
|
117
|
+
|
|
118
|
+
anchor_embeddings = embed(examples)
|
|
119
|
+
|
|
120
|
+
_embedding_classifier_cache = (labels, anchor_embeddings)
|
|
121
|
+
return _embedding_classifier_cache
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _classify_by_embedding(text: str) -> TaskType:
|
|
125
|
+
try:
|
|
126
|
+
labels, anchor_embeddings = _get_embedding_classifier()
|
|
127
|
+
from arcus.embeddings import embed
|
|
128
|
+
|
|
129
|
+
query_embedding = embed([text])[0]
|
|
130
|
+
except Exception:
|
|
131
|
+
# no internet on first run, weights not cached yet, whatever the
|
|
132
|
+
# reason, the regex rules already did their best. don't let a
|
|
133
|
+
# missing model take classification down entirely.
|
|
134
|
+
return TaskType.GENERAL
|
|
135
|
+
|
|
136
|
+
similarities = anchor_embeddings @ query_embedding
|
|
137
|
+
best_idx = int(similarities.argmax())
|
|
138
|
+
|
|
139
|
+
if similarities[best_idx] < _SIMILARITY_THRESHOLD:
|
|
140
|
+
return TaskType.GENERAL
|
|
141
|
+
|
|
142
|
+
return labels[best_idx]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _looks_like_code(text: str) -> bool:
|
|
146
|
+
return bool(_CODE_MARKERS.search(text))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _looks_like_math(text: str) -> bool:
|
|
150
|
+
return bool(_MATH_MARKERS.search(text))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _looks_like_document_task(text: str) -> bool:
|
|
154
|
+
return bool(_DOC_MARKERS.search(text))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _looks_like_writing_request(text: str) -> bool:
|
|
158
|
+
return bool(_WRITING_MARKERS.search(text))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classify_task_type(text: str) -> TaskType:
|
|
162
|
+
# order matters here. code and error output are the most confident
|
|
163
|
+
# signal we ever get (and piping errors in is the single most common
|
|
164
|
+
# thing this tool will see), so that gets checked first no matter
|
|
165
|
+
# what else is in the prompt.
|
|
166
|
+
if _looks_like_code(text):
|
|
167
|
+
return TaskType.CODE
|
|
168
|
+
if _looks_like_math(text):
|
|
169
|
+
return TaskType.REASONING_MATH
|
|
170
|
+
if _looks_like_document_task(text):
|
|
171
|
+
return TaskType.LONG_DOCUMENT
|
|
172
|
+
if _looks_like_writing_request(text):
|
|
173
|
+
return TaskType.WRITING
|
|
174
|
+
|
|
175
|
+
# nothing hit a keyword. rather than giving up and calling it
|
|
176
|
+
# GENERAL, check how close it reads to labeled examples of each
|
|
177
|
+
# category, catches phrasing the regex rules just don't cover.
|
|
178
|
+
result = _classify_by_embedding(text)
|
|
179
|
+
|
|
180
|
+
# a long wall of text with no other signal is still a document task
|
|
181
|
+
# even if the embedding read came back wishy-washy about it, raw
|
|
182
|
+
# length beats a low-confidence embedding guess here.
|
|
183
|
+
if result == TaskType.GENERAL and bucket_length(text) in (LengthBucket.LONG, LengthBucket.VERY_LONG):
|
|
184
|
+
return TaskType.LONG_DOCUMENT
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def bucket_length(text: str) -> LengthBucket:
|
|
190
|
+
# rough chars-per-token estimate (~4), not a real tokenizer. none of
|
|
191
|
+
# ARC's four models use OpenAI's tokenizer anyway so an exact count
|
|
192
|
+
# would just be fake precision. these thresholds are a first guess,
|
|
193
|
+
# worth retuning once we have real query lengths to look at.
|
|
194
|
+
approx_tokens = len(text) // 4
|
|
195
|
+
|
|
196
|
+
if approx_tokens < 100:
|
|
197
|
+
return LengthBucket.SHORT
|
|
198
|
+
if approx_tokens < 500:
|
|
199
|
+
return LengthBucket.MEDIUM
|
|
200
|
+
if approx_tokens < 2000:
|
|
201
|
+
return LengthBucket.LONG
|
|
202
|
+
return LengthBucket.VERY_LONG
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class Context:
|
|
207
|
+
task_type: TaskType
|
|
208
|
+
length_bucket: LengthBucket
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def key(self) -> str:
|
|
212
|
+
# this is the dict key the per-context bandits key off of, one
|
|
213
|
+
# bandit instance per bucket.
|
|
214
|
+
return f"{self.task_type.value}:{self.length_bucket.value}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def classify(text: str) -> Context:
|
|
218
|
+
return Context(task_type=classify_task_type(text), length_bucket=bucket_length(text))
|
arcus/routing/reward.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
# published commercial hosting rates for these open-weight models, one
|
|
4
|
+
# primary source per model, as of August 2026. this is a SIMULATED cost
|
|
5
|
+
# signal, ARC itself is free to the user, nobody's actually being
|
|
6
|
+
# billed these numbers. rates are dollars per 1M tokens.
|
|
7
|
+
#
|
|
8
|
+
# gpt-oss-120b: Groq
|
|
9
|
+
# GLM-5.3: Zhipu/Z.ai official pricing
|
|
10
|
+
# Kimi-K3: Moonshot AI official pricing (cache-miss rate, ignoring the
|
|
11
|
+
# cache-hit discount, more precision than a simulated signal needs)
|
|
12
|
+
# DeepSeek-V4-Flash: DeepSeek official pricing (off-peak rate, ignoring
|
|
13
|
+
# the peak-hours surcharge for the same reason)
|
|
14
|
+
MODEL_HOSTING_RATES: dict[str, tuple[float, float]] = {
|
|
15
|
+
"gpt-oss-120b": (0.15, 0.60),
|
|
16
|
+
"GLM-5.3": (1.40, 4.40),
|
|
17
|
+
"Kimi-K3": (3.00, 15.00),
|
|
18
|
+
"DeepSeek-V4-Flash": (0.22, 0.66),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _blended_rate(model: str) -> float:
|
|
23
|
+
input_rate, output_rate = MODEL_HOSTING_RATES[model]
|
|
24
|
+
return (input_rate + output_rate) / 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# computed from the table above rather than hardcoded, so the raw rates
|
|
28
|
+
# stay the actual source of truth. cheapest model scores 1.0, priciest
|
|
29
|
+
# scores 0.0, everything else falls in between.
|
|
30
|
+
def _compute_cost_scores() -> dict[str, float]:
|
|
31
|
+
blended = {model: _blended_rate(model) for model in MODEL_HOSTING_RATES}
|
|
32
|
+
cheapest = min(blended.values())
|
|
33
|
+
priciest = max(blended.values())
|
|
34
|
+
spread = priciest - cheapest
|
|
35
|
+
|
|
36
|
+
return {model: 1 - (rate - cheapest) / spread for model, rate in blended.items()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
COST_SCORES = _compute_cost_scores()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_latency(latency_ms: float, ceiling_ms: float = 20_000) -> float:
|
|
43
|
+
# past the ceiling, slower is just uniformly bad, no reason to keep
|
|
44
|
+
# penalizing harder. 20s is a reasonable worst-case wait for an
|
|
45
|
+
# interactive CLI tool.
|
|
46
|
+
return max(0.0, min(1.0, 1 - latency_ms / ceiling_ms))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def basic_quality_score(finish_reason: str | None, content: str | None) -> float:
|
|
50
|
+
"""A minimal quality signal: finished cleanly (finish_reason == "stop")
|
|
51
|
+
and non-empty content. The real request pipeline uses the fuller
|
|
52
|
+
checks in quality/gate.py instead.
|
|
53
|
+
"""
|
|
54
|
+
if finish_reason != "stop":
|
|
55
|
+
return 0.0
|
|
56
|
+
if not content or not content.strip():
|
|
57
|
+
return 0.0
|
|
58
|
+
return 1.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class RewardWeights:
|
|
63
|
+
quality: float
|
|
64
|
+
latency: float
|
|
65
|
+
cost: float
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# quality weighted highest: a fast, cheap, wrong answer is worth about
|
|
69
|
+
# nothing. latency next: it's the most immediately felt cost on shared,
|
|
70
|
+
# sometimes-contended infra, where quality can degrade during peak load.
|
|
71
|
+
# cost lowest: ARC is free to the user, this dimension exists to
|
|
72
|
+
# demonstrate routing that accounts for cost as an engineering practice,
|
|
73
|
+
# not because real budget pressure exists here.
|
|
74
|
+
DEFAULT_WEIGHTS = RewardWeights(quality=0.5, latency=0.3, cost=0.2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_reward(
|
|
78
|
+
latency_ms: float,
|
|
79
|
+
model: str,
|
|
80
|
+
quality_score: float,
|
|
81
|
+
weights: RewardWeights = DEFAULT_WEIGHTS,
|
|
82
|
+
) -> float:
|
|
83
|
+
weight_sum = weights.quality + weights.latency + weights.cost
|
|
84
|
+
if abs(weight_sum - 1.0) > 1e-6:
|
|
85
|
+
raise ValueError(f"reward weights must sum to 1.0, got {weight_sum}")
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
weights.quality * quality_score
|
|
89
|
+
+ weights.latency * normalize_latency(latency_ms)
|
|
90
|
+
+ weights.cost * COST_SCORES[model]
|
|
91
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from sqlmodel import Session, select
|
|
2
|
+
|
|
3
|
+
from arcus.routing.bandit import ContextualBandit
|
|
4
|
+
from arcus.storage.db import RequestLog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def replay_history(bandit: ContextualBandit, engine, mode: str) -> None:
|
|
8
|
+
"""Rebuilds a bandit's learned state from past requests in the log.
|
|
9
|
+
|
|
10
|
+
Every `arcus` invocation is a fresh process, there's no daemon keeping
|
|
11
|
+
the bandit alive in memory between runs. Without this, each run would
|
|
12
|
+
start from a blank slate and the router would never actually learn
|
|
13
|
+
anything. It works because update() on all four algorithms is just an
|
|
14
|
+
associative accumulation of pull counts and reward sums, so replaying
|
|
15
|
+
the log in chronological order and feeding each row back through
|
|
16
|
+
update() lands on the same state as if the process had been running
|
|
17
|
+
continuously the whole time.
|
|
18
|
+
"""
|
|
19
|
+
with Session(engine) as session:
|
|
20
|
+
rows = session.exec(
|
|
21
|
+
select(RequestLog)
|
|
22
|
+
.where(RequestLog.mode == mode)
|
|
23
|
+
.where(RequestLog.reward.is_not(None))
|
|
24
|
+
.order_by(RequestLog.created_at)
|
|
25
|
+
).all()
|
|
26
|
+
|
|
27
|
+
for row in rows:
|
|
28
|
+
if row.model not in bandit.arms:
|
|
29
|
+
# a model that was live when this row was logged but has
|
|
30
|
+
# since been retired or renamed by ARC. no arm to credit the
|
|
31
|
+
# reward to anymore, skip it rather than crash the whole
|
|
32
|
+
# replay over one stale row.
|
|
33
|
+
continue
|
|
34
|
+
context_key = f"{row.task_type}:{row.length_bucket}"
|
|
35
|
+
bandit.update(context_key, row.model, row.reward)
|
|
File without changes
|
arcus/storage/db.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_data_dir
|
|
6
|
+
from sqlmodel import Field, Session, SQLModel, create_engine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestLog(SQLModel, table=True):
|
|
10
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
11
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
12
|
+
|
|
13
|
+
prompt: str
|
|
14
|
+
task_type: str
|
|
15
|
+
length_bucket: str
|
|
16
|
+
model: str
|
|
17
|
+
|
|
18
|
+
# nullable since a cache hit skips the bandit and quality gate
|
|
19
|
+
# entirely, but propensity in particular has to be a column from day
|
|
20
|
+
# one: there's no way to go back and add propensity logging to rows
|
|
21
|
+
# that already happened.
|
|
22
|
+
propensity: float | None = Field(default=None)
|
|
23
|
+
latency_ms: float | None = Field(default=None)
|
|
24
|
+
finish_reason: str | None = Field(default=None)
|
|
25
|
+
error: str | None = Field(default=None)
|
|
26
|
+
|
|
27
|
+
# "bandit" or "random", whichever ContextualBandit actually drove
|
|
28
|
+
# this request. mode plus model is the whole point of the A/B stats
|
|
29
|
+
# comparison.
|
|
30
|
+
mode: str | None = Field(default=None)
|
|
31
|
+
# the actual compute_reward() output at request time, stored rather
|
|
32
|
+
# than recomputed later. if reward.py's weights or cost table change
|
|
33
|
+
# down the line, historical stats should still reflect what the
|
|
34
|
+
# bandit was actually updated with, not whatever the weights happen
|
|
35
|
+
# to be when someone runs `arcus stats`.
|
|
36
|
+
reward: float | None = Field(default=None)
|
|
37
|
+
|
|
38
|
+
# was this response served from the semantic cache instead of an
|
|
39
|
+
# actual ARC call. needed for `arcus stats` to report a cache hit
|
|
40
|
+
# rate, the current schema has no other way to tell a cache hit apart
|
|
41
|
+
# from a normal request.
|
|
42
|
+
cache_hit: bool = Field(default=False)
|
|
43
|
+
# did this specific attempt pass the quality gate. combined with
|
|
44
|
+
# `model`, this is what makes a per-model catch rate possible instead
|
|
45
|
+
# of only ever seeing which model eventually succeeded.
|
|
46
|
+
quality_passed: bool = Field(default=True)
|
|
47
|
+
|
|
48
|
+
# groups every turn of one `arcus chat` session together. null for
|
|
49
|
+
# one-shot `arcus "..."` calls, there's no session to group those into.
|
|
50
|
+
conversation_id: str | None = Field(default=None, index=True)
|
|
51
|
+
# position of this turn within its conversation, 0-indexed. null
|
|
52
|
+
# alongside conversation_id for one-shot calls.
|
|
53
|
+
turn_index: int | None = Field(default=None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _database_url() -> str:
|
|
57
|
+
override = os.environ.get("ARCUS_DATABASE_URL")
|
|
58
|
+
if override:
|
|
59
|
+
return override
|
|
60
|
+
|
|
61
|
+
data_dir = Path(user_data_dir("arcus"))
|
|
62
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
return f"sqlite:///{data_dir / 'arcus.db'}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_engine():
|
|
67
|
+
# deliberately not cached as a module-level singleton. sqlite engine
|
|
68
|
+
# creation is cheap, and skipping the singleton means there's no
|
|
69
|
+
# stale global state to worry about resetting between tests (or
|
|
70
|
+
# between one CLI run and the next, if ARCUS_DATABASE_URL changes).
|
|
71
|
+
engine = create_engine(_database_url())
|
|
72
|
+
SQLModel.metadata.create_all(engine)
|
|
73
|
+
return engine
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def log_request(
|
|
77
|
+
prompt: str,
|
|
78
|
+
task_type: str,
|
|
79
|
+
length_bucket: str,
|
|
80
|
+
model: str,
|
|
81
|
+
propensity: float | None = None,
|
|
82
|
+
latency_ms: float | None = None,
|
|
83
|
+
finish_reason: str | None = None,
|
|
84
|
+
error: str | None = None,
|
|
85
|
+
mode: str | None = None,
|
|
86
|
+
reward: float | None = None,
|
|
87
|
+
cache_hit: bool = False,
|
|
88
|
+
quality_passed: bool = True,
|
|
89
|
+
conversation_id: str | None = None,
|
|
90
|
+
turn_index: int | None = None,
|
|
91
|
+
engine=None,
|
|
92
|
+
) -> RequestLog:
|
|
93
|
+
engine = engine or get_engine()
|
|
94
|
+
|
|
95
|
+
entry = RequestLog(
|
|
96
|
+
prompt=prompt,
|
|
97
|
+
task_type=task_type,
|
|
98
|
+
length_bucket=length_bucket,
|
|
99
|
+
model=model,
|
|
100
|
+
propensity=propensity,
|
|
101
|
+
latency_ms=latency_ms,
|
|
102
|
+
finish_reason=finish_reason,
|
|
103
|
+
error=error,
|
|
104
|
+
mode=mode,
|
|
105
|
+
reward=reward,
|
|
106
|
+
cache_hit=cache_hit,
|
|
107
|
+
quality_passed=quality_passed,
|
|
108
|
+
conversation_id=conversation_id,
|
|
109
|
+
turn_index=turn_index,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
with Session(engine) as session:
|
|
113
|
+
session.add(entry)
|
|
114
|
+
session.commit()
|
|
115
|
+
session.refresh(entry)
|
|
116
|
+
|
|
117
|
+
return entry
|
arcus/storage/stats.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from statistics import mean
|
|
3
|
+
|
|
4
|
+
from sqlmodel import Session, select
|
|
5
|
+
|
|
6
|
+
from arcus.routing.reward import COST_SCORES
|
|
7
|
+
from arcus.storage.db import RequestLog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ArmModeSummary:
|
|
12
|
+
model: str
|
|
13
|
+
mode: str
|
|
14
|
+
request_count: int
|
|
15
|
+
avg_reward: float | None
|
|
16
|
+
avg_latency_ms: float | None
|
|
17
|
+
cost_score: float | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def aggregate_by_arm_and_mode(engine) -> list[ArmModeSummary]:
|
|
21
|
+
"""Groups logged requests by (model, mode) and averages reward/latency
|
|
22
|
+
per group. Done in plain Python rather than a SQL GROUP BY/AVG, this
|
|
23
|
+
is a personal local CLI's log, realistically thousands of rows at
|
|
24
|
+
most, not a scale where hand-rolled SQL aggregation earns its
|
|
25
|
+
complexity over just reading the rows.
|
|
26
|
+
"""
|
|
27
|
+
with Session(engine) as session:
|
|
28
|
+
rows = session.exec(select(RequestLog)).all()
|
|
29
|
+
|
|
30
|
+
groups: dict[tuple[str, str], list[RequestLog]] = {}
|
|
31
|
+
for row in rows:
|
|
32
|
+
key = (row.model, row.mode or "unknown")
|
|
33
|
+
groups.setdefault(key, []).append(row)
|
|
34
|
+
|
|
35
|
+
summaries = []
|
|
36
|
+
for (model, mode), entries in groups.items():
|
|
37
|
+
rewards = [e.reward for e in entries if e.reward is not None]
|
|
38
|
+
latencies = [e.latency_ms for e in entries if e.latency_ms is not None]
|
|
39
|
+
|
|
40
|
+
summaries.append(
|
|
41
|
+
ArmModeSummary(
|
|
42
|
+
model=model,
|
|
43
|
+
mode=mode,
|
|
44
|
+
request_count=len(entries),
|
|
45
|
+
avg_reward=mean(rewards) if rewards else None,
|
|
46
|
+
avg_latency_ms=mean(latencies) if latencies else None,
|
|
47
|
+
cost_score=COST_SCORES.get(model),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return sorted(summaries, key=lambda s: (s.model, s.mode))
|