syntaxmatrix 1.4.6__py3-none-any.whl → 2.5.5.4__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.
- syntaxmatrix/__init__.py +13 -8
- syntaxmatrix/agentic/__init__.py +0 -0
- syntaxmatrix/agentic/agent_tools.py +24 -0
- syntaxmatrix/agentic/agents.py +810 -0
- syntaxmatrix/agentic/code_tools_registry.py +37 -0
- syntaxmatrix/agentic/model_templates.py +1790 -0
- syntaxmatrix/auth.py +308 -14
- syntaxmatrix/commentary.py +328 -0
- syntaxmatrix/core.py +993 -375
- syntaxmatrix/dataset_preprocessing.py +218 -0
- syntaxmatrix/db.py +92 -95
- syntaxmatrix/display.py +95 -121
- syntaxmatrix/generate_page.py +634 -0
- syntaxmatrix/gpt_models_latest.py +46 -0
- syntaxmatrix/history_store.py +26 -29
- syntaxmatrix/kernel_manager.py +96 -17
- syntaxmatrix/llm_store.py +1 -1
- syntaxmatrix/plottings.py +6 -0
- syntaxmatrix/profiles.py +64 -8
- syntaxmatrix/project_root.py +55 -43
- syntaxmatrix/routes.py +5072 -1398
- syntaxmatrix/session.py +19 -0
- syntaxmatrix/settings/logging.py +40 -0
- syntaxmatrix/settings/model_map.py +300 -33
- syntaxmatrix/settings/prompts.py +273 -62
- syntaxmatrix/settings/string_navbar.py +3 -3
- syntaxmatrix/static/docs.md +272 -0
- syntaxmatrix/static/icons/favicon.png +0 -0
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- syntaxmatrix/templates/dashboard.html +608 -147
- syntaxmatrix/templates/docs.html +71 -0
- syntaxmatrix/templates/error.html +2 -3
- syntaxmatrix/templates/login.html +1 -0
- syntaxmatrix/templates/register.html +1 -0
- syntaxmatrix/ui_modes.py +14 -0
- syntaxmatrix/utils.py +2482 -159
- syntaxmatrix/vectorizer.py +16 -12
- {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/METADATA +20 -17
- syntaxmatrix-2.5.5.4.dist-info/RECORD +68 -0
- syntaxmatrix/model_templates.py +0 -30
- syntaxmatrix/static/icons/favicon.ico +0 -0
- syntaxmatrix-1.4.6.dist-info/RECORD +0 -54
- {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/WHEEL +0 -0
- {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/top_level.txt +0 -0
syntaxmatrix/auth.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
1
2
|
import os
|
|
2
3
|
import sqlite3
|
|
3
4
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
@@ -24,8 +25,10 @@ def _get_conn():
|
|
|
24
25
|
conn.execute("PRAGMA foreign_keys = ON;")
|
|
25
26
|
return conn
|
|
26
27
|
|
|
28
|
+
|
|
27
29
|
def init_auth_db():
|
|
28
30
|
"""Create users table and seed the superadmin from env vars."""
|
|
31
|
+
# --- Users table ---
|
|
29
32
|
conn = _get_conn()
|
|
30
33
|
conn.execute("""
|
|
31
34
|
CREATE TABLE IF NOT EXISTS users (
|
|
@@ -38,6 +41,33 @@ def init_auth_db():
|
|
|
38
41
|
);
|
|
39
42
|
""")
|
|
40
43
|
|
|
44
|
+
# --- Roles table ---
|
|
45
|
+
conn.execute("""
|
|
46
|
+
CREATE TABLE IF NOT EXISTS roles (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
name TEXT UNIQUE NOT NULL,
|
|
49
|
+
description TEXT DEFAULT '',
|
|
50
|
+
is_employee INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
is_superadmin INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
54
|
+
);
|
|
55
|
+
""")
|
|
56
|
+
|
|
57
|
+
# --- Role change audit table ---
|
|
58
|
+
conn.execute("""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS role_audit (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
actor_id INTEGER,
|
|
62
|
+
actor_label TEXT,
|
|
63
|
+
target_id INTEGER NOT NULL,
|
|
64
|
+
target_label TEXT,
|
|
65
|
+
from_role TEXT NOT NULL,
|
|
66
|
+
to_role TEXT NOT NULL,
|
|
67
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
68
|
+
);
|
|
69
|
+
""")
|
|
70
|
+
|
|
41
71
|
row = conn.execute(
|
|
42
72
|
"SELECT 1 FROM users WHERE username = 'ceo' AND role='superadmin'"
|
|
43
73
|
).fetchone()
|
|
@@ -45,9 +75,9 @@ def init_auth_db():
|
|
|
45
75
|
if not row:
|
|
46
76
|
# (a) generate or read the one-off password file
|
|
47
77
|
fw_data_dir = _CLIENT_DIR # returns Path to <project>/.syntaxmatrix
|
|
48
|
-
cred_file = fw_data_dir / "
|
|
78
|
+
cred_file = fw_data_dir / "superadmin_credentials.txt"
|
|
49
79
|
|
|
50
|
-
superadmin_email = "ceo@syntaxmatrix.
|
|
80
|
+
superadmin_email = "ceo@syntaxmatrix.ceo"
|
|
51
81
|
superadmin_username = "ceo"
|
|
52
82
|
|
|
53
83
|
if cred_file.exists():
|
|
@@ -69,10 +99,193 @@ def init_auth_db():
|
|
|
69
99
|
"VALUES (?, ?, ?, ?)",
|
|
70
100
|
(superadmin_email, superadmin_username, pw_hash, "superadmin")
|
|
71
101
|
)
|
|
102
|
+
|
|
103
|
+
# --- Roles table + seed ---
|
|
104
|
+
# canonical roles
|
|
105
|
+
seed_roles = [
|
|
106
|
+
("user", "Default registration", 0, 0, 0),
|
|
107
|
+
("employee", "Employee", 1, 0, 0),
|
|
108
|
+
("admin", "Administrator", 1, 1, 0),
|
|
109
|
+
("superadmin", "Super administrator", 1, 1, 1),
|
|
110
|
+
]
|
|
111
|
+
for r in seed_roles:
|
|
112
|
+
conn.execute("""
|
|
113
|
+
INSERT OR IGNORE INTO roles (name, description, is_employee, is_admin, is_superadmin)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?)
|
|
115
|
+
""", r)
|
|
116
|
+
|
|
72
117
|
conn.commit()
|
|
73
118
|
conn.close()
|
|
74
119
|
|
|
75
120
|
|
|
121
|
+
def list_roles():
|
|
122
|
+
conn = _get_conn()
|
|
123
|
+
rows = conn.execute("""
|
|
124
|
+
SELECT id, name, description, is_employee, is_admin, is_superadmin, created_at
|
|
125
|
+
FROM roles
|
|
126
|
+
ORDER BY
|
|
127
|
+
CASE name
|
|
128
|
+
WHEN 'superadmin' THEN 0
|
|
129
|
+
WHEN 'admin' THEN 1
|
|
130
|
+
WHEN 'employee' THEN 2
|
|
131
|
+
WHEN 'user' THEN 3
|
|
132
|
+
ELSE 4
|
|
133
|
+
END, name
|
|
134
|
+
""").fetchall()
|
|
135
|
+
conn.close()
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"id": r[0], "name": r[1], "description": r[2],
|
|
139
|
+
"is_employee": bool(r[3]), "is_admin": bool(r[4]),
|
|
140
|
+
"is_superadmin": bool(r[5]), "created_at": r[6]
|
|
141
|
+
} for r in rows
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
RESERVED_ROLES = {"user", "employee", "admin", "superadmin"}
|
|
146
|
+
|
|
147
|
+
def create_role(name: str, description: str = "", *, is_employee: bool = False, is_admin: bool = False) -> bool:
|
|
148
|
+
name = (name or "").strip().lower()
|
|
149
|
+
if not name or name in RESERVED_ROLES:
|
|
150
|
+
return False
|
|
151
|
+
if is_employee and is_admin:
|
|
152
|
+
# keep the hierarchy simple: a role can't be both employee and admin
|
|
153
|
+
return False
|
|
154
|
+
conn = _get_conn()
|
|
155
|
+
try:
|
|
156
|
+
conn.execute(
|
|
157
|
+
"INSERT INTO roles (name, description, is_employee, is_admin, is_superadmin) VALUES (?, ?, ?, ?, 0)",
|
|
158
|
+
(name, description or "", 1 if is_employee else 0, 1 if is_admin else 0)
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
161
|
+
return True
|
|
162
|
+
except sqlite3.IntegrityError:
|
|
163
|
+
return False
|
|
164
|
+
finally:
|
|
165
|
+
conn.close()
|
|
166
|
+
|
|
167
|
+
def delete_role(role_name: str):
|
|
168
|
+
"""
|
|
169
|
+
Delete a custom role if:
|
|
170
|
+
- it is not reserved (superadmin, admin, employee, user)
|
|
171
|
+
- no users are currently assigned to it
|
|
172
|
+
Returns (ok, error_message_or_None)
|
|
173
|
+
"""
|
|
174
|
+
import sqlite3
|
|
175
|
+
from . import db as _db
|
|
176
|
+
|
|
177
|
+
if not role_name:
|
|
178
|
+
return (False, "missing role_name")
|
|
179
|
+
|
|
180
|
+
name_l = role_name.strip().lower()
|
|
181
|
+
if name_l in {"superadmin", "admin", "employee", "user"}:
|
|
182
|
+
return (False, "reserved role")
|
|
183
|
+
|
|
184
|
+
conn = _get_conn()
|
|
185
|
+
conn.row_factory = sqlite3.Row
|
|
186
|
+
cur = conn.cursor()
|
|
187
|
+
|
|
188
|
+
# ensure role exists
|
|
189
|
+
cur.execute("SELECT id FROM roles WHERE lower(name)=?", (name_l,))
|
|
190
|
+
row = cur.fetchone()
|
|
191
|
+
if not row:
|
|
192
|
+
conn.close()
|
|
193
|
+
return (False, "not found")
|
|
194
|
+
|
|
195
|
+
# block if any user has this role
|
|
196
|
+
cur.execute("SELECT COUNT(*) AS c FROM users WHERE lower(role)=?", (name_l,))
|
|
197
|
+
in_use = (cur.fetchone() or {"c": 0})["c"]
|
|
198
|
+
if in_use:
|
|
199
|
+
conn.close()
|
|
200
|
+
return (False, "role in use by users")
|
|
201
|
+
|
|
202
|
+
cur.execute("DELETE FROM roles WHERE lower(name)=?", (name_l,))
|
|
203
|
+
conn.commit()
|
|
204
|
+
conn.close()
|
|
205
|
+
return (True, None)
|
|
206
|
+
|
|
207
|
+
#############################################################
|
|
208
|
+
# --- Minimal helpers for the Admin Users card ---
|
|
209
|
+
def list_users():
|
|
210
|
+
"""Return users for admin listing."""
|
|
211
|
+
conn = _get_conn()
|
|
212
|
+
rows = conn.execute("""
|
|
213
|
+
SELECT id, email, username, role, created_at
|
|
214
|
+
FROM users
|
|
215
|
+
ORDER BY created_at DESC
|
|
216
|
+
""").fetchall()
|
|
217
|
+
conn.close()
|
|
218
|
+
out = []
|
|
219
|
+
for r in rows:
|
|
220
|
+
out.append({
|
|
221
|
+
"id": r[0],
|
|
222
|
+
"email": r[1],
|
|
223
|
+
"username": r[2],
|
|
224
|
+
"role": (r[3] or "user"),
|
|
225
|
+
"created_at": r[4],
|
|
226
|
+
})
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _can_assign_role(actor_role: str, from_role: str, to_role: str) -> bool:
|
|
231
|
+
"""Business rules:
|
|
232
|
+
- Admin can only do user -> employee
|
|
233
|
+
- Only superadmin can promote employee -> admin (or user -> admin)
|
|
234
|
+
- Never demote a superadmin
|
|
235
|
+
"""
|
|
236
|
+
actor_role = (actor_role or "").lower()
|
|
237
|
+
from_role = (from_role or "").lower()
|
|
238
|
+
to_role = (to_role or "").lower()
|
|
239
|
+
|
|
240
|
+
if actor_role == "superadmin":
|
|
241
|
+
if from_role == "superadmin" and to_role != "superadmin":
|
|
242
|
+
return False
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
if actor_role == "admin":
|
|
246
|
+
# admins can: user -> employee OR employee -> user
|
|
247
|
+
return (from_role == "user" and to_role == "employee") or \
|
|
248
|
+
(from_role == "employee" and to_role == "user")
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def set_user_role(actor_role: str, user_id: int, role_name: str) -> bool:
|
|
254
|
+
"""Assign a role to a user, enforcing the rules above."""
|
|
255
|
+
role_name = (role_name or "").strip().lower()
|
|
256
|
+
if not role_name:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
conn = _get_conn()
|
|
260
|
+
try:
|
|
261
|
+
# ensure target role exists
|
|
262
|
+
exists = conn.execute(
|
|
263
|
+
"SELECT 1 FROM roles WHERE name = ?", (role_name,)
|
|
264
|
+
).fetchone()
|
|
265
|
+
if not exists:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
# current role of target user
|
|
269
|
+
row = conn.execute(
|
|
270
|
+
"SELECT role FROM users WHERE id = ?", (user_id,)
|
|
271
|
+
).fetchone()
|
|
272
|
+
if not row:
|
|
273
|
+
return False
|
|
274
|
+
from_role = (row[0] or "user")
|
|
275
|
+
|
|
276
|
+
if not _can_assign_role(actor_role, from_role, role_name):
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
conn.execute(
|
|
280
|
+
"UPDATE users SET role = ? WHERE id = ?",
|
|
281
|
+
(role_name, user_id)
|
|
282
|
+
)
|
|
283
|
+
conn.commit()
|
|
284
|
+
return True
|
|
285
|
+
finally:
|
|
286
|
+
conn.close()
|
|
287
|
+
|
|
288
|
+
|
|
76
289
|
def register_user(email:str, username:str, password:str, role:str = "user") -> bool:
|
|
77
290
|
"""Return True if registration succeeded, False if username taken."""
|
|
78
291
|
hashed = generate_password_hash(password)
|
|
@@ -111,17 +324,17 @@ def login_required(f):
|
|
|
111
324
|
return f(*args, **kwargs)
|
|
112
325
|
return decorated
|
|
113
326
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
327
|
+
|
|
328
|
+
def admin_required(view):
|
|
329
|
+
@wraps(view)
|
|
330
|
+
def wrapper(*args, **kwargs):
|
|
331
|
+
role = (session.get("role") or "").lower()
|
|
332
|
+
if not has_admin_privileges(role):
|
|
333
|
+
flash("You do not have permission to access that page.", "error")
|
|
334
|
+
return redirect(url_for("login"))
|
|
335
|
+
return view(*args, **kwargs)
|
|
336
|
+
return wrapper
|
|
337
|
+
|
|
125
338
|
|
|
126
339
|
def superadmin_required(f):
|
|
127
340
|
@wraps(f)
|
|
@@ -134,4 +347,85 @@ def superadmin_required(f):
|
|
|
134
347
|
return redirect(url_for("dashboard"))
|
|
135
348
|
return f(*args, **kwargs)
|
|
136
349
|
return decorated
|
|
137
|
-
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def has_admin_privileges(role_name: str) -> bool:
|
|
353
|
+
role = (role_name or "").strip().lower()
|
|
354
|
+
if role == "superadmin":
|
|
355
|
+
return True
|
|
356
|
+
conn = _get_conn()
|
|
357
|
+
row = conn.execute("SELECT is_admin FROM roles WHERE name = ?", (role,)).fetchone()
|
|
358
|
+
conn.close()
|
|
359
|
+
return bool(row and row[0])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_user_basic(user_id: int):
|
|
363
|
+
if not user_id:
|
|
364
|
+
return None
|
|
365
|
+
conn = _get_conn()
|
|
366
|
+
row = conn.execute(
|
|
367
|
+
"SELECT id, email, username, role FROM users WHERE id = ?",
|
|
368
|
+
(user_id,)
|
|
369
|
+
).fetchone()
|
|
370
|
+
conn.close()
|
|
371
|
+
if not row:
|
|
372
|
+
return None
|
|
373
|
+
return {"id": row[0], "email": row[1], "username": row[2], "role": (row[3] or "user")}
|
|
374
|
+
|
|
375
|
+
def add_role_audit(actor_id: int, actor_label: str, target_id: int, target_label: str, from_role: str, to_role: str):
|
|
376
|
+
conn = _get_conn()
|
|
377
|
+
conn.execute(
|
|
378
|
+
"INSERT INTO role_audit (actor_id, actor_label, target_id, target_label, from_role, to_role) VALUES (?,?,?,?,?,?)",
|
|
379
|
+
(actor_id, actor_label or "", target_id, target_label or "", (from_role or "user"), (to_role or "user"))
|
|
380
|
+
)
|
|
381
|
+
conn.commit()
|
|
382
|
+
conn.close()
|
|
383
|
+
|
|
384
|
+
def list_role_audit(limit: int = 50):
|
|
385
|
+
conn = _get_conn()
|
|
386
|
+
rows = conn.execute(
|
|
387
|
+
"SELECT actor_label, target_label, from_role, to_role, created_at FROM role_audit ORDER BY id DESC LIMIT ?",
|
|
388
|
+
(int(limit),)
|
|
389
|
+
).fetchall()
|
|
390
|
+
conn.close()
|
|
391
|
+
return [
|
|
392
|
+
{"actor_label": r[0], "target_label": r[1], "from_role": r[2], "to_role": r[3], "created_at": r[4]}
|
|
393
|
+
for r in rows
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def delete_user(actor_id: int, target_id: int) -> bool:
|
|
398
|
+
"""Delete a user account. Blocks deleting superadmin and self."""
|
|
399
|
+
if not target_id or (actor_id and actor_id == target_id):
|
|
400
|
+
return False
|
|
401
|
+
conn = _get_conn()
|
|
402
|
+
try:
|
|
403
|
+
row = conn.execute("SELECT role FROM users WHERE id = ?", (target_id,)).fetchone()
|
|
404
|
+
if not row:
|
|
405
|
+
return False
|
|
406
|
+
if (row[0] or "user").lower() == "superadmin":
|
|
407
|
+
return False
|
|
408
|
+
conn.execute("DELETE FROM users WHERE id = ?", (target_id,))
|
|
409
|
+
conn.commit()
|
|
410
|
+
return True
|
|
411
|
+
finally:
|
|
412
|
+
conn.close()
|
|
413
|
+
|
|
414
|
+
def clear_role_audit(before_iso: Optional[str] = None) -> int:
|
|
415
|
+
"""
|
|
416
|
+
Delete audit rows. If before_iso is provided (ISO timestamp or 'YYYY-MM-DD'),
|
|
417
|
+
delete only rows older than that. Returns number of rows removed.
|
|
418
|
+
"""
|
|
419
|
+
conn = _get_conn()
|
|
420
|
+
|
|
421
|
+
if before_iso:
|
|
422
|
+
conn.execute(
|
|
423
|
+
"DELETE FROM role_audit WHERE datetime(created_at) < datetime(?)",
|
|
424
|
+
(before_iso,)
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
conn.execute("DELETE FROM role_audit")
|
|
428
|
+
deleted = conn.rowcount if hasattr(conn, "rowcount") else 0
|
|
429
|
+
conn.commit()
|
|
430
|
+
return int(deleted)
|
|
431
|
+
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os, io, re, json, base64
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from syntaxmatrix import profiles as _prof
|
|
7
|
+
from syntaxmatrix.settings.model_map import GPT_MODELS_LATEST
|
|
8
|
+
from syntaxmatrix.gpt_models_latest import extract_output_text as _out, set_args
|
|
9
|
+
from google.genai import types
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Axes/labels/legend (read-only; no plotting changes)
|
|
13
|
+
MPL_PROBE_SNIPPET = r"""
|
|
14
|
+
import json
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
|
|
17
|
+
out=[]
|
|
18
|
+
for num in plt.get_fignums():
|
|
19
|
+
fig = plt.figure(num)
|
|
20
|
+
for ax in fig.get_axes():
|
|
21
|
+
info = {
|
|
22
|
+
"title": (ax.get_title() or "").strip(),
|
|
23
|
+
"x_label": (ax.get_xlabel() or "").strip(),
|
|
24
|
+
"y_label": (ax.get_ylabel() or "").strip(),
|
|
25
|
+
"legend": []
|
|
26
|
+
}
|
|
27
|
+
try:
|
|
28
|
+
leg = ax.get_legend()
|
|
29
|
+
if leg:
|
|
30
|
+
info["legend"] = [t.get_text().strip() for t in leg.get_texts() if t.get_text().strip()]
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
out.append(info)
|
|
34
|
+
print("SMX_VIS_SUMMARY::" + json.dumps(out))
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# 2) Figure images to base64 (tight bbox, high DPI)
|
|
38
|
+
MPL_IMAGE_PROBE_SNIPPET = r"""
|
|
39
|
+
import json, io, base64
|
|
40
|
+
import matplotlib.pyplot as plt
|
|
41
|
+
|
|
42
|
+
payload=[]
|
|
43
|
+
for num in plt.get_fignums():
|
|
44
|
+
fig = plt.figure(num)
|
|
45
|
+
axes=[]
|
|
46
|
+
for ax in fig.get_axes():
|
|
47
|
+
info={"title": (ax.get_title() or "").strip(),
|
|
48
|
+
"x_label": (ax.get_xlabel() or "").strip(),
|
|
49
|
+
"y_label": (ax.get_ylabel() or "").strip(),
|
|
50
|
+
"legend": []}
|
|
51
|
+
try:
|
|
52
|
+
leg = ax.get_legend()
|
|
53
|
+
if leg:
|
|
54
|
+
info["legend"] = [t.get_text().strip() for t in leg.get_texts() if t.get_text().strip()]
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
axes.append(info)
|
|
58
|
+
|
|
59
|
+
b64 = ""
|
|
60
|
+
try:
|
|
61
|
+
buf = io.BytesIO()
|
|
62
|
+
fig.savefig(buf, format="png", dpi=192, bbox_inches="tight", facecolor="white")
|
|
63
|
+
buf.seek(0)
|
|
64
|
+
b64 = base64.b64encode(buf.read()).decode("ascii")
|
|
65
|
+
except Exception:
|
|
66
|
+
b64 = ""
|
|
67
|
+
payload.append({"png_b64": b64, "axes": axes})
|
|
68
|
+
print("SMX_FIGS_B64::" + json.dumps(payload))
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json(obj: Any) -> str:
|
|
73
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), indent=2)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_mpl_probe_output(text_blocks: List[str]) -> List[Dict[str, Any]]:
|
|
77
|
+
joined = "\n".join(text_blocks)
|
|
78
|
+
m = re.search(r"SMX_VIS_SUMMARY::(\[.*\]|\{.*\})", joined, re.DOTALL)
|
|
79
|
+
if not m:
|
|
80
|
+
return []
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(m.group(1))
|
|
83
|
+
return data if isinstance(data, list) else []
|
|
84
|
+
except Exception:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
def parse_image_probe_output(text_blocks: List[str]) -> List[Dict[str, Any]]:
|
|
88
|
+
joined = "\n".join(text_blocks)
|
|
89
|
+
m = re.search(r"SMX_FIGS_B64::(\[.*\])", joined, re.DOTALL)
|
|
90
|
+
if not m:
|
|
91
|
+
return []
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(m.group(1))
|
|
94
|
+
return data if isinstance(data, list) else []
|
|
95
|
+
except Exception:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
# 3) Table headers (from already-rendered HTML) — optional but helps context
|
|
99
|
+
def _strip_tags(s: str) -> str:
|
|
100
|
+
return re.sub(r"<[^>]+>", " ", s).strip()
|
|
101
|
+
|
|
102
|
+
def sniff_tables_from_html(html: str) -> List[Dict[str, Any]]:
|
|
103
|
+
tables=[]
|
|
104
|
+
for tbl in re.findall(r"<table[^>]*class=[\"'][^\"']*smx-table[^\"']*[\"'][^>]*>(.*?)</table>",
|
|
105
|
+
html, re.DOTALL|re.IGNORECASE):
|
|
106
|
+
ths = re.findall(r"<th[^>]*>(.*?)</th>", tbl, re.DOTALL|re.IGNORECASE)
|
|
107
|
+
headers = [_strip_tags(h) for h in ths][:50]
|
|
108
|
+
trs = re.findall(r"<tr[^>]*>", tbl, re.IGNORECASE)
|
|
109
|
+
tables.append({
|
|
110
|
+
"columns": headers,
|
|
111
|
+
"columns_count": len(headers),
|
|
112
|
+
"rows_approx": max(0, len(trs)-1)
|
|
113
|
+
})
|
|
114
|
+
return tables
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_display_summary(question: str,
|
|
118
|
+
mpl_axes: List[Dict[str, Any]],
|
|
119
|
+
html_blocks: List[str]) -> Dict[str, Any]:
|
|
120
|
+
html_joined = "\n".join(str(b) for b in html_blocks)
|
|
121
|
+
tables = sniff_tables_from_html(html_joined)
|
|
122
|
+
|
|
123
|
+
axes_clean=[]
|
|
124
|
+
for ax in mpl_axes:
|
|
125
|
+
axes_clean.append({
|
|
126
|
+
"title": ax.get("title",""),
|
|
127
|
+
"x_label": ax.get("x_label",""),
|
|
128
|
+
"y_label": ax.get("y_label",""),
|
|
129
|
+
"legend": ax.get("legend", []),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"question": (question or "").strip(),
|
|
134
|
+
"axes": axes_clean,
|
|
135
|
+
"tables": tables
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def _context_strings(context: Dict[str, Any]) -> List[str]:
|
|
139
|
+
s = [context.get("question","")]
|
|
140
|
+
for ax in context.get("axes", []) or []:
|
|
141
|
+
s += [ax.get("title",""), ax.get("x_label",""), ax.get("y_label","")]
|
|
142
|
+
s += (ax.get("legend", []) or [])
|
|
143
|
+
for t in context.get("tables", []) or []:
|
|
144
|
+
s += (t.get("columns", []) or [])
|
|
145
|
+
# de-dup
|
|
146
|
+
seen=set(); out=[]
|
|
147
|
+
for it in s:
|
|
148
|
+
it=(it or "").strip()
|
|
149
|
+
if not it: continue
|
|
150
|
+
k=it.lower()
|
|
151
|
+
if k in seen: continue
|
|
152
|
+
seen.add(k); out.append(it)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def phrase_commentary_vision(context: Dict[str, Any], images_b64: List[str]) -> str:
|
|
157
|
+
"""
|
|
158
|
+
Use the project's 'vision2text' profile (profiles.py). If the provider supports images,
|
|
159
|
+
send figures + text; otherwise fall back to a text-only prompt grounded by labels.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
_SYSTEM_VISION = (
|
|
163
|
+
"You are a data analyst. Write a detailed analysis that explains what the "
|
|
164
|
+
"already-rendered visuals mean for the user's question. "
|
|
165
|
+
"You use information visible in the attached figures and the provided context strings (texs, field names, labels). "
|
|
166
|
+
"You interprete the output without preamble."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
_USER_TMPL_VISION = """
|
|
170
|
+
question:
|
|
171
|
+
{q}
|
|
172
|
+
|
|
173
|
+
Visible context strings (titles, axes, legends, headers):
|
|
174
|
+
{ctx}
|
|
175
|
+
|
|
176
|
+
Write a comprehensive conclusion (~250-350 words) as follows:
|
|
177
|
+
- <b>Headline</b>
|
|
178
|
+
2-3 sentence answering the question from an overview of all the output.
|
|
179
|
+
- <b>Evidence</b>
|
|
180
|
+
8-10 bullets referencing the (output-texts/tables/panels/axes/legend groups) seen in the output.
|
|
181
|
+
As you reference the visuals, you should interprete them in a way to show how they answer the question.
|
|
182
|
+
- <b>Limitations</b>
|
|
183
|
+
1 bullet; avoid quoting numbers unless present in context.
|
|
184
|
+
- <b>Next step</b>
|
|
185
|
+
1 bullet.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
visible = _context_strings(context)
|
|
189
|
+
user = _USER_TMPL_VISION.format(
|
|
190
|
+
q=context.get("question",""),
|
|
191
|
+
ctx=json.dumps(visible, ensure_ascii=False, indent=2)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
prof = _prof.get_profile("vision2text") or _prof.get_profile("admin")
|
|
195
|
+
if not prof:
|
|
196
|
+
return (
|
|
197
|
+
"<div class='smx-alert smx-alert-warn'>"
|
|
198
|
+
"No LLM profile is configured for Image2Text. Please, do that in the Admin panel or contact your Administrator."
|
|
199
|
+
"</div>"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
prof['client'] = _prof.get_client(prof)
|
|
203
|
+
_client = prof["client"]
|
|
204
|
+
_provider = prof["provider"].lower()
|
|
205
|
+
_model = prof["model"]
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
#1 Google
|
|
209
|
+
if _provider == "google":
|
|
210
|
+
try:
|
|
211
|
+
input_contents = []
|
|
212
|
+
|
|
213
|
+
# Add text part first
|
|
214
|
+
text_part = {"text": user}
|
|
215
|
+
input_contents.append(text_part)
|
|
216
|
+
|
|
217
|
+
# Add image parts
|
|
218
|
+
for b64 in images_b64:
|
|
219
|
+
if b64:
|
|
220
|
+
image_part = {
|
|
221
|
+
"inline_data": {
|
|
222
|
+
"mime_type": "image/png",
|
|
223
|
+
"data": b64
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
input_contents.append(image_part)
|
|
227
|
+
|
|
228
|
+
response = _client.models.generate_content(
|
|
229
|
+
model=_model,
|
|
230
|
+
contents=input_contents,
|
|
231
|
+
config=types.GenerateContentConfig(
|
|
232
|
+
system_instruction=_SYSTEM_VISION,
|
|
233
|
+
temperature=0.7,
|
|
234
|
+
max_output_tokens=1024,
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
txt = response.text.strip()
|
|
238
|
+
return txt.strip()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
#2 Openai
|
|
243
|
+
elif _provider == "openai" and _model in GPT_MODELS_LATEST:
|
|
244
|
+
try:
|
|
245
|
+
input_contents = []
|
|
246
|
+
|
|
247
|
+
text_part = {"type": "input_text", "text": user}
|
|
248
|
+
input_contents.append(text_part)
|
|
249
|
+
for b64 in images_b64:
|
|
250
|
+
if b64:
|
|
251
|
+
image_part = {
|
|
252
|
+
"type": "input_image",
|
|
253
|
+
"image_url": f"data:image/png;base64,{b64}"
|
|
254
|
+
}
|
|
255
|
+
input_contents.append(image_part)
|
|
256
|
+
|
|
257
|
+
args = set_args(
|
|
258
|
+
model=_model,
|
|
259
|
+
instructions=_SYSTEM_VISION,
|
|
260
|
+
input=[{"role": "user", "content": input_contents}],
|
|
261
|
+
previous_id=None,
|
|
262
|
+
store=False,
|
|
263
|
+
reasoning_effort="low",
|
|
264
|
+
verbosity="medium",
|
|
265
|
+
)
|
|
266
|
+
resp = _client.responses.create(**args)
|
|
267
|
+
txt = _out(resp) or ""
|
|
268
|
+
if txt.strip():
|
|
269
|
+
return txt.strip()
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Anthropic
|
|
274
|
+
elif _provider == "anthropic":
|
|
275
|
+
try:
|
|
276
|
+
input_contents = []
|
|
277
|
+
|
|
278
|
+
text_part = {"type":"text","text": user}
|
|
279
|
+
input_contents.append(text_part)
|
|
280
|
+
|
|
281
|
+
for b64 in images_b64:
|
|
282
|
+
if b64:
|
|
283
|
+
image_part = {
|
|
284
|
+
"type":"image_url",
|
|
285
|
+
"image_url":{"url": f"data:image/png;base64,{b64}"}
|
|
286
|
+
}
|
|
287
|
+
input_contents.append(image_part)
|
|
288
|
+
|
|
289
|
+
response = _client.messages.create(
|
|
290
|
+
model=_model,
|
|
291
|
+
max_tokens=1024,
|
|
292
|
+
system=_SYSTEM_VISION,
|
|
293
|
+
messages=[{"role": "user", "content":input_contents}],
|
|
294
|
+
stream=False,
|
|
295
|
+
)
|
|
296
|
+
return response.content[0].text.strip()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
# OpenAI SDK
|
|
301
|
+
else:
|
|
302
|
+
try:
|
|
303
|
+
input_contents = [{"type":"text","text": user}]
|
|
304
|
+
for b64 in images_b64:
|
|
305
|
+
if b64:
|
|
306
|
+
input_contents.append({"type":"image_url","image_url":{"url": f"data:image/png;base64,{b64}"}})
|
|
307
|
+
resp = _client.chat.completions.create(
|
|
308
|
+
model=_model,
|
|
309
|
+
temperature=1,
|
|
310
|
+
messages=[
|
|
311
|
+
{"role":"system","content":_SYSTEM_VISION},
|
|
312
|
+
{"role":"user","content":input_contents},
|
|
313
|
+
],
|
|
314
|
+
max_tokens=1024,
|
|
315
|
+
)
|
|
316
|
+
return (resp.choices[0].message.content or "").strip()
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
except Exception:
|
|
320
|
+
return "Insufficient context to comment usefully."
|
|
321
|
+
|
|
322
|
+
def wrap_html(card_text: str) -> str:
|
|
323
|
+
return f"""
|
|
324
|
+
<div class="smx-commentary-card" style="margin-top:1rem;padding:1rem;border:1px solid #e5e7eb;border-radius:0.75rem;background:#fafafa">
|
|
325
|
+
<div style="font-weight:600;margin-bottom:0.5rem;">smxAI Feedback</div>
|
|
326
|
+
<div class="prose" style="white-space:pre-wrap;line-height:1.45">{card_text}</div>
|
|
327
|
+
</div>
|
|
328
|
+
""".strip()
|