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.
Files changed (45) hide show
  1. syntaxmatrix/__init__.py +13 -8
  2. syntaxmatrix/agentic/__init__.py +0 -0
  3. syntaxmatrix/agentic/agent_tools.py +24 -0
  4. syntaxmatrix/agentic/agents.py +810 -0
  5. syntaxmatrix/agentic/code_tools_registry.py +37 -0
  6. syntaxmatrix/agentic/model_templates.py +1790 -0
  7. syntaxmatrix/auth.py +308 -14
  8. syntaxmatrix/commentary.py +328 -0
  9. syntaxmatrix/core.py +993 -375
  10. syntaxmatrix/dataset_preprocessing.py +218 -0
  11. syntaxmatrix/db.py +92 -95
  12. syntaxmatrix/display.py +95 -121
  13. syntaxmatrix/generate_page.py +634 -0
  14. syntaxmatrix/gpt_models_latest.py +46 -0
  15. syntaxmatrix/history_store.py +26 -29
  16. syntaxmatrix/kernel_manager.py +96 -17
  17. syntaxmatrix/llm_store.py +1 -1
  18. syntaxmatrix/plottings.py +6 -0
  19. syntaxmatrix/profiles.py +64 -8
  20. syntaxmatrix/project_root.py +55 -43
  21. syntaxmatrix/routes.py +5072 -1398
  22. syntaxmatrix/session.py +19 -0
  23. syntaxmatrix/settings/logging.py +40 -0
  24. syntaxmatrix/settings/model_map.py +300 -33
  25. syntaxmatrix/settings/prompts.py +273 -62
  26. syntaxmatrix/settings/string_navbar.py +3 -3
  27. syntaxmatrix/static/docs.md +272 -0
  28. syntaxmatrix/static/icons/favicon.png +0 -0
  29. syntaxmatrix/static/icons/hero_bg.jpg +0 -0
  30. syntaxmatrix/templates/dashboard.html +608 -147
  31. syntaxmatrix/templates/docs.html +71 -0
  32. syntaxmatrix/templates/error.html +2 -3
  33. syntaxmatrix/templates/login.html +1 -0
  34. syntaxmatrix/templates/register.html +1 -0
  35. syntaxmatrix/ui_modes.py +14 -0
  36. syntaxmatrix/utils.py +2482 -159
  37. syntaxmatrix/vectorizer.py +16 -12
  38. {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/METADATA +20 -17
  39. syntaxmatrix-2.5.5.4.dist-info/RECORD +68 -0
  40. syntaxmatrix/model_templates.py +0 -30
  41. syntaxmatrix/static/icons/favicon.ico +0 -0
  42. syntaxmatrix-1.4.6.dist-info/RECORD +0 -54
  43. {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/WHEEL +0 -0
  44. {syntaxmatrix-1.4.6.dist-info → syntaxmatrix-2.5.5.4.dist-info}/licenses/LICENSE.txt +0 -0
  45. {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 / "superadmin_creds.txt"
78
+ cred_file = fw_data_dir / "superadmin_credentials.txt"
49
79
 
50
- superadmin_email = "ceo@syntaxmatrix.com"
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
- def admin_required(f):
115
- @wraps(f)
116
- def decorated(*args, **kwargs):
117
- if not session.get("user_id"):
118
- flash("Please log in to access this page.")
119
- return redirect(url_for("login", next=request.path))
120
- if session.get("role") not in ("admin", "superadmin"):
121
- flash("You do not have permission to access this page.")
122
- return redirect(url_for("dashboard"))
123
- return f(*args, **kwargs)
124
- return decorated
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()