token-optimise 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.
src/__init__.py ADDED
File without changes
src/core/__init__.py ADDED
File without changes
src/core/cache.py ADDED
@@ -0,0 +1,126 @@
1
+ import hashlib, time, os, logging
2
+ from src.core.client import _chroma_client, CHROMA_PATH
3
+ from chromadb.utils import embedding_functions
4
+ from token_optimise.config import settings, PROJECT_ROOT
5
+
6
+ logger = logging.getLogger("token")
7
+
8
+
9
+ TTL_BY_TOOL = {
10
+ "execute": 300,
11
+ "ask_document": 86400,
12
+ "search_all_documents": 86400,
13
+ "list_indexed_documents": 60,
14
+ "index_document": 0,
15
+ "index_documents_folder": 0,
16
+ }
17
+ TTL_DEFAULT = 3600
18
+
19
+ _cache_collection = None
20
+
21
+ def _get_cache():
22
+ global _cache_collection
23
+ if _cache_collection is None:
24
+ embedder = embedding_functions.SentenceTransformerEmbeddingFunction(
25
+ model_name=settings.embedder
26
+ )
27
+ _cache_collection = _chroma_client.get_or_create_collection(
28
+ name="semantic_cache", embedding_function=embedder
29
+ )
30
+ return _cache_collection
31
+
32
+ def check_cache(query: str, tool_name: str = ""):
33
+ """Returns (answer, similarity). answer is None on miss or expiry."""
34
+ try:
35
+ results = _get_cache().query(query_texts=[query], n_results=1)
36
+ if not results["documents"][0]:
37
+ return None, 0.0
38
+ distance = results["distances"][0][0]
39
+ similarity = 1 - distance
40
+ if similarity >= 0.8:
41
+ meta = results["metadatas"][0][0]
42
+ cached_at = meta.get("cached_at", 0)
43
+ ttl = TTL_BY_TOOL.get(tool_name, TTL_DEFAULT)
44
+ if ttl == 0:
45
+ return None, similarity
46
+ age = time.time() - cached_at
47
+ if age > ttl:
48
+ logger.info(
49
+ f"[CACHE] EXPIRED for '{tool_name}' | age={int(age)}s ttl={ttl}s"
50
+ )
51
+ return None, similarity
52
+ logger.info(f"[CACHE] HIT for '{tool_name}' | sim={similarity:.3f} age={int(age)}s")
53
+ return meta["answer"], similarity
54
+ return None, similarity
55
+ except Exception as e:
56
+ logger.warning(f"[CACHE] check failed: {e}", exc_info=True)
57
+ return None, 0.0
58
+
59
+
60
+ def check_cache_exact(key: str, tool_name: str = ""):
61
+ try:
62
+ result = _get_cache().get(ids=[key], include=["metadatas"])
63
+ if not result["ids"]:
64
+ return None, 0.0
65
+ meta = result["metadatas"][0]
66
+ cached_at = meta.get("cached_at", 0)
67
+ ttl = TTL_BY_TOOL.get(tool_name, TTL_DEFAULT)
68
+ if ttl == 0:
69
+ return None, 1.0
70
+ age = time.time() - cached_at
71
+ if age > ttl:
72
+ logger.info(f"[CACHE] EXPIRED (exact) for '{tool_name}' | age={int(age)}s")
73
+ return None, 1.0
74
+ logger.info(f"[CACHE] HIT (exact) for '{tool_name}'")
75
+ return meta["answer"], 1.0
76
+ except Exception as e:
77
+ logger.warning(f"[CACHE] exact check failed: {e}")
78
+ return None, 0.0
79
+
80
+
81
+
82
+
83
+ def store_answer(query, answer,tool_name):
84
+ query_id = hashlib.sha256(query.strip().lower().encode()).hexdigest()
85
+ try:
86
+ _get_cache().upsert(
87
+ ids=[query_id],
88
+ documents=[query],
89
+ metadatas=[{"answer": answer, "cached_at": time.time(), "tool_name": tool_name}],
90
+ )
91
+ except Exception as e:
92
+ logger.warning(f"[CACHE] store failed: {e}")
93
+
94
+
95
+ def store_answer_exact(key: str, answer: str, tool_name: str) -> None:
96
+ """Store by exact key (no embedding). Used for doc queries."""
97
+ try:
98
+ _get_cache().upsert(
99
+ ids=[key],
100
+ documents=[key],
101
+ metadatas=[{"answer": answer, "cached_at": time.time(), "tool_name": tool_name}],
102
+ )
103
+ except Exception as e:
104
+ logger.warning(f"[CACHE] exact store failed: {e}")
105
+
106
+
107
+
108
+ def cleanup_cache():
109
+ try:
110
+ all_entries = _get_cache().get(include=["metadatas"])
111
+ now = time.time()
112
+ expired_ids = []
113
+ for id_, meta in zip(all_entries["ids"], all_entries["metadatas"]):
114
+ tool_name = meta.get("tool_name", "")
115
+ ttl = TTL_BY_TOOL.get(tool_name, TTL_DEFAULT)
116
+ if ttl == 0:
117
+ continue
118
+ if now - meta.get("cached_at", 0) > ttl:
119
+ expired_ids.append(id_)
120
+ if expired_ids:
121
+ _get_cache().delete(ids=expired_ids)
122
+ logger.info(f"[CACHE] cleaned up {len(expired_ids)} expired entries")
123
+ return len(expired_ids)
124
+ except Exception as e:
125
+ logger.warning(f"[CACHE] cleanup failed: {e}")
126
+ return 0
src/core/client.py ADDED
@@ -0,0 +1,114 @@
1
+ from groq import Groq
2
+ from token_optimise.config import settings,PROJECT_ROOT
3
+ import chromadb
4
+ import os,json
5
+ import logging
6
+
7
+ logger = logging.getLogger("token")
8
+
9
+ _groq_client = Groq(api_key=settings.groq_api_key)
10
+ CHROMA_PATH = os.path.join(PROJECT_ROOT, "storage", "chroma_db")
11
+ _chroma_client = chromadb.PersistentClient(path=CHROMA_PATH)
12
+
13
+ def fill_args_llm(query,schema):
14
+ props = schema.get("properties", {})
15
+ required = schema.get("required", [])
16
+ schema_summary="\n".join([
17
+ f"- {name} ({info.get('type','string')}) : {info.get('description','no desc')}"
18
+ for name,info in props.items()
19
+ ])
20
+
21
+ prompt = (
22
+ f"You are a tool argument filler. Given a tool schema and a user query, "
23
+ f"return ONLY a valid JSON object with the correct arguments.\n"
24
+ f"Tool parameters:\n{schema_summary}\n"
25
+ f"Required fields: {required}\n"
26
+ f"User query: \"{query}\"\n\n"
27
+ f"Rules:\n"
28
+ f"- Return ONLY a JSON object, no explanation, no markdown, no backticks\n"
29
+ f"- For file paths: NEVER use ~ or relative paths. Always expand to full absolute path.\n"
30
+ f"- Home directory is: {os.path.expanduser('~')}\n"
31
+ f"- Desktop is: {os.path.expanduser('~/Desktop')}\n"
32
+ f"- Documents is: {os.path.expanduser('~/Documents')}\n"
33
+ f"JSON:"
34
+ )
35
+
36
+
37
+ try:
38
+ raw = _groq_client.chat.completions.create(
39
+ model=settings.groq_model,
40
+ messages=[{"role": "user", "content": prompt}],
41
+ temperature=0,
42
+ max_tokens=settings.max_response_tokens,
43
+ )
44
+ usage = raw.usage
45
+ logger.info(
46
+ f"[GROQ] fill_args | prompt_tokens={usage.prompt_tokens} "
47
+ f"completion_tokens={usage.completion_tokens}"
48
+ )
49
+
50
+ response = raw.choices[0].message.content.strip()
51
+
52
+ if response.startswith("```"):
53
+ response = response.split("```")[1]
54
+ if response.startswith("json"):
55
+ response = response[4:]
56
+ response = response.strip()
57
+
58
+ args = json.loads(response)
59
+ result = {k: v for k, v in args.items() if v is not None}
60
+ result["_groq_usage"] = {
61
+ "prompt_tokens": usage.prompt_tokens,
62
+ "completion_tokens": usage.completion_tokens,
63
+ }
64
+ return result
65
+
66
+ except json.JSONDecodeError as e:
67
+ logger.warning(f"[GROQ] failed to parse args JSON: {e}")
68
+ return {"_groq_error": f"JSONDecodeError: {e}"}
69
+ except Exception as e:
70
+ logger.warning(f"[GROQ] fill_args_llm failed: {e}")
71
+ return {"_groq_error": str(e)}
72
+
73
+
74
+ def expand_query(query: str, tool_descriptions: list[dict]) -> tuple[str, int, int]:
75
+ """Rewrite query using tool terminology. Returns (rewritten, prompt_tokens, completion_tokens)."""
76
+ if not tool_descriptions:
77
+ return query, 0, 0
78
+
79
+ # Using all the tools, capping stopped.
80
+ tools_text = "\n".join(
81
+ f"- {t['function']['name']}: {t['function'].get('description', '')}"
82
+ for t in tool_descriptions
83
+ )
84
+
85
+ try:
86
+ resp = _groq_client.chat.completions.create(
87
+ model=settings.groq_model,
88
+ messages=[
89
+ {
90
+ "role": "system",
91
+ "content": (
92
+ "You are a query rewriter. Given a user query and a list of available tools, "
93
+ "rewrite the query using the exact terminology and phrasing that best matches "
94
+ "the tool descriptions. Output ONLY the rewritten query, nothing else."
95
+ ),
96
+ },
97
+ {
98
+ "role": "user",
99
+ "content": f"Tools:\n{tools_text}\n\nUser query: {query}\n\nRewritten query:",
100
+ },
101
+ ],
102
+ max_tokens=60,
103
+ temperature=0.0,
104
+ )
105
+ usage = resp.usage
106
+ logger.info(
107
+ f"[GROQ] expand_query | prompt_tokens={usage.prompt_tokens} "
108
+ f"completion_tokens={usage.completion_tokens}"
109
+ )
110
+ rewritten = resp.choices[0].message.content.strip()
111
+ return (rewritten if rewritten else query, usage.prompt_tokens, usage.completion_tokens)
112
+ except Exception as e:
113
+ logger.warning(f"[EXPAND_QUERY] failed: {e}")
114
+ return query, 0, 0
src/core/db.py ADDED
@@ -0,0 +1,299 @@
1
+ import os
2
+ import time
3
+ import uuid
4
+ import logging
5
+ import threading
6
+ import sqlite3
7
+ import requests
8
+ from contextlib import contextmanager
9
+ from token_optimise.config import PROJECT_ROOT,settings
10
+
11
+ logger = logging.getLogger("token")
12
+
13
+ DB_PATH = os.path.join(PROJECT_ROOT, "storage", "token_events.db")
14
+
15
+ INGEST_URL = settings.token_ingest_url
16
+
17
+
18
+ CONVERSATION_TIMEOUT_MINUTES = 15
19
+ _current_conversation_id = None
20
+ _last_event_time = None
21
+ _conv_lock = threading.Lock()
22
+
23
+
24
+ def _get_or_create_conversation_id() -> str:
25
+ global _current_conversation_id, _last_event_time
26
+ with _conv_lock:
27
+ now = time.time()
28
+ if (
29
+ _current_conversation_id is None
30
+ or _last_event_time is None
31
+ or (now - _last_event_time) > CONVERSATION_TIMEOUT_MINUTES * 60
32
+ ):
33
+ _current_conversation_id = str(uuid.uuid4())
34
+ _last_event_time = now
35
+ return _current_conversation_id
36
+
37
+
38
+ def get_connection() -> sqlite3.Connection:
39
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
40
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
41
+ conn.row_factory = sqlite3.Row
42
+ return conn
43
+
44
+
45
+ @contextmanager
46
+ def get_db():
47
+ conn = get_connection()
48
+ try:
49
+ yield conn
50
+ finally:
51
+ conn.close()
52
+
53
+
54
+ def init_db(conn):
55
+ conn.execute("""
56
+ CREATE TABLE IF NOT EXISTS events (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ user_id TEXT,
59
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
60
+ conversation_id TEXT,
61
+ tool_name TEXT NOT NULL,
62
+ query TEXT,
63
+ cache_hit INTEGER DEFAULT 0,
64
+ cache_similarity REAL DEFAULT 0.0,
65
+ tokens_before_trim INTEGER DEFAULT 0,
66
+ tokens_after_trim INTEGER DEFAULT 0,
67
+ trim_saved INTEGER DEFAULT 0,
68
+ schema_tokens_full INTEGER DEFAULT 0,
69
+ schema_tokens_selected INTEGER DEFAULT 0,
70
+ schema_tokens_saved INTEGER DEFAULT 0,
71
+ groq_prompt_tokens INTEGER DEFAULT 0,
72
+ groq_completion_tokens INTEGER DEFAULT 0,
73
+ doc_id TEXT,
74
+ success INTEGER DEFAULT 1
75
+ )
76
+ """)
77
+ for col in ["user_id", "conversation_id"]:
78
+ try:
79
+ conn.execute(f"ALTER TABLE events ADD COLUMN {col} TEXT")
80
+ except Exception:
81
+ pass
82
+ conn.commit()
83
+ logger.info(f"[DB] SQLite ready at {DB_PATH}")
84
+
85
+
86
+
87
+ def init_db_once() -> None:
88
+ """Call once at server startup."""
89
+ with get_db() as conn:
90
+ init_db(conn)
91
+
92
+
93
+
94
+
95
+
96
+ def _post_to_cloud(data: dict):
97
+ """Send event to AWS /collect endpoint."""
98
+ if not INGEST_URL:
99
+ return
100
+ try:
101
+ # Read token fresh — it may have been saved after module loaded
102
+ token = settings.collect_token or ""
103
+ headers = {"X-Collect-Token": token} if token else {}
104
+ requests.post(INGEST_URL, json=data, timeout=3, headers=headers)
105
+ except Exception as e:
106
+ logger.debug(f"[DB] Cloud ingest failed (local SQLite has it): {e}")
107
+
108
+
109
+
110
+ def insert_event(**kwargs):
111
+ kwargs.setdefault("user_id", os.getenv("TOKEN_USER_ID", "unknown"))
112
+ kwargs.setdefault("conversation_id", _get_or_create_conversation_id())
113
+
114
+ fields = [
115
+ "user_id", "conversation_id",
116
+ "tool_name", "query", "cache_hit", "cache_similarity",
117
+ "tokens_before_trim", "tokens_after_trim", "trim_saved",
118
+ "schema_tokens_full", "schema_tokens_selected", "schema_tokens_saved",
119
+ "doc_id", "success",
120
+ "groq_prompt_tokens", "groq_completion_tokens",
121
+ ]
122
+
123
+ data = {f: kwargs.get(f, None) for f in fields}
124
+
125
+ # always write to local SQLite
126
+ with get_db() as conn:
127
+ placeholders = ", ".join(["?" for _ in fields])
128
+ columns = ", ".join(fields)
129
+ conn.execute(
130
+ f"INSERT INTO events ({columns}) VALUES ({placeholders})",
131
+ list(data.values()),
132
+ )
133
+ conn.commit()
134
+
135
+
136
+ _post_to_cloud(data)
137
+
138
+
139
+ def _fetchall(conn, sql, params=()):
140
+ rows = conn.execute(sql, params).fetchall()
141
+ return [dict(r) for r in rows]
142
+
143
+
144
+
145
+ def _fetchone(conn, sql, params=()):
146
+ row = conn.execute(sql, params).fetchone()
147
+ return dict(row) if row else {}
148
+
149
+
150
+ def get_summary(conn):
151
+ return _fetchone(conn, """
152
+ SELECT
153
+ COUNT(*) as total_calls,
154
+ COALESCE(SUM(cache_hit), 0) as cache_hits,
155
+ COALESCE(ROUND(AVG(cache_hit) * 100, 1), 0.0) as hit_rate_pct,
156
+ COALESCE(SUM(trim_saved), 0) as total_trim_saved,
157
+ COALESCE(SUM(schema_tokens_saved), 0) as total_schema_saved,
158
+ COALESCE(SUM(trim_saved + schema_tokens_saved), 0) as total_tokens_saved
159
+ FROM events
160
+ """)
161
+
162
+
163
+
164
+ def get_recent_events(conn, limit=50):
165
+ return _fetchall(
166
+ conn, "SELECT * FROM events ORDER BY timestamp DESC LIMIT ?", (limit,)
167
+ )
168
+
169
+ def get_tool_stats(conn):
170
+ return _fetchall(conn,"""
171
+ SELECT
172
+ tool_name,
173
+ COUNT(*) AS calls,
174
+ SUM(cache_hit) AS hits,
175
+ SUM(trim_saved) AS trim_saved,
176
+ SUM(schema_tokens_saved) AS schema_saved
177
+ FROM events
178
+ GROUP BY tool_name
179
+ ORDER BY calls DESC
180
+ """)
181
+
182
+
183
+ def get_token_analysis(conn):
184
+ rows = _fetchone(conn, """
185
+ SELECT
186
+ COUNT(*) AS total_queries,
187
+ COALESCE(SUM(schema_tokens_saved), 0) AS schema_saved,
188
+ COALESCE(SUM(schema_tokens_full), 0) AS schema_full,
189
+ COALESCE(SUM(schema_tokens_selected), 0) AS schema_selected,
190
+ COALESCE(SUM(trim_saved), 0) AS trim_saved,
191
+ COALESCE(SUM(tokens_before_trim), 0) AS tokens_before_trim,
192
+ COALESCE(SUM(tokens_after_trim), 0) AS tokens_after_trim,
193
+ COALESCE(SUM(cache_hit), 0) AS cache_hits,
194
+ COUNT(CASE WHEN cache_hit = 0 THEN 1 END) as cache_misses
195
+ FROM events
196
+ """)
197
+
198
+ schema_saved = rows.get("schema_saved",0)
199
+ schema_full = rows.get("schema_full",0)
200
+ schema_selected = rows.get("schema_selected",0)
201
+ trim_saved = rows.get("trim_saved",0)
202
+ tokens_before = rows.get("tokens_before_trim",0)
203
+ tokens_after = rows.get("tokens_after_trim",0)
204
+ cache_hits = rows.get("cache_hits",0)
205
+ cache_misses = rows.get("cache_misses",0)
206
+ total_queries = rows.get("total_queries",0)
207
+
208
+ total_saved = schema_saved + trim_saved
209
+
210
+ actual_without= schema_full + tokens_before
211
+ actual_with = schema_selected + tokens_after
212
+
213
+ pct_saved = round((total_saved / actual_without * 100), 1) if actual_without > 0 else 0.0
214
+
215
+ return {
216
+ "total_queries": total_queries,
217
+ "cache_hits": cache_hits,
218
+ "cache_misses": cache_misses,
219
+ "schema_tokens_without_tom": schema_full,
220
+ "schema_tokens_with_tom": schema_selected,
221
+ "schema_saved": schema_saved,
222
+ "tokens_before_trim": tokens_before,
223
+ "tokens_after_trim": tokens_after,
224
+ "trim_saved": trim_saved,
225
+ "total_saved": total_saved,
226
+ "actual_without_tom": actual_without,
227
+ "actual_with_tom": actual_with,
228
+ "pct_saved": pct_saved,
229
+ }
230
+
231
+ def get_event_count(conn) -> int:
232
+ """Quick row count for health check."""
233
+ row = _fetchone(conn, "SELECT COUNT(*) AS cnt FROM events")
234
+ return row.get("cnt", 0)
235
+
236
+ def get_conversation_stats(conn, limit=20):
237
+ return _fetchall(conn, """
238
+ SELECT
239
+ conversation_id,
240
+ MIN(timestamp) AS started_at,
241
+ COUNT(*) AS total_calls,
242
+ SUM(cache_hit) AS cache_hits,
243
+ SUM(trim_saved) AS trim_saved,
244
+ SUM(schema_tokens_saved) AS schema_saved,
245
+ SUM(trim_saved + schema_tokens_saved) AS total_saved
246
+ FROM events
247
+ WHERE conversation_id IS NOT NULL
248
+ GROUP BY conversation_id
249
+ ORDER BY started_at DESC
250
+ LIMIT ?
251
+ """, (limit,))
252
+
253
+
254
+
255
+ def get_groq_usage(conn):
256
+ return _fetchone(conn, """
257
+ SELECT
258
+ COALESCE(SUM(groq_prompt_tokens), 0) AS total_prompt_tokens,
259
+ COALESCE(SUM(groq_completion_tokens), 0) AS total_completion_tokens,
260
+ COALESCE(SUM(groq_prompt_tokens + groq_completion_tokens), 0) AS total_groq_tokens
261
+ FROM events
262
+ """)
263
+
264
+
265
+ def get_timeseries(conn, hours: int = 24):
266
+ return _fetchall(conn, """
267
+ SELECT
268
+ strftime('%Y-%m-%d %H:00', timestamp) AS hour,
269
+ COUNT(*) AS total_calls,
270
+ COALESCE(SUM(cache_hit), 0) AS cache_hits,
271
+ COALESCE(SUM(schema_tokens_saved), 0) AS schema_saved,
272
+ COALESCE(SUM(trim_saved), 0) AS trim_saved,
273
+ COALESCE(SUM(groq_prompt_tokens + groq_completion_tokens), 0) AS groq_tokens
274
+ FROM events
275
+ WHERE timestamp >= datetime('now', ? || ' hours')
276
+ GROUP BY hour
277
+ ORDER BY hour ASC
278
+ """, (f"-{hours}",))
279
+
280
+
281
+
282
+ def get_period_summary(conn, period: str = "today"):
283
+ _safe = {
284
+ "today": "date(timestamp) = date('now')",
285
+ "yesterday": "date(timestamp) = date('now', '-1 day')",
286
+ "week": "timestamp >= datetime('now', '-7 days')",
287
+ "all": "1=1",
288
+ }
289
+ where = _safe.get(period, "1=1")
290
+ return _fetchone(conn, f"""
291
+ SELECT
292
+ COUNT(*) AS total_calls,
293
+ COALESCE(SUM(cache_hit), 0) AS cache_hits,
294
+ COALESCE(SUM(schema_tokens_saved + trim_saved), 0) AS total_saved,
295
+ COALESCE(SUM(groq_prompt_tokens), 0) AS groq_prompt,
296
+ COALESCE(SUM(groq_completion_tokens), 0) AS groq_completion
297
+ FROM events
298
+ WHERE {where}
299
+ """)