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 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
+ )
File without changes
@@ -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
+ )