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/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
from openai.types.chat import ChatCompletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArcModel(str, Enum):
|
|
9
|
+
"""The four models ARC currently exposes."""
|
|
10
|
+
|
|
11
|
+
GPT_OSS_120B = "gpt-oss-120b"
|
|
12
|
+
GLM_5_3 = "GLM-5.3"
|
|
13
|
+
KIMI_K3 = "Kimi-K3"
|
|
14
|
+
DEEPSEEK_V4_FLASH = "DeepSeek-V4-Flash"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ArcAdapter:
|
|
18
|
+
"""Thin wrapper around the openai SDK pointed at ARC's endpoint.
|
|
19
|
+
|
|
20
|
+
ARC's API is OpenAI-compatible, so we don't need a custom HTTP client,
|
|
21
|
+
just the right base_url and a key. Everything else (routing, caching,
|
|
22
|
+
the quality gate) should go through this instead of importing openai
|
|
23
|
+
directly, so ARC's endpoint details only live in one place.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
BASE_URL = "https://llm-api.arc.vt.edu/api/v1"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
max_retries: int = 2,
|
|
33
|
+
) -> None:
|
|
34
|
+
api_key = api_key or os.environ.get("ARC_API_KEY")
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"no ARC API key found. Pass api_key=<your-key> "
|
|
38
|
+
"Get one from llm.arc.vt.edu under "
|
|
39
|
+
"User profile > Settings > Account > API keys."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# timeout + max_retries gives us the "be a good citizen on shared
|
|
43
|
+
# infra" behavior the spec asks for, no need to hand-roll a rate
|
|
44
|
+
# limiter on top of what the SDK already does.
|
|
45
|
+
self._client = OpenAI(
|
|
46
|
+
base_url=self.BASE_URL,
|
|
47
|
+
api_key=api_key,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
max_retries=max_retries,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def chat(
|
|
53
|
+
self,
|
|
54
|
+
model: ArcModel | str,
|
|
55
|
+
messages: list[dict],
|
|
56
|
+
**kwargs,
|
|
57
|
+
) -> ChatCompletion:
|
|
58
|
+
"""Send a chat completion request to ARC for the given model.
|
|
59
|
+
|
|
60
|
+
Returns the raw ChatCompletion, not just the text, because callers
|
|
61
|
+
further down the pipeline (the quality gate especially) need
|
|
62
|
+
finish_reason and usage info, not just the message content.
|
|
63
|
+
"""
|
|
64
|
+
return self._client.chat.completions.create(
|
|
65
|
+
model=model.value if isinstance(model, ArcModel) else model,
|
|
66
|
+
messages=messages,
|
|
67
|
+
**kwargs,
|
|
68
|
+
)
|
arcus/cache/__init__.py
ADDED
|
File without changes
|
arcus/cache/benchmark.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from sqlmodel import SQLModel, create_engine
|
|
4
|
+
|
|
5
|
+
from arcus.cache.semantic_cache import lookup, store
|
|
6
|
+
|
|
7
|
+
# true paraphrases of stable/conceptual questions, should hit the cache
|
|
8
|
+
_PARAPHRASE_PAIRS = [
|
|
9
|
+
("how does binary search work", "explain the binary search algorithm"),
|
|
10
|
+
("what is a hash table", "can you explain what a hash map is"),
|
|
11
|
+
("explain the difference between TCP and UDP", "what's the difference between TCP and UDP protocols"),
|
|
12
|
+
("what is recursion in programming", "can you explain recursion"),
|
|
13
|
+
("how does photosynthesis work", "explain the process of photosynthesis"),
|
|
14
|
+
("what caused World War 1", "what were the causes of the First World War"),
|
|
15
|
+
("explain the pythagorean theorem", "what is the pythagorean theorem"),
|
|
16
|
+
("how do neural networks learn", "explain how neural networks are trained"),
|
|
17
|
+
("what is object oriented programming", "can you explain OOP"),
|
|
18
|
+
("how does a car engine work", "explain how an internal combustion engine works"),
|
|
19
|
+
("what is the theory of relativity", "explain einstein's theory of relativity"),
|
|
20
|
+
("how do vaccines work", "explain how vaccines protect against disease"),
|
|
21
|
+
("what is dependency injection", "explain the dependency injection pattern"),
|
|
22
|
+
("how does DNS work", "explain how domain name resolution works"),
|
|
23
|
+
("what is a black hole", "explain what a black hole is"),
|
|
24
|
+
("how does compound interest work", "explain compound interest"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# near-duplicate phrasing, different project/assignment number, should NOT hit
|
|
28
|
+
_PROJECT_NUMBER_PAIRS = [
|
|
29
|
+
(f"when is project {n} due for CS 3214", f"when is project {n + 1} due for CS 3214") for n in range(1, 9)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# near-duplicate phrasing, different software version, should NOT hit
|
|
33
|
+
_VERSION_PAIRS = [
|
|
34
|
+
("what's new in python 3.10", "what's new in python 3.11"),
|
|
35
|
+
("what's new in python 3.11", "what's new in python 3.12"),
|
|
36
|
+
("what's new in node 18", "what's new in node 20"),
|
|
37
|
+
("what's new in java 11", "what's new in java 17"),
|
|
38
|
+
("what's new in react 17", "what's new in react 18"),
|
|
39
|
+
("what's new in ubuntu 22.04", "what's new in ubuntu 24.04"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# near-duplicate phrasing, different year, should NOT hit
|
|
43
|
+
_YEAR_PAIRS = [
|
|
44
|
+
(f"what major tech events happened in {y}", f"what major tech events happened in {y + 1}")
|
|
45
|
+
for y in range(2018, 2026)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# near-duplicate phrasing, different named entity, should NOT hit
|
|
49
|
+
_CITY_PAIRS = [
|
|
50
|
+
("what's the population of Paris", "what's the population of Berlin"),
|
|
51
|
+
("what's the population of Tokyo", "what's the population of Seoul"),
|
|
52
|
+
("what's the population of London", "what's the population of Madrid"),
|
|
53
|
+
("what's the population of Toronto", "what's the population of Vancouver"),
|
|
54
|
+
("what's the population of Cairo", "what's the population of Nairobi"),
|
|
55
|
+
("what's the population of Boston", "what's the population of Chicago"),
|
|
56
|
+
]
|
|
57
|
+
_COMPANY_PAIRS = [
|
|
58
|
+
("who is the CEO of Google", "who is the CEO of Microsoft"),
|
|
59
|
+
("who is the CEO of Amazon", "who is the CEO of Apple"),
|
|
60
|
+
("who is the CEO of Tesla", "who is the CEO of Ford"),
|
|
61
|
+
("who is the CEO of Netflix", "who is the CEO of Disney"),
|
|
62
|
+
("who is the CEO of Spotify", "who is the CEO of Samsung"),
|
|
63
|
+
("who is the CEO of Sony", "who is the CEO of Nintendo"),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# near-duplicate phrasing, different quantity, should NOT hit
|
|
67
|
+
_QUANTITY_PAIRS = [
|
|
68
|
+
(f"how many calories are in {n} slices of pizza", f"how many calories are in {n + 1} slices of pizza")
|
|
69
|
+
for n in range(1, 7)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# clearly unrelated topics, easy negatives
|
|
73
|
+
_UNRELATED_PAIRS = [
|
|
74
|
+
("how does binary search work", "what's a good recipe for banana bread"),
|
|
75
|
+
("explain the pythagorean theorem", "who won the world cup in 2022"),
|
|
76
|
+
("how do vaccines work", "what's the best way to learn guitar"),
|
|
77
|
+
("what is a black hole", "how do I fix a flat tire"),
|
|
78
|
+
("how does DNS work", "what's the capital of Australia"),
|
|
79
|
+
("what is recursion in programming", "how do I make cold brew coffee"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
BENCHMARK_PAIRS: list[tuple[str, str, bool]] = (
|
|
83
|
+
[(a, b, True) for a, b in _PARAPHRASE_PAIRS]
|
|
84
|
+
+ [
|
|
85
|
+
(a, b, False)
|
|
86
|
+
for a, b in (
|
|
87
|
+
_PROJECT_NUMBER_PAIRS
|
|
88
|
+
+ _VERSION_PAIRS
|
|
89
|
+
+ _YEAR_PAIRS
|
|
90
|
+
+ _CITY_PAIRS
|
|
91
|
+
+ _COMPANY_PAIRS
|
|
92
|
+
+ _QUANTITY_PAIRS
|
|
93
|
+
+ _UNRELATED_PAIRS
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class BenchmarkStats:
|
|
101
|
+
tp: int
|
|
102
|
+
fp: int
|
|
103
|
+
fn: int
|
|
104
|
+
tn: int
|
|
105
|
+
precision: float
|
|
106
|
+
recall: float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fresh_engine():
|
|
110
|
+
engine = create_engine("sqlite://")
|
|
111
|
+
SQLModel.metadata.create_all(engine)
|
|
112
|
+
return engine
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def evaluate_pairs(
|
|
116
|
+
pairs: list[tuple[str, str, bool]],
|
|
117
|
+
use_param_diff: bool,
|
|
118
|
+
similarity_threshold: float = 0.80,
|
|
119
|
+
) -> BenchmarkStats:
|
|
120
|
+
tp = fp = fn = tn = 0
|
|
121
|
+
|
|
122
|
+
for query_a, query_b, should_match in pairs:
|
|
123
|
+
# fresh engine per pair, no cross-pair contamination from earlier
|
|
124
|
+
# entries in the loop affecting a later pair's nearest match
|
|
125
|
+
engine = _fresh_engine()
|
|
126
|
+
store(query_a, response=f"answer to: {query_a}", model="gpt-oss-120b", engine=engine)
|
|
127
|
+
|
|
128
|
+
result = lookup(query_b, engine=engine, similarity_threshold=similarity_threshold, use_param_diff=use_param_diff)
|
|
129
|
+
predicted_match = result.hit
|
|
130
|
+
|
|
131
|
+
if predicted_match and should_match:
|
|
132
|
+
tp += 1
|
|
133
|
+
elif predicted_match and not should_match:
|
|
134
|
+
fp += 1
|
|
135
|
+
elif not predicted_match and should_match:
|
|
136
|
+
fn += 1
|
|
137
|
+
else:
|
|
138
|
+
tn += 1
|
|
139
|
+
|
|
140
|
+
precision = tp / (tp + fp) if (tp + fp) else 1.0
|
|
141
|
+
recall = tp / (tp + fn) if (tp + fn) else 1.0
|
|
142
|
+
|
|
143
|
+
return BenchmarkStats(tp=tp, fp=fp, fn=fn, tn=tn, precision=precision, recall=recall)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_benchmark(pairs: list[tuple[str, str, bool]] = BENCHMARK_PAIRS) -> dict[str, BenchmarkStats]:
|
|
147
|
+
return {
|
|
148
|
+
"naive_cosine": evaluate_pairs(pairs, use_param_diff=False),
|
|
149
|
+
"with_param_diff": evaluate_pairs(pairs, use_param_diff=True),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
results = run_benchmark()
|
|
155
|
+
print(f"benchmark set: {len(BENCHMARK_PAIRS)} pairs\n")
|
|
156
|
+
for name, stats in results.items():
|
|
157
|
+
print(f"{name}:")
|
|
158
|
+
print(f" tp={stats.tp} fp={stats.fp} fn={stats.fn} tn={stats.tn}")
|
|
159
|
+
print(f" precision={stats.precision:.3f} recall={stats.recall:.3f}\n")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sqlmodel import Field, Session, SQLModel, select
|
|
8
|
+
|
|
9
|
+
from arcus.embeddings import embed
|
|
10
|
+
from arcus.storage.db import get_engine
|
|
11
|
+
|
|
12
|
+
DEFAULT_SIMILARITY_THRESHOLD = 0.80
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Volatility(str, Enum):
|
|
16
|
+
VOLATILE = "volatile"
|
|
17
|
+
STABLE = "stable"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_VOLATILE_MARKERS = re.compile(
|
|
21
|
+
r"\btoday\b|\bcurrent(ly)?\b|\blatest\b|\bnow\b"
|
|
22
|
+
r"|\bthis (week|month|year)\b|\brecently\b|\bright now\b",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def classify_volatility(query: str) -> Volatility:
|
|
30
|
+
if _VOLATILE_MARKERS.search(query):
|
|
31
|
+
return Volatility.VOLATILE
|
|
32
|
+
return Volatility.STABLE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ttl_seconds_for(volatility: Volatility) -> int:
|
|
36
|
+
# VOLATILE gets a TTL of 0, so it expires the instant it's written
|
|
37
|
+
# and can never actually be served back. that's simpler than a
|
|
38
|
+
# separate "don't cache this" branch, the normal expiry check already
|
|
39
|
+
# handles it. 7 days for STABLE is a first guess, easy to retune once
|
|
40
|
+
# there's real usage data on how often people ask the same
|
|
41
|
+
# conceptual question again.
|
|
42
|
+
if volatility == Volatility.VOLATILE:
|
|
43
|
+
return 0
|
|
44
|
+
return _SEVEN_DAYS_SECONDS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_NUMBER_PATTERN = re.compile(r"\b\d[\d,]*\.?\d*\b")
|
|
48
|
+
# crude proper-noun/entity proxy: capitalized words not at the very start
|
|
49
|
+
# of the string, where every sentence's first word would otherwise cause
|
|
50
|
+
# false positives. not real NER, no NLP dependency for it, the benchmark
|
|
51
|
+
# in cache/benchmark.py is what honestly measures how well this actually
|
|
52
|
+
# performs rather than just asserting it does.
|
|
53
|
+
_ENTITY_PATTERN = re.compile(r"(?<!^)\b[A-Z][a-zA-Z]{2,}\b")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_params(text: str) -> set[str]:
|
|
57
|
+
numbers = set(_NUMBER_PATTERN.findall(text))
|
|
58
|
+
entities = set(_ENTITY_PATTERN.findall(text.strip()))
|
|
59
|
+
return numbers | entities
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def params_conflict(query_a: str, query_b: str) -> bool:
|
|
63
|
+
return extract_params(query_a) != extract_params(query_b)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CacheEntry(SQLModel, table=True):
|
|
67
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
68
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
69
|
+
|
|
70
|
+
query: str
|
|
71
|
+
response: str
|
|
72
|
+
# which ARC model actually produced this response. without this, a
|
|
73
|
+
# cache hit would have no real arm to attribute stats to, "the cache"
|
|
74
|
+
# isn't one of the four models catch/reward numbers get broken down
|
|
75
|
+
# by.
|
|
76
|
+
model: str
|
|
77
|
+
ttl_seconds: int
|
|
78
|
+
# raw float32 bytes, not JSON, cheaper to store and reload than a
|
|
79
|
+
# list of floats for a 384-dim vector.
|
|
80
|
+
embedding: bytes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _serialize_embedding(vector: np.ndarray) -> bytes:
|
|
84
|
+
return vector.astype(np.float32).tobytes()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _deserialize_embedding(blob: bytes) -> np.ndarray:
|
|
88
|
+
return np.frombuffer(blob, dtype=np.float32)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_expired(entry: CacheEntry) -> bool:
|
|
92
|
+
# SQLite has no native timezone-aware datetime type, so a round-trip
|
|
93
|
+
# through it comes back naive even though it was stored as UTC.
|
|
94
|
+
# reattach the tzinfo rather than comparing naive against aware and
|
|
95
|
+
# blowing up.
|
|
96
|
+
created_at = entry.created_at
|
|
97
|
+
if created_at.tzinfo is None:
|
|
98
|
+
created_at = created_at.replace(tzinfo=UTC)
|
|
99
|
+
|
|
100
|
+
expires_at = created_at + timedelta(seconds=entry.ttl_seconds)
|
|
101
|
+
return datetime.now(UTC) > expires_at
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def store(query: str, response: str, model: str, engine=None) -> CacheEntry:
|
|
105
|
+
engine = engine or get_engine()
|
|
106
|
+
|
|
107
|
+
vector = embed([query])[0]
|
|
108
|
+
ttl_seconds = ttl_seconds_for(classify_volatility(query))
|
|
109
|
+
|
|
110
|
+
entry = CacheEntry(
|
|
111
|
+
query=query,
|
|
112
|
+
response=response,
|
|
113
|
+
model=model,
|
|
114
|
+
ttl_seconds=ttl_seconds,
|
|
115
|
+
embedding=_serialize_embedding(vector),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
with Session(engine) as session:
|
|
119
|
+
session.add(entry)
|
|
120
|
+
session.commit()
|
|
121
|
+
session.refresh(entry)
|
|
122
|
+
|
|
123
|
+
return entry
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class CacheResult:
|
|
128
|
+
hit: bool
|
|
129
|
+
response: str | None
|
|
130
|
+
similarity: float | None
|
|
131
|
+
matched_query: str | None
|
|
132
|
+
model: str | None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def lookup(
|
|
136
|
+
query: str,
|
|
137
|
+
engine=None,
|
|
138
|
+
similarity_threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
|
|
139
|
+
use_param_diff: bool = True,
|
|
140
|
+
) -> CacheResult:
|
|
141
|
+
engine = engine or get_engine()
|
|
142
|
+
|
|
143
|
+
with Session(engine) as session:
|
|
144
|
+
entries = session.exec(select(CacheEntry)).all()
|
|
145
|
+
|
|
146
|
+
live_entries = [e for e in entries if not _is_expired(e)]
|
|
147
|
+
if not live_entries:
|
|
148
|
+
return CacheResult(hit=False, response=None, similarity=None, matched_query=None, model=None)
|
|
149
|
+
|
|
150
|
+
query_embedding = embed([query])[0]
|
|
151
|
+
similarities = [
|
|
152
|
+
(float(_deserialize_embedding(e.embedding) @ query_embedding), e) for e in live_entries
|
|
153
|
+
]
|
|
154
|
+
best_similarity, best_entry = max(similarities, key=lambda pair: pair[0])
|
|
155
|
+
|
|
156
|
+
if best_similarity < similarity_threshold:
|
|
157
|
+
return CacheResult(hit=False, response=None, similarity=best_similarity, matched_query=None, model=None)
|
|
158
|
+
|
|
159
|
+
if use_param_diff and params_conflict(query, best_entry.query):
|
|
160
|
+
return CacheResult(hit=False, response=None, similarity=best_similarity, matched_query=None, model=None)
|
|
161
|
+
|
|
162
|
+
return CacheResult(
|
|
163
|
+
hit=True,
|
|
164
|
+
response=best_entry.response,
|
|
165
|
+
similarity=best_similarity,
|
|
166
|
+
matched_query=best_entry.query,
|
|
167
|
+
model=best_entry.model,
|
|
168
|
+
)
|