claude-finops 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
package/finops/api.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Stdlib HTTP server: JSON API + static dashboard. Binds to localhost only —
|
|
2
|
+
the warehouse contains your full prompt text.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import io
|
|
6
|
+
import csv
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import traceback
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from urllib.parse import urlparse, parse_qs
|
|
13
|
+
|
|
14
|
+
from .analytics import Analytics, load_settings
|
|
15
|
+
from .paths import DB_PATH, LOCAL_SETTINGS_PATH, LOGFILE, PIDFILE, WEB_DIR, ensure_dirs
|
|
16
|
+
|
|
17
|
+
WEB = WEB_DIR
|
|
18
|
+
_lock = threading.Lock()
|
|
19
|
+
A = None
|
|
20
|
+
|
|
21
|
+
MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
22
|
+
".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
23
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def filters_from(qs):
|
|
27
|
+
def lst(k):
|
|
28
|
+
v = qs.get(k, [])
|
|
29
|
+
out = []
|
|
30
|
+
for item in v:
|
|
31
|
+
out += [x for x in item.split(",") if x]
|
|
32
|
+
return out
|
|
33
|
+
f = {
|
|
34
|
+
"start": qs.get("start", [None])[0] or None,
|
|
35
|
+
"end": qs.get("end", [None])[0] or None,
|
|
36
|
+
"agents": lst("agents"), "models": lst("models"), "projects": lst("projects"),
|
|
37
|
+
"sessions": lst("sessions"), "categories": lst("categories"),
|
|
38
|
+
"include_sandbox": qs.get("include_sandbox", ["1"])[0] != "0",
|
|
39
|
+
"min_cost": qs.get("min_cost", [None])[0] or None,
|
|
40
|
+
"max_cost": qs.get("max_cost", [None])[0] or None,
|
|
41
|
+
"min_tokens": qs.get("min_tokens", [None])[0] or None,
|
|
42
|
+
}
|
|
43
|
+
return f
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def flatten(rows):
|
|
47
|
+
keys = []
|
|
48
|
+
for r in rows:
|
|
49
|
+
for k in r:
|
|
50
|
+
if k not in keys:
|
|
51
|
+
keys.append(k)
|
|
52
|
+
buf = io.StringIO()
|
|
53
|
+
wtr = csv.DictWriter(buf, fieldnames=keys, extrasaction="ignore")
|
|
54
|
+
wtr.writeheader()
|
|
55
|
+
for r in rows:
|
|
56
|
+
wtr.writerow({k: (json.dumps(v) if isinstance(v, (dict, list)) else v)
|
|
57
|
+
for k, v in r.items()})
|
|
58
|
+
return buf.getvalue()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Handler(BaseHTTPRequestHandler):
|
|
62
|
+
protocol_version = "HTTP/1.1"
|
|
63
|
+
|
|
64
|
+
def log_message(self, *a):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def send_json(self, obj, code=200):
|
|
68
|
+
body = json.dumps(obj, default=str).encode()
|
|
69
|
+
self.send_response(code)
|
|
70
|
+
self.send_header("Content-Type", "application/json")
|
|
71
|
+
self.send_header("Content-Length", str(len(body)))
|
|
72
|
+
self.end_headers()
|
|
73
|
+
self.wfile.write(body)
|
|
74
|
+
|
|
75
|
+
def send_text(self, body, ctype, filename=None, code=200):
|
|
76
|
+
if isinstance(body, str):
|
|
77
|
+
body = body.encode()
|
|
78
|
+
self.send_response(code)
|
|
79
|
+
self.send_header("Content-Type", ctype)
|
|
80
|
+
if filename:
|
|
81
|
+
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
|
82
|
+
self.send_header("Content-Length", str(len(body)))
|
|
83
|
+
self.end_headers()
|
|
84
|
+
self.wfile.write(body)
|
|
85
|
+
|
|
86
|
+
def do_POST(self):
|
|
87
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
88
|
+
payload = json.loads(self.rfile.read(n) or b"{}")
|
|
89
|
+
path = urlparse(self.path).path
|
|
90
|
+
try:
|
|
91
|
+
if path.startswith("/api/live/"):
|
|
92
|
+
self._payload = payload
|
|
93
|
+
return self.session_action(path)
|
|
94
|
+
if path.startswith("/api/do/"):
|
|
95
|
+
if not self._same_origin():
|
|
96
|
+
return self.send_json({"ok": False, "error": "forbidden"}, 403)
|
|
97
|
+
return self.do_action(path[len("/api/do/"):].strip("/").split("/"), payload)
|
|
98
|
+
if path == "/api/settings":
|
|
99
|
+
# UI edits go to the gitignored per-machine file, never the shared defaults
|
|
100
|
+
local = {}
|
|
101
|
+
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
102
|
+
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
103
|
+
local = json.load(fh)
|
|
104
|
+
for k, v in payload.items():
|
|
105
|
+
if k in ("budgets", "limits", "alert_thresholds_pct", "waste_rules",
|
|
106
|
+
"anomaly", "scorecard", "account", "billing_period"):
|
|
107
|
+
if isinstance(v, dict) and isinstance(local.get(k), dict):
|
|
108
|
+
local[k].update(v)
|
|
109
|
+
else:
|
|
110
|
+
local[k] = v
|
|
111
|
+
with open(LOCAL_SETTINGS_PATH, "w") as fh:
|
|
112
|
+
json.dump(local, fh, indent=2)
|
|
113
|
+
cur = load_settings()
|
|
114
|
+
with _lock:
|
|
115
|
+
A.settings = cur
|
|
116
|
+
return self.send_json({"ok": True, "settings": cur})
|
|
117
|
+
self.send_json({"error": "unknown endpoint"}, 404)
|
|
118
|
+
except Exception:
|
|
119
|
+
self.send_json({"error": traceback.format_exc()}, 500)
|
|
120
|
+
|
|
121
|
+
def _same_origin(self):
|
|
122
|
+
origin = self.headers.get("Origin")
|
|
123
|
+
return self.headers.get("X-FinOps-Action") == "1" and (
|
|
124
|
+
not origin or origin == f"http://{self.headers.get('Host', '')}")
|
|
125
|
+
|
|
126
|
+
def do_action(self, parts, payload):
|
|
127
|
+
"""POST /api/do/...: actions that change this machine. Always user-initiated."""
|
|
128
|
+
from . import actions as X
|
|
129
|
+
if parts == ["sync"]:
|
|
130
|
+
cur = X.JOBS.get(_SYNC["job"] or "")
|
|
131
|
+
if not cur or cur["state"] != "running":
|
|
132
|
+
_SYNC["job"] = X._job(_sync_job)["id"]
|
|
133
|
+
return self.send_json({"job": _SYNC["job"]})
|
|
134
|
+
if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "install":
|
|
135
|
+
j = X._job(X.free_model_install, parts[1], bool(payload.get("consent")),
|
|
136
|
+
payload.get("api_key"))
|
|
137
|
+
return self.send_json({"job": j["id"]})
|
|
138
|
+
if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "test":
|
|
139
|
+
return self.send_json({"job": X._job(X.free_model_test, parts[1])["id"]})
|
|
140
|
+
if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "remove":
|
|
141
|
+
return self.send_json(X.free_model_remove(parts[1]))
|
|
142
|
+
if parts == ["cloud_sync"]:
|
|
143
|
+
from . import cloud
|
|
144
|
+
j = X._job(lambda log: cloud.sync(int(payload.get("days") or 30), log))
|
|
145
|
+
return self.send_json({"job": j["id"]})
|
|
146
|
+
if parts == ["skill"]:
|
|
147
|
+
return self.send_json(X.create_skill(payload))
|
|
148
|
+
if len(parts) == 2 and parts[0] == "mcp":
|
|
149
|
+
return self.send_json(X.add_mcp(parts[1], payload))
|
|
150
|
+
return self.send_json({"error": "unknown action"}, 404)
|
|
151
|
+
|
|
152
|
+
def actions_get(self, route):
|
|
153
|
+
from . import actions as X
|
|
154
|
+
parts = route.strip("/").split("/")
|
|
155
|
+
if parts == ["free_models"]:
|
|
156
|
+
return self.send_json(X.free_models())
|
|
157
|
+
if len(parts) == 3 and parts[0] == "free_models" and parts[2] == "plan":
|
|
158
|
+
return self.send_json(X.free_model_plan(parts[1]))
|
|
159
|
+
if parts == ["compare"]:
|
|
160
|
+
with _lock:
|
|
161
|
+
a = A
|
|
162
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
163
|
+
ags = [x for x in (qs.get("agents", [""])[0]).split(",") if x]
|
|
164
|
+
return self.send_json(X.compare(a, ags or None))
|
|
165
|
+
if parts == ["suggestions"]:
|
|
166
|
+
with _lock:
|
|
167
|
+
a = A
|
|
168
|
+
return self.send_json(X.suggestions(a))
|
|
169
|
+
if parts[0] == "job" and len(parts) == 2:
|
|
170
|
+
return self.send_json(X.job_status(parts[1]))
|
|
171
|
+
if parts == ["sync"]:
|
|
172
|
+
with _lock:
|
|
173
|
+
a = A
|
|
174
|
+
built = a.q("SELECT value FROM meta WHERE key='built_at'")
|
|
175
|
+
j = X.job_status(_SYNC["job"]) if _SYNC["job"] else None
|
|
176
|
+
return self.send_json({"built_at": built[0]["value"] if built else None, "job": j})
|
|
177
|
+
return self.send_json({"error": "unknown route"}, 404)
|
|
178
|
+
|
|
179
|
+
def session_action(self, path):
|
|
180
|
+
"""POST /api/live/<pid>/<interrupt|close|kill>.
|
|
181
|
+
|
|
182
|
+
Guarded against cross-site requests: a custom header can't be sent by another
|
|
183
|
+
origin without a CORS preflight (which this server never approves), and any
|
|
184
|
+
Origin present must be this dashboard.
|
|
185
|
+
"""
|
|
186
|
+
origin = self.headers.get("Origin")
|
|
187
|
+
host = self.headers.get("Host", "")
|
|
188
|
+
if self.headers.get("X-FinOps-Action") != "1" or (
|
|
189
|
+
origin and origin not in (f"http://{host}",)):
|
|
190
|
+
return self.send_json({"ok": False, "error": "forbidden"}, 403)
|
|
191
|
+
from .procs import act
|
|
192
|
+
parts = path.strip("/").split("/")
|
|
193
|
+
if len(parts) != 4 or not parts[2].isdigit():
|
|
194
|
+
return self.send_json({"ok": False, "error": "bad request"}, 400)
|
|
195
|
+
agent = (self._payload or {}).get("agent") or "claude"
|
|
196
|
+
if agent != "claude":
|
|
197
|
+
from .procs import act_agent
|
|
198
|
+
if parts[3] == "handover":
|
|
199
|
+
return self.send_json({"ok": False, "error": "Hand over is only available for Claude Code."})
|
|
200
|
+
return self.send_json(act_agent(int(parts[2]), parts[3], agent))
|
|
201
|
+
if parts[3] == "handover":
|
|
202
|
+
from .procs import handover
|
|
203
|
+
return self.send_json(handover(int(parts[2]), self._payload))
|
|
204
|
+
return self.send_json(act(int(parts[2]), parts[3]))
|
|
205
|
+
|
|
206
|
+
def do_GET(self):
|
|
207
|
+
u = urlparse(self.path)
|
|
208
|
+
path, qs = u.path, parse_qs(u.query)
|
|
209
|
+
try:
|
|
210
|
+
if path.startswith("/api/"):
|
|
211
|
+
return self.api(path[5:], qs)
|
|
212
|
+
rel = "index.html" if path in ("/", "") else path.lstrip("/")
|
|
213
|
+
fp = os.path.normpath(os.path.join(WEB, rel))
|
|
214
|
+
if not fp.startswith(WEB) or not os.path.isfile(fp):
|
|
215
|
+
return self.send_text("not found", "text/plain", code=404)
|
|
216
|
+
ext = os.path.splitext(fp)[1]
|
|
217
|
+
with open(fp, "rb") as fh:
|
|
218
|
+
self.send_text(fh.read(), MIME.get(ext, "application/octet-stream"))
|
|
219
|
+
except BrokenPipeError:
|
|
220
|
+
pass
|
|
221
|
+
except Exception:
|
|
222
|
+
self.send_json({"error": traceback.format_exc()}, 500)
|
|
223
|
+
|
|
224
|
+
def api(self, route, qs):
|
|
225
|
+
if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
|
|
226
|
+
return self.actions_get(route)
|
|
227
|
+
f = filters_from(qs)
|
|
228
|
+
g = lambda k, d=None: qs.get(k, [d])[0]
|
|
229
|
+
with _lock:
|
|
230
|
+
a = A
|
|
231
|
+
if route == "options":
|
|
232
|
+
from .cloud import configured
|
|
233
|
+
o = a.options()
|
|
234
|
+
o["cloud"] = configured() # nav hides "Billed vs local" until a key exists
|
|
235
|
+
return self.send_json(o)
|
|
236
|
+
if route == "by_agent":
|
|
237
|
+
return self.send_json(a.by_agent(f))
|
|
238
|
+
if route == "overview":
|
|
239
|
+
return self.send_json(a.overview(f))
|
|
240
|
+
if route == "burn":
|
|
241
|
+
return self.send_json(a.burn(f))
|
|
242
|
+
if route == "timeline":
|
|
243
|
+
return self.send_json(a.timeline(f, g("grain", "day")))
|
|
244
|
+
if route == "models":
|
|
245
|
+
return self.send_json(a.models(f))
|
|
246
|
+
if route == "projects":
|
|
247
|
+
return self.send_json(a.projects(f))
|
|
248
|
+
if route == "sessions":
|
|
249
|
+
return self.send_json(a.sessions(f, int(g("limit", 200)), g("order", "cost")))
|
|
250
|
+
if route == "prompts":
|
|
251
|
+
return self.send_json(a.prompts(f, int(g("limit", 200)), int(g("offset", 0)),
|
|
252
|
+
g("order", "cost"), g("q")))
|
|
253
|
+
if route == "leaderboards":
|
|
254
|
+
return self.send_json(a.leaderboards(f, int(g("n", 20))))
|
|
255
|
+
if route == "categories":
|
|
256
|
+
return self.send_json(a.categories(f))
|
|
257
|
+
if route == "efficiency":
|
|
258
|
+
return self.send_json(a.efficiency(f))
|
|
259
|
+
if route == "context":
|
|
260
|
+
return self.send_json(a.context_analysis(f))
|
|
261
|
+
if route == "waste":
|
|
262
|
+
return self.send_json(a.waste(f))
|
|
263
|
+
if route == "model_switch":
|
|
264
|
+
return self.send_json(a.model_switch(f))
|
|
265
|
+
if route == "recommendations":
|
|
266
|
+
return self.send_json(a.recommendations(f))
|
|
267
|
+
if route == "forecast":
|
|
268
|
+
return self.send_json(a.forecast(f))
|
|
269
|
+
if route == "budgets":
|
|
270
|
+
return self.send_json(a.budgets(f))
|
|
271
|
+
if route == "anomalies":
|
|
272
|
+
return self.send_json(a.anomalies(f))
|
|
273
|
+
if route == "scorecard":
|
|
274
|
+
return self.send_json(a.scorecard(f))
|
|
275
|
+
if route == "advisor":
|
|
276
|
+
return self.send_json(a.advisor(f))
|
|
277
|
+
if route == "diagnose":
|
|
278
|
+
from .diagnose import Diagnoser
|
|
279
|
+
return self.send_json(Diagnoser(a).run(f))
|
|
280
|
+
if route == "breakdown":
|
|
281
|
+
from .diagnose import Diagnoser
|
|
282
|
+
return self.send_json(Diagnoser(a).breakdown(f))
|
|
283
|
+
if route == "live":
|
|
284
|
+
from .procs import list_sessions, list_agent_sessions
|
|
285
|
+
want = [x for x in (g("agents", "") or "").split(",") if x]
|
|
286
|
+
claude = [dict(s, agent="claude", signalable=True,
|
|
287
|
+
resume=f"claude --resume {s.get('session_id')}")
|
|
288
|
+
for s in list_sessions(a.pricing)] if not want or "claude" in want else []
|
|
289
|
+
others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
|
|
290
|
+
if not want or any(x != "claude" for x in want) else []
|
|
291
|
+
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
292
|
+
return self.send_json({"sessions": rows})
|
|
293
|
+
if route == "cloud":
|
|
294
|
+
from .cloud import report
|
|
295
|
+
return self.send_json(report(a, int(g("days", 30))))
|
|
296
|
+
if route == "developer":
|
|
297
|
+
return self.send_json(a.developer(f))
|
|
298
|
+
if route == "search":
|
|
299
|
+
return self.send_json(a.search(g("q", ""), int(g("limit", 40))))
|
|
300
|
+
if route.startswith("prompt/"):
|
|
301
|
+
return self.send_json(a.prompt_detail(int(route.split("/")[1])))
|
|
302
|
+
if route.startswith("session/"):
|
|
303
|
+
return self.send_json(a.session_detail(route.split("/", 1)[1]))
|
|
304
|
+
if route == "bundle":
|
|
305
|
+
return self.send_json({
|
|
306
|
+
"overview": a.overview(f), "burn": a.burn(f), "timeline": a.timeline(f),
|
|
307
|
+
"models": a.models(f), "projects": a.projects(f)[:40],
|
|
308
|
+
"categories": a.categories(f), "efficiency": a.efficiency(f),
|
|
309
|
+
"context": a.context_analysis(f), "waste": a.waste(f),
|
|
310
|
+
"recommendations": a.recommendations(f), "forecast": a.forecast(f),
|
|
311
|
+
"budgets": a.budgets(f), "anomalies": a.anomalies(f),
|
|
312
|
+
"scorecard": a.scorecard(f), "advisor": a.advisor(f),
|
|
313
|
+
"leaderboards": a.leaderboards(f), "developer": a.developer(f),
|
|
314
|
+
})
|
|
315
|
+
if route.startswith("export/"):
|
|
316
|
+
return self.export(route.split("/", 1)[1], f, g)
|
|
317
|
+
self.send_json({"error": f"unknown route {route}"}, 404)
|
|
318
|
+
|
|
319
|
+
def export(self, what, f, g):
|
|
320
|
+
with _lock:
|
|
321
|
+
a = A
|
|
322
|
+
fmt = g("format", "csv")
|
|
323
|
+
data = {
|
|
324
|
+
"prompts": lambda: a.prompts(dict(f, _full_text=True), limit=100000, order="cost"),
|
|
325
|
+
"sessions": lambda: a.sessions(f, limit=100000, order="cost"),
|
|
326
|
+
"usage": lambda: a.timeline(f),
|
|
327
|
+
"models": lambda: a.models(f)["rows"],
|
|
328
|
+
"projects": lambda: a.projects(f),
|
|
329
|
+
"costs": lambda: a.timeline(f),
|
|
330
|
+
"waste": lambda: [dict(x, evidence=len(x["evidence"])) for x in a.waste(f)["findings"]],
|
|
331
|
+
"recommendations": lambda: a.recommendations(f)["recommendations"],
|
|
332
|
+
"forecast": lambda: [dict(name=k, **v) for k, v in
|
|
333
|
+
(a.forecast(f).get("scenarios") or {}).items()],
|
|
334
|
+
}
|
|
335
|
+
if what == "report":
|
|
336
|
+
return self.report(a, f)
|
|
337
|
+
if what not in data:
|
|
338
|
+
return self.send_json({"error": "unknown export"}, 404)
|
|
339
|
+
rows = data[what]()
|
|
340
|
+
if fmt == "json":
|
|
341
|
+
return self.send_text(json.dumps(rows, indent=2, default=str),
|
|
342
|
+
"application/json", f"claude-finops-{what}.json")
|
|
343
|
+
return self.send_text(flatten(rows), "text/csv", f"claude-finops-{what}.csv")
|
|
344
|
+
|
|
345
|
+
def report(self, a, f):
|
|
346
|
+
"""Self-contained printable HTML report (Cmd/Ctrl+P -> PDF)."""
|
|
347
|
+
from .report import build_report
|
|
348
|
+
self.send_text(build_report(a, f), "text/html; charset=utf-8")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _under_claude():
|
|
355
|
+
if os.name == "nt":
|
|
356
|
+
return False
|
|
357
|
+
from .procs import _ps, _ancestors, _is_claude
|
|
358
|
+
procs = _ps()
|
|
359
|
+
return any(_is_claude(procs[p]) for p in _ancestors(os.getpid(), procs) if p in procs)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def detach(port):
|
|
363
|
+
"""Re-parent the server into its own process session so closing the Claude
|
|
364
|
+
session (or terminal) that launched it doesn't take the dashboard down too."""
|
|
365
|
+
import signal
|
|
366
|
+
ensure_dirs()
|
|
367
|
+
if os.fork():
|
|
368
|
+
print(f"Claude FinOps Command Center -> http://127.0.0.1:{port} (detached)")
|
|
369
|
+
print(f" log: {LOGFILE} stop: ./run.sh --stop")
|
|
370
|
+
os._exit(0)
|
|
371
|
+
os.setsid()
|
|
372
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
373
|
+
if os.fork():
|
|
374
|
+
os._exit(0)
|
|
375
|
+
fd = os.open(LOGFILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
376
|
+
os.dup2(os.open(os.devnull, os.O_RDONLY), 0)
|
|
377
|
+
os.dup2(fd, 1)
|
|
378
|
+
os.dup2(fd, 2)
|
|
379
|
+
with open(PIDFILE, "w") as fh:
|
|
380
|
+
fh.write(str(os.getpid()))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
_SYNC = {"job": None}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _sync_job(log):
|
|
387
|
+
"""Rebuild, then swap the live Analytics over to the fresh warehouse."""
|
|
388
|
+
global A
|
|
389
|
+
from . import actions as X
|
|
390
|
+
res = X.sync(log)
|
|
391
|
+
with _lock:
|
|
392
|
+
try:
|
|
393
|
+
A.db.close() # Windows can't replace a file that's still open
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
X.finish_sync(res["tmp"])
|
|
397
|
+
A = Analytics(DB_PATH)
|
|
398
|
+
log("Synced.")
|
|
399
|
+
return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def serve(port=8787, db=DB_PATH, background=None):
|
|
403
|
+
global A
|
|
404
|
+
if not os.path.exists(db):
|
|
405
|
+
raise SystemExit(f"No warehouse at {db}. Run: python3 -m finops.etl")
|
|
406
|
+
if background is None:
|
|
407
|
+
background = os.environ.get("FINOPS_DETACH") == "1" or _under_claude()
|
|
408
|
+
if background and hasattr(os, "fork"):
|
|
409
|
+
detach(port)
|
|
410
|
+
A = Analytics(db)
|
|
411
|
+
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
412
|
+
print(f"Claude FinOps Command Center -> http://127.0.0.1:{port}")
|
|
413
|
+
print(f" warehouse: {db}")
|
|
414
|
+
print(f" data : {A.first_day} .. {A.last_day}")
|
|
415
|
+
print(" Ctrl-C to stop.")
|
|
416
|
+
try:
|
|
417
|
+
srv.serve_forever()
|
|
418
|
+
except KeyboardInterrupt:
|
|
419
|
+
print("\nbye")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
import sys
|
|
424
|
+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
425
|
+
bg = True if "--detach" in sys.argv else False if "--foreground" in sys.argv else None
|
|
426
|
+
serve(int(args[0]) if args else 8787, background=bg)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Heuristic prompt classification.
|
|
2
|
+
|
|
3
|
+
Deliberately transparent and rule-based: every prompt records WHY it landed in a
|
|
4
|
+
category so the dashboard can show the evidence rather than an opaque label.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
CATEGORIES = [
|
|
9
|
+
("debugging", 3.0, r"\b(bug|debug|error|exception|traceback|stack ?trace|not working|broken|fails?|failing|failed|crash|fix this|why (is|does|isn'?t)|troubleshoot|502|500|null pointer|undefined is not)\b"),
|
|
10
|
+
("code_review", 2.6, r"\b(review|code ?review|pr\b|pull request|feedback on|critique|lgtm|nitpick|approve)\b"),
|
|
11
|
+
("refactoring", 2.5, r"\b(refactor|clean ?up|simplify|restructure|rename|extract (a )?(function|method|component)|dedupe|deduplicate|tech debt|tidy)\b"),
|
|
12
|
+
("testing", 2.4, r"\b(test|tests|unit test|integration test|pytest|jest|coverage|assert|mock|spec file)\b"),
|
|
13
|
+
("documentation", 2.3, r"\b(document|documentation|docs?|readme|changelog|comment(s)? (for|on)|docstring|write up|write-up)\b"),
|
|
14
|
+
("architecture", 2.2, r"\b(architect|architecture|design (the|a) (system|schema|api)|system design|data model|schema|scal(e|ing|ability)|trade[- ]?offs?|high level design)\b"),
|
|
15
|
+
("planning", 2.1, r"\b(plan|roadmap|break (this )?down|steps to|approach|strategy|estimate|milestone|backlog|prioriti[sz]e)\b"),
|
|
16
|
+
("automation", 2.0, r"\b(script|automat|cron|pipeline|ci/?cd|workflow|deploy|jenkins|github action|makefile|bash script)\b"),
|
|
17
|
+
("research", 1.9, r"\b(research|compare|find out|look up|investigate|what (is|are)|explore options|alternatives|pros and cons|benchmark|which (library|tool|framework))\b"),
|
|
18
|
+
("learning", 1.8, r"\b(explain|how does|teach me|understand|what does .* mean|walk me through|help me learn|tutorial|eli5)\b"),
|
|
19
|
+
("writing", 1.7, r"\b(write (an?|the)? ?(email|blog|post|copy|article|summary|message|slide)|draft|rephrase|proofread|tone|paraphrase)\b"),
|
|
20
|
+
("data_analysis", 1.7, r"\b(analy[sz]e|dataset|csv|dataframe|sql query|aggregate|chart|visuali[sz]|report on|metrics)\b"),
|
|
21
|
+
("coding", 1.5, r"\b(implement|build|create|add|write (a )?(function|class|component|endpoint|module)|code|feature|api|component|migrate|integrate|install|setup|set up|configure)\b"),
|
|
22
|
+
("casual", 1.0, r"^\s*(hi|hey|hello|thanks|thank you|ok|okay|yes|no|cool|nice|great|continue|go ahead|proceed|yep|sure)\b"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_COMPILED = [(name, w, re.compile(pat, re.I)) for name, w, pat in CATEGORIES]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def classify(text):
|
|
29
|
+
"""Return (category, confidence 0-1, matched_terms)."""
|
|
30
|
+
if not text or not text.strip():
|
|
31
|
+
return "other", 0.0, []
|
|
32
|
+
head = text[:4000]
|
|
33
|
+
scores = {}
|
|
34
|
+
evidence = {}
|
|
35
|
+
for name, weight, rx in _COMPILED:
|
|
36
|
+
hits = rx.findall(head)
|
|
37
|
+
if hits:
|
|
38
|
+
flat = []
|
|
39
|
+
for h in hits:
|
|
40
|
+
flat.append(h if isinstance(h, str) else next((x for x in h if x), ""))
|
|
41
|
+
n = len(flat)
|
|
42
|
+
scores[name] = weight * (1 + 0.25 * min(n - 1, 4))
|
|
43
|
+
evidence[name] = sorted({s.lower().strip() for s in flat if s})[:5]
|
|
44
|
+
if not scores:
|
|
45
|
+
return "other", 0.0, []
|
|
46
|
+
best = max(scores, key=scores.get)
|
|
47
|
+
total = sum(scores.values())
|
|
48
|
+
return best, round(scores[best] / total, 3), evidence.get(best, [])
|