qalita 2.3.1__py3-none-any.whl → 2.5.2__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 (95) hide show
  1. qalita/__main__.py +213 -9
  2. qalita/commands/{agent.py → worker.py} +89 -89
  3. qalita/internal/config.py +26 -19
  4. qalita/internal/utils.py +1 -1
  5. qalita/web/app.py +97 -14
  6. qalita/web/blueprints/context.py +13 -60
  7. qalita/web/blueprints/dashboard.py +35 -76
  8. qalita/web/blueprints/helpers.py +154 -63
  9. qalita/web/blueprints/sources.py +29 -61
  10. qalita/web/blueprints/{agents.py → workers.py} +108 -185
  11. qalita-2.5.2.dist-info/METADATA +66 -0
  12. qalita-2.5.2.dist-info/RECORD +24 -0
  13. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/WHEEL +1 -1
  14. qalita-2.5.2.dist-info/entry_points.txt +2 -0
  15. qalita/web/blueprints/studio.py +0 -1255
  16. qalita/web/public/chatgpt.svg +0 -3
  17. qalita/web/public/claude.png +0 -0
  18. qalita/web/public/favicon.ico +0 -0
  19. qalita/web/public/gemini.png +0 -0
  20. qalita/web/public/logo-no-slogan.png +0 -0
  21. qalita/web/public/logo-white-no-slogan.svg +0 -11
  22. qalita/web/public/mistral.svg +0 -1
  23. qalita/web/public/noise.webp +0 -0
  24. qalita/web/public/ollama.png +0 -0
  25. qalita/web/public/platform.png +0 -0
  26. qalita/web/public/sources-logos/alloy-db.png +0 -0
  27. qalita/web/public/sources-logos/amazon-athena.png +0 -0
  28. qalita/web/public/sources-logos/amazon-rds.png +0 -0
  29. qalita/web/public/sources-logos/api.svg +0 -2
  30. qalita/web/public/sources-logos/avro.svg +0 -20
  31. qalita/web/public/sources-logos/azure-database-mysql.png +0 -0
  32. qalita/web/public/sources-logos/azure-database-postgresql.png +0 -0
  33. qalita/web/public/sources-logos/azure-sql-database.png +0 -0
  34. qalita/web/public/sources-logos/azure-sql-managed-instance.png +0 -0
  35. qalita/web/public/sources-logos/azure-synapse-analytics.png +0 -0
  36. qalita/web/public/sources-logos/azure_blob.svg +0 -1
  37. qalita/web/public/sources-logos/bigquery.png +0 -0
  38. qalita/web/public/sources-logos/cassandra.svg +0 -254
  39. qalita/web/public/sources-logos/clickhouse.png +0 -0
  40. qalita/web/public/sources-logos/cloud-sql.png +0 -0
  41. qalita/web/public/sources-logos/cockroach-db.png +0 -0
  42. qalita/web/public/sources-logos/csv.svg +0 -1
  43. qalita/web/public/sources-logos/database.svg +0 -3
  44. qalita/web/public/sources-logos/databricks.png +0 -0
  45. qalita/web/public/sources-logos/duckdb.png +0 -0
  46. qalita/web/public/sources-logos/elasticsearch.svg +0 -1
  47. qalita/web/public/sources-logos/excel.svg +0 -1
  48. qalita/web/public/sources-logos/file.svg +0 -1
  49. qalita/web/public/sources-logos/folder.svg +0 -6
  50. qalita/web/public/sources-logos/gcs.png +0 -0
  51. qalita/web/public/sources-logos/hdfs.svg +0 -1
  52. qalita/web/public/sources-logos/ibm-db2.png +0 -0
  53. qalita/web/public/sources-logos/json.png +0 -0
  54. qalita/web/public/sources-logos/maria-db.png +0 -0
  55. qalita/web/public/sources-logos/mongodb.svg +0 -1
  56. qalita/web/public/sources-logos/mssql.svg +0 -1
  57. qalita/web/public/sources-logos/mysql.svg +0 -7
  58. qalita/web/public/sources-logos/oracle.svg +0 -4
  59. qalita/web/public/sources-logos/parquet.svg +0 -16
  60. qalita/web/public/sources-logos/picture.png +0 -0
  61. qalita/web/public/sources-logos/postgresql.svg +0 -22
  62. qalita/web/public/sources-logos/questdb.png +0 -0
  63. qalita/web/public/sources-logos/redshift.png +0 -0
  64. qalita/web/public/sources-logos/s3.svg +0 -34
  65. qalita/web/public/sources-logos/sap-hana.png +0 -0
  66. qalita/web/public/sources-logos/sftp.png +0 -0
  67. qalita/web/public/sources-logos/single-store.png +0 -0
  68. qalita/web/public/sources-logos/snowflake.png +0 -0
  69. qalita/web/public/sources-logos/sqlite.svg +0 -104
  70. qalita/web/public/sources-logos/sqlserver.png +0 -0
  71. qalita/web/public/sources-logos/starburst.png +0 -0
  72. qalita/web/public/sources-logos/stream.png +0 -0
  73. qalita/web/public/sources-logos/teradata.png +0 -0
  74. qalita/web/public/sources-logos/timescale.png +0 -0
  75. qalita/web/public/sources-logos/xls.svg +0 -1
  76. qalita/web/public/sources-logos/xlsx.svg +0 -1
  77. qalita/web/public/sources-logos/yugabyte-db.png +0 -0
  78. qalita/web/public/studio-logo.svg +0 -10
  79. qalita/web/public/studio.css +0 -304
  80. qalita/web/public/studio.png +0 -0
  81. qalita/web/public/styles.css +0 -682
  82. qalita/web/templates/dashboard.html +0 -373
  83. qalita/web/templates/navbar.html +0 -40
  84. qalita/web/templates/sources/added.html +0 -57
  85. qalita/web/templates/sources/edit.html +0 -411
  86. qalita/web/templates/sources/select-source.html +0 -128
  87. qalita/web/templates/studio/agent-panel.html +0 -769
  88. qalita/web/templates/studio/context-panel.html +0 -300
  89. qalita/web/templates/studio/index.html +0 -79
  90. qalita/web/templates/studio/navbar.html +0 -14
  91. qalita/web/templates/studio/view-panel.html +0 -529
  92. qalita-2.3.1.dist-info/METADATA +0 -58
  93. qalita-2.3.1.dist-info/RECORD +0 -101
  94. qalita-2.3.1.dist-info/entry_points.txt +0 -3
  95. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,1255 +0,0 @@
1
- """
2
- # QALITA (c) COPYRIGHT 2025 - ALL RIGHTS RESERVED -
3
- """
4
-
5
- import os
6
- import json
7
- import yaml
8
- import requests
9
- from datetime import datetime
10
- from flask import Blueprint, render_template, jsonify, request, current_app, Response
11
- from flask import stream_with_context
12
-
13
-
14
- bp = Blueprint("studio", __name__)
15
-
16
-
17
- @bp.get("/")
18
- def studio_home():
19
- return render_template("studio/index.html")
20
-
21
-
22
- # ---- Config management ----
23
-
24
-
25
- def _qalita_home():
26
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
27
- try:
28
- return cfg.qalita_home # type: ignore[attr-defined]
29
- except Exception:
30
- return os.path.expanduser("~/.qalita")
31
-
32
-
33
- def _studio_config_path() -> str:
34
- root = _qalita_home()
35
- try:
36
- os.makedirs(root, exist_ok=True)
37
- except Exception:
38
- pass
39
- return os.path.join(root, ".studio")
40
-
41
-
42
- def _qalita_home() -> str:
43
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
44
- try:
45
- return getattr(cfg, "qalita_home")
46
- except Exception:
47
- return os.path.expanduser("~/.qalita")
48
-
49
-
50
- def _read_qalita_conf() -> dict:
51
- try:
52
- path = os.path.join(_qalita_home(), "sources-conf.yaml")
53
- if not os.path.isfile(path):
54
- return {}
55
- with open(path, "r", encoding="utf-8") as f:
56
- data = yaml.safe_load(f) or {}
57
- return data if isinstance(data, dict) else {}
58
- except Exception:
59
- return {}
60
-
61
-
62
- def _find_source_by_id(conf: dict, source_id: str) -> dict | None:
63
- try:
64
- items = conf.get("sources") if isinstance(conf.get("sources"), list) else []
65
- for s in items:
66
- if isinstance(s, dict) and str(s.get("id", "")) == str(source_id):
67
- return s
68
- except Exception:
69
- return None
70
- return None
71
-
72
-
73
- def _redact_sensitive(obj: dict) -> dict:
74
- try:
75
- SENSITIVE = {"password", "secret", "token", "access_key", "secret_key", "connection_string", "credentials", "api_key"}
76
- def scrub(v):
77
- if isinstance(v, dict):
78
- return {k: ("***" if k.lower() in SENSITIVE else scrub(v2)) for k, v2 in v.items()}
79
- if isinstance(v, list):
80
- return [scrub(it) for it in v]
81
- return v
82
- return scrub(dict(obj)) if isinstance(obj, dict) else {}
83
- except Exception:
84
- return {}
85
-
86
-
87
- def _augment_prompt_with_context(prompt: str, issue_id: str | None, source_id: str | None, issue_details: dict | None = None, source_details: dict | None = None) -> str:
88
- base = prompt or ""
89
- meta_parts: list[str] = []
90
- if issue_id:
91
- meta_parts.append(f"Issue: {issue_id}")
92
- if source_id:
93
- meta_parts.append(f"Source: {source_id}")
94
- # Attach compact JSON of issue details if present
95
- if issue_details:
96
- try:
97
- snip = json.dumps(issue_details, ensure_ascii=False)[:800]
98
- meta_parts.append(f"IssueDetails: {snip}")
99
- except Exception:
100
- pass
101
- if source_details:
102
- try:
103
- red = _redact_sensitive(source_details)
104
- snip = json.dumps(red, ensure_ascii=False)[:800]
105
- meta_parts.append(f"SourceDetails: {snip}")
106
- except Exception:
107
- pass
108
- if not meta_parts:
109
- return base
110
- meta = "\n\n[Context]\n" + " | ".join(meta_parts) + "\n" # lightweight hint
111
- return meta + base
112
-
113
-
114
- def _studio_conv_dir() -> str:
115
- """Return the conversations directory, ensuring it exists."""
116
- root = _qalita_home()
117
- conv_dir = os.path.join(root, "studio_conversations")
118
- try:
119
- os.makedirs(conv_dir, exist_ok=True)
120
- except Exception:
121
- pass
122
- return conv_dir
123
-
124
-
125
- def _safe_conv_id(raw: str) -> str:
126
- """Sanitize a conversation id to be filesystem-safe."""
127
- s = (raw or "").strip()
128
- if not s:
129
- s = datetime.utcnow().strftime("conv_%Y%m%d_%H%M%S")
130
- # allow alnum, dash, underscore only
131
- out = []
132
- for ch in s:
133
- if ch.isalnum() or ch in ("-", "_"):
134
- out.append(ch)
135
- s2 = "".join(out)
136
- return s2 or datetime.utcnow().strftime("conv_%Y%m%d_%H%M%S")
137
-
138
-
139
- def _studio_conv_file_for(conv_id: str) -> str:
140
- conv_dir = _studio_conv_dir()
141
- safe_id = _safe_conv_id(conv_id)
142
- return os.path.join(conv_dir, f"{safe_id}.jsonl")
143
-
144
-
145
- def _studio_conv_write(conv_id: str, record: dict) -> None:
146
- """Append one JSONL record to the studio conversations log.
147
-
148
- Errors are swallowed to avoid impacting the main request flow.
149
- """
150
- try:
151
- path = _studio_conv_file_for(conv_id)
152
- record = dict(record or {})
153
- if "ts" not in record:
154
- record["ts"] = datetime.utcnow().isoformat() + "Z"
155
- with open(path, "a", encoding="utf-8") as f:
156
- f.write(json.dumps(record, ensure_ascii=False))
157
- f.write("\n")
158
- except Exception:
159
- pass
160
-
161
-
162
- @bp.get("/conversations")
163
- def conversations_list():
164
- """List available conversations (one file per conversation)."""
165
- conv_dir = _studio_conv_dir()
166
- items: list[dict] = []
167
- try:
168
- for name in os.listdir(conv_dir):
169
- if not name.endswith(".jsonl"):
170
- continue
171
- path = os.path.join(conv_dir, name)
172
- try:
173
- st = os.stat(path)
174
- # count lines may be expensive, do bounded scan
175
- count = 0
176
- with open(path, "r", encoding="utf-8") as f:
177
- for _ in f:
178
- count += 1
179
- if count > 10000:
180
- break
181
- items.append(
182
- {
183
- "id": name[:-6],
184
- "file": name,
185
- "size": st.st_size,
186
- "mtime": datetime.utcfromtimestamp(st.st_mtime).isoformat()
187
- + "Z",
188
- "lines": count,
189
- }
190
- )
191
- except Exception:
192
- continue
193
- # Sort by mtime desc
194
- items.sort(key=lambda x: x.get("mtime", ""), reverse=True)
195
- except Exception:
196
- items = []
197
- return jsonify({"ok": True, "items": items})
198
-
199
-
200
- @bp.get("/conversation")
201
- def conversation_get():
202
- """Return a conversation's messages from its id."""
203
- conv_id = _safe_conv_id(request.args.get("id", ""))
204
- if not conv_id:
205
- return jsonify({"ok": False, "message": "Missing id"}), 400
206
- path = _studio_conv_file_for(conv_id)
207
- if not os.path.isfile(path):
208
- return jsonify({"ok": False, "message": "Not found"}), 404
209
- messages: list[dict] = []
210
- try:
211
- with open(path, "r", encoding="utf-8") as f:
212
- for raw in f:
213
- line = (raw or "").strip()
214
- if not line:
215
- continue
216
- try:
217
- obj = json.loads(line)
218
- except Exception:
219
- continue
220
- # New format uses role/text
221
- if isinstance(obj, dict) and obj.get("role") and obj.get("text") is not None:
222
- messages.append(
223
- {
224
- "role": obj.get("role"),
225
- "text": obj.get("text"),
226
- "ts": obj.get("ts"),
227
- }
228
- )
229
- # Back-compat: prompt/response record
230
- elif isinstance(obj, dict) and obj.get("prompt") is not None:
231
- messages.append({"role": "user", "text": obj.get("prompt"), "ts": obj.get("ts")})
232
- if obj.get("response") is not None:
233
- messages.append(
234
- {"role": "assistant", "text": obj.get("response"), "ts": obj.get("ts")}
235
- )
236
- except Exception as exc:
237
- return jsonify({"ok": False, "message": str(exc)}), 500
238
- return jsonify({"ok": True, "id": conv_id, "messages": messages})
239
-
240
-
241
- @bp.get("/status")
242
- def studio_status():
243
- p = _studio_config_path()
244
- exists = os.path.isfile(p)
245
- data: dict | None = None
246
- if exists:
247
- try:
248
- with open(p, "r", encoding="utf-8") as f:
249
- raw = f.read().strip()
250
- if raw:
251
- data = json.loads(raw)
252
- except Exception:
253
- data = None
254
- # Surface current provider quickly for the UI
255
- current_provider = None
256
- if isinstance(data, dict):
257
- current_provider = data.get("current_provider")
258
- if not current_provider and isinstance(data.get("providers"), dict):
259
- # Pick one deterministically for display
260
- try:
261
- current_provider = next(iter(data["providers"].keys()))
262
- except Exception:
263
- current_provider = None
264
- return jsonify(
265
- {"configured": exists, "config": data, "current_provider": current_provider}
266
- )
267
-
268
-
269
- @bp.post("/config")
270
- def studio_save_config():
271
- payload = request.get_json(silent=True) or {}
272
- p = _studio_config_path()
273
- # Merge semantics to support multiple providers while remaining backward compatible
274
- try:
275
- current: dict = {}
276
- if os.path.isfile(p):
277
- try:
278
- with open(p, "r", encoding="utf-8") as rf:
279
- raw = (rf.read() or "").strip()
280
- if raw:
281
- current = json.loads(raw)
282
- if not isinstance(current, dict):
283
- current = {}
284
- except Exception:
285
- current = {}
286
- # Normalize base structure
287
- if "providers" not in current or not isinstance(current.get("providers"), dict):
288
- current["providers"] = {}
289
- providers: dict = current["providers"] # type: ignore[assignment]
290
- # Path A: structured provider update
291
- provider = (payload.get("provider") or "").strip()
292
- conf = (
293
- payload.get("config") if isinstance(payload.get("config"), dict) else None
294
- )
295
- set_current = bool(payload.get("set_current"))
296
- if provider and conf is not None:
297
- providers[provider] = conf
298
- if set_current:
299
- current["current_provider"] = provider
300
- else:
301
- # Path B: legacy flat payload (e.g., { "model": "gpt-oss:20b" })
302
- # Interpret as local provider settings
303
- if "model" in payload and isinstance(payload.get("model"), str):
304
- local_conf = (
305
- providers.get("local", {})
306
- if isinstance(providers.get("local"), dict)
307
- else {}
308
- )
309
- local_conf["model"] = (payload.get("model") or "").strip()
310
- providers["local"] = local_conf
311
- # Prefer local as current if not already chosen
312
- if not current.get("current_provider"):
313
- current["current_provider"] = "local"
314
- else:
315
- # Fallback: overwrite with provided payload (explicit user intent)
316
- current = payload
317
- if "providers" not in current:
318
- current = {
319
- "providers": {"legacy": payload},
320
- "current_provider": current.get("current_provider", "legacy"),
321
- }
322
- # Persist
323
- with open(p, "w", encoding="utf-8") as f:
324
- f.write(json.dumps(current, ensure_ascii=False, indent=2))
325
- return jsonify({"ok": True, "saved": True})
326
- except Exception as exc:
327
- return jsonify({"ok": False, "message": str(exc)}), 500
328
-
329
-
330
- @bp.get("/check-ollama")
331
- def check_ollama():
332
- url = "http://127.0.0.1:11434/api/tags"
333
- try:
334
- r = requests.get(url, timeout=2)
335
- ok = r.status_code == 200
336
- return jsonify({"ok": ok})
337
- except Exception:
338
- return jsonify({"ok": False})
339
-
340
-
341
- @bp.get("/providers")
342
- def list_providers():
343
- """Return available agent provider types and current selection from .studio config."""
344
- p = _studio_config_path()
345
- data: dict = {}
346
- try:
347
- if os.path.isfile(p):
348
- with open(p, "r", encoding="utf-8") as f:
349
- raw = (f.read() or "").strip()
350
- if raw:
351
- data = json.loads(raw)
352
- if not isinstance(data, dict):
353
- data = {}
354
- except Exception:
355
- data = {}
356
- providers = data.get("providers") if isinstance(data.get("providers"), dict) else {}
357
- current = (
358
- data.get("current_provider")
359
- if isinstance(data.get("current_provider"), str)
360
- else None
361
- )
362
- # Static list for now; can be extended later or discovered dynamically
363
- available = [
364
- {"id": "local", "name": "Local Agent", "logo": "/static/ollama.png"},
365
- {"id": "openai", "name": "ChatGPT", "logo": "/static/chatgpt.svg"},
366
- {"id": "mistral", "name": "Mistral", "logo": "/static/mistral.svg"},
367
- {"id": "claude", "name": "Claude", "logo": "/static/sources-logos/api.svg"},
368
- {"id": "gemini", "name": "Gemini", "logo": "/static/sources-logos/api.svg"},
369
- ]
370
- return jsonify(
371
- {
372
- "available": available,
373
- "current": current,
374
- "configs": providers,
375
- }
376
- )
377
-
378
-
379
- @bp.post("/check-remote")
380
- def check_remote():
381
- """Best-effort connectivity check for remote AI providers (OpenAI, Mistral).
382
-
383
- Body: { "provider": "openai"|"mistral", "api_key": "...", "model": "..." }
384
- Returns: { ok: bool, message?: str, provider: str }
385
- """
386
- data = request.get_json(silent=True) or {}
387
- provider = (data.get("provider") or "").strip().lower()
388
- api_key = (data.get("api_key") or "").strip()
389
- model = (data.get("model") or "").strip()
390
- if not provider or not api_key:
391
- return (
392
- jsonify(
393
- {
394
- "ok": False,
395
- "message": "Missing provider or API key",
396
- "provider": provider,
397
- }
398
- ),
399
- 400,
400
- )
401
- try:
402
- if provider == "openai":
403
- # Lightweight models list call
404
- url = "https://api.openai.com/v1/models"
405
- headers = {"Authorization": f"Bearer {api_key}"}
406
- r = requests.get(url, headers=headers, timeout=8)
407
- if 200 <= r.status_code < 300:
408
- return jsonify({"ok": True, "provider": provider})
409
- try:
410
- body = r.json()
411
- except Exception:
412
- body = {"detail": r.text[:200]}
413
- return (
414
- jsonify(
415
- {
416
- "ok": False,
417
- "provider": provider,
418
- "status": r.status_code,
419
- "error": body,
420
- }
421
- ),
422
- 200,
423
- )
424
- if provider == "mistral":
425
- # Mistral whoami endpoint
426
- url = "https://api.mistral.ai/v1/models"
427
- headers = {"Authorization": f"Bearer {api_key}"}
428
- r = requests.get(url, headers=headers, timeout=8)
429
- if 200 <= r.status_code < 300:
430
- return jsonify({"ok": True, "provider": provider})
431
- try:
432
- body = r.json()
433
- except Exception:
434
- body = {"detail": r.text[:200]}
435
- return (
436
- jsonify(
437
- {
438
- "ok": False,
439
- "provider": provider,
440
- "status": r.status_code,
441
- "error": body,
442
- }
443
- ),
444
- 200,
445
- )
446
- if provider == "claude":
447
- # Anthropic models list requires API key header and version header
448
- url = "https://api.anthropic.com/v1/models"
449
- headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
450
- r = requests.get(url, headers=headers, timeout=8)
451
- if 200 <= r.status_code < 300:
452
- return jsonify({"ok": True, "provider": provider})
453
- try:
454
- body = r.json()
455
- except Exception:
456
- body = {"detail": r.text[:200]}
457
- return (
458
- jsonify(
459
- {
460
- "ok": False,
461
- "provider": provider,
462
- "status": r.status_code,
463
- "error": body,
464
- }
465
- ),
466
- 200,
467
- )
468
- if provider == "gemini":
469
- # Google Generative Language API models list with key in query
470
- url = f"https://generativelanguage.googleapis.com/v1/models?key={api_key}"
471
- r = requests.get(url, timeout=8)
472
- if 200 <= r.status_code < 300:
473
- return jsonify({"ok": True, "provider": provider})
474
- try:
475
- body = r.json()
476
- except Exception:
477
- body = {"detail": r.text[:200]}
478
- return (
479
- jsonify(
480
- {
481
- "ok": False,
482
- "provider": provider,
483
- "status": r.status_code,
484
- "error": body,
485
- }
486
- ),
487
- 200,
488
- )
489
- return (
490
- jsonify(
491
- {"ok": False, "message": "Unsupported provider", "provider": provider}
492
- ),
493
- 400,
494
- )
495
- except Exception as exc:
496
- return jsonify({"ok": False, "message": str(exc), "provider": provider}), 200
497
-
498
-
499
- @bp.get("/check-backend")
500
- def check_backend():
501
- """Proxy healthcheck against the remote backend URL from current context.
502
- Avoids CORS issues in the browser and standardizes the response shape.
503
- """
504
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
505
- backend_url: str | None = None
506
- token_value: str | None = None
507
- try:
508
- backend_url = getattr(cfg, "url", None)
509
- token_value = getattr(cfg, "token", None)
510
- except Exception:
511
- backend_url = None
512
- token_value = None
513
- # Fallback: read selected env pointer and parse URL from env file
514
- try:
515
- if not backend_url:
516
- home = _qalita_home()
517
- pointer = os.path.join(home, ".current_env")
518
- if os.path.isfile(pointer):
519
- with open(pointer, "r", encoding="utf-8") as f:
520
- env_path = (f.read() or "").strip()
521
- if env_path and os.path.isfile(env_path):
522
- with open(env_path, "r", encoding="utf-8") as ef:
523
- for raw in ef.readlines():
524
- line = (raw or "").strip()
525
- if not line or line.startswith("#") or "=" not in line:
526
- continue
527
- k, v = line.split("=", 1)
528
- k = (k or "").strip().upper()
529
- v = (v or "").strip().strip('"').strip("'")
530
- if k in (
531
- "QALITA_AGENT_ENDPOINT",
532
- "AGENT_ENDPOINT",
533
- "QALITA_URL",
534
- "URL",
535
- ):
536
- backend_url = v
537
- if (
538
- k in ("QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN")
539
- and not token_value
540
- ):
541
- token_value = v
542
- # no break: we want to scan whole file to capture both url and token
543
- except Exception:
544
- pass
545
- # Compute readiness flags
546
- endpoint_present = bool(backend_url)
547
- token_present = bool(token_value)
548
- configured = endpoint_present and token_present
549
- if not backend_url:
550
- return (
551
- jsonify(
552
- {
553
- "ok": False,
554
- "status": None,
555
- "url": None,
556
- "endpoint_present": endpoint_present,
557
- "token_present": token_present,
558
- "configured": configured,
559
- }
560
- ),
561
- 200,
562
- )
563
- try:
564
- url = str(backend_url).rstrip("/") + "/api/v1/healthcheck"
565
- except Exception:
566
- url = str(backend_url) + "/api/v1/healthcheck"
567
- try:
568
- r = requests.get(url, timeout=3)
569
- ok = 200 <= r.status_code < 300
570
- return jsonify(
571
- {
572
- "ok": ok,
573
- "status": r.status_code,
574
- "url": str(backend_url).rstrip("/"),
575
- "endpoint_present": endpoint_present,
576
- "token_present": token_present,
577
- "configured": configured,
578
- }
579
- )
580
- except Exception:
581
- return (
582
- jsonify(
583
- {
584
- "ok": False,
585
- "status": None,
586
- "url": str(backend_url).rstrip("/"),
587
- "endpoint_present": endpoint_present,
588
- "token_present": token_present,
589
- "configured": configured,
590
- }
591
- ),
592
- 200,
593
- )
594
-
595
-
596
- @bp.get("/projects")
597
- def studio_projects():
598
- """Proxy projects list against the remote backend URL from current context.
599
- Standardizes response to { ok: bool, items: [...] } and avoids CORS.
600
- """
601
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
602
- backend_url: str | None = None
603
- token_value: str | None = None
604
- try:
605
- backend_url = getattr(cfg, "url", None)
606
- token_value = getattr(cfg, "token", None)
607
- except Exception:
608
- backend_url = None
609
- token_value = None
610
- # Fallback to env file like in check_backend
611
- try:
612
- if not backend_url:
613
- home = _qalita_home()
614
- pointer = os.path.join(home, ".current_env")
615
- if os.path.isfile(pointer):
616
- with open(pointer, "r", encoding="utf-8") as f:
617
- env_path = (f.read() or "").strip()
618
- if env_path and os.path.isfile(env_path):
619
- with open(env_path, "r", encoding="utf-8") as ef:
620
- for raw in ef.readlines():
621
- line = (raw or "").strip()
622
- if not line or line.startswith("#") or "=" not in line:
623
- continue
624
- k, v = line.split("=", 1)
625
- k = (k or "").strip().upper()
626
- v = (v or "").strip().strip('"').strip("'")
627
- if k in (
628
- "QALITA_AGENT_ENDPOINT",
629
- "AGENT_ENDPOINT",
630
- "QALITA_URL",
631
- "URL",
632
- ):
633
- backend_url = v
634
- if (
635
- k in ("QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN")
636
- and not token_value
637
- ):
638
- token_value = v
639
- except Exception:
640
- pass
641
- if not backend_url:
642
- return jsonify({"ok": False, "items": [], "message": "Missing backend URL"}), 200
643
- try:
644
- url = str(backend_url).rstrip("/") + "/api/v2/projects"
645
- except Exception:
646
- url = str(backend_url) + "/api/v2/projects"
647
- headers = {"Accept": "application/json"}
648
- if token_value:
649
- headers["Authorization"] = f"Bearer {token_value}"
650
- try:
651
- r = requests.get(url, headers=headers, timeout=8)
652
- # Normalize response shapes
653
- try:
654
- body = r.json()
655
- except Exception:
656
- body = None
657
- def _normalize_projects(j):
658
- try:
659
- if not j:
660
- return []
661
- if isinstance(j, list):
662
- return j
663
- if isinstance(j, dict):
664
- if isinstance(j.get("items"), list):
665
- return j["items"]
666
- if isinstance(j.get("data"), list):
667
- return j["data"]
668
- if isinstance(j.get("results"), list):
669
- return j["results"]
670
- if isinstance(j.get("projects"), list):
671
- return j["projects"]
672
- if isinstance(j.get("data"), dict) and isinstance(j["data"].get("items"), list):
673
- return j["data"]["items"]
674
- # Single object
675
- if (j.get("id") is not None) or (j.get("name") is not None):
676
- return [j]
677
- except Exception:
678
- return []
679
- return []
680
- if 200 <= r.status_code < 300:
681
- items = _normalize_projects(body)
682
- return jsonify({"ok": True, "items": items})
683
- # Error passthrough (without failing the request status)
684
- return jsonify({"ok": False, "status": r.status_code, "error": body}), 200
685
- except Exception as exc:
686
- return jsonify({"ok": False, "items": [], "message": str(exc)}), 200
687
-
688
-
689
- @bp.get("/sources")
690
- def studio_sources():
691
- """Proxy sources list against the remote backend URL from current context.
692
- Enrich with local presence and validation flags from ~/.qalita/sources-conf.yaml.
693
- Response shape: { ok: bool, items: [ { ..., local_present, local_validate } ] }
694
- Optional query passthrough: project_id
695
- """
696
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
697
- backend_url: str | None = None
698
- token_value: str | None = None
699
- try:
700
- backend_url = getattr(cfg, "url", None)
701
- token_value = getattr(cfg, "token", None)
702
- except Exception:
703
- backend_url = None
704
- token_value = None
705
- # Fallback to env file like in check_backend
706
- try:
707
- if not backend_url:
708
- home = _qalita_home()
709
- pointer = os.path.join(home, ".current_env")
710
- if os.path.isfile(pointer):
711
- with open(pointer, "r", encoding="utf-8") as f:
712
- env_path = (f.read() or "").strip()
713
- if env_path and os.path.isfile(env_path):
714
- with open(env_path, "r", encoding="utf-8") as ef:
715
- for raw in ef.readlines():
716
- line = (raw or "").strip()
717
- if not line or line.startswith("#") or "=" not in line:
718
- continue
719
- k, v = line.split("=", 1)
720
- k = (k or "").strip().upper()
721
- v = (v or "").strip().strip('"').strip("'")
722
- if k in (
723
- "QALITA_AGENT_ENDPOINT",
724
- "AGENT_ENDPOINT",
725
- "QALITA_URL",
726
- "URL",
727
- ):
728
- backend_url = v
729
- if (
730
- k in ("QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN")
731
- and not token_value
732
- ):
733
- token_value = v
734
- except Exception:
735
- pass
736
- if not backend_url:
737
- return jsonify({"ok": False, "items": [], "message": "Missing backend URL"}), 200
738
- try:
739
- base = str(backend_url).rstrip("/") + "/api/v2/sources"
740
- except Exception:
741
- base = str(backend_url) + "/api/v2/sources"
742
- # Optional filters passthrough
743
- params = {}
744
- project_id = (request.args.get("project_id") or "").strip()
745
- if project_id:
746
- params["project_id"] = project_id
747
- headers = {"Accept": "application/json"}
748
- if token_value:
749
- headers["Authorization"] = f"Bearer {token_value}"
750
- try:
751
- r = requests.get(base, headers=headers, params=params, timeout=8)
752
- try:
753
- body = r.json()
754
- except Exception:
755
- body = None
756
- def _normalize_sources(j):
757
- try:
758
- if not j:
759
- return []
760
- if isinstance(j, list):
761
- return j
762
- if isinstance(j, dict):
763
- if isinstance(j.get("items"), list):
764
- return j["items"]
765
- if isinstance(j.get("data"), list):
766
- return j["data"]
767
- if isinstance(j.get("results"), list):
768
- return j["results"]
769
- if isinstance(j.get("data"), dict) and isinstance(j["data"].get("items"), list):
770
- return j["data"]["items"]
771
- if isinstance(j.get("sources"), list):
772
- return j["sources"]
773
- # Single object
774
- if (j.get("id") is not None) or (j.get("name") is not None):
775
- return [j]
776
- except Exception:
777
- return []
778
- return []
779
- if 200 <= r.status_code < 300:
780
- items = _normalize_sources(body)
781
- # Enrich with local conf presence and validate flag
782
- conf = _read_qalita_conf()
783
- local_sources = conf.get("sources") if isinstance(conf.get("sources"), list) else []
784
- local_by_id: dict[str, dict] = {}
785
- try:
786
- for s in local_sources:
787
- if isinstance(s, dict) and s.get("id") is not None:
788
- local_by_id[str(s.get("id"))] = s
789
- except Exception:
790
- local_by_id = {}
791
- enriched = []
792
- seen_ids: set[str] = set()
793
- for it in items:
794
- try:
795
- obj = dict(it) if isinstance(it, dict) else {"value": it}
796
- except Exception:
797
- obj = {"value": it}
798
- sid = str(obj.get("id", ""))
799
- if sid:
800
- seen_ids.add(sid)
801
- lobj = local_by_id.get(sid)
802
- obj["local_present"] = bool(lobj is not None)
803
- if isinstance(lobj, dict):
804
- val = lobj.get("validate")
805
- try:
806
- obj["local_validate"] = (str(val).lower() if val is not None else None)
807
- except Exception:
808
- obj["local_validate"] = None
809
- else:
810
- obj["local_validate"] = None
811
- enriched.append(obj)
812
- # Add local-only sources (not present in backend response)
813
- try:
814
- for sid, lobj in local_by_id.items():
815
- if sid in seen_ids:
816
- continue
817
- try:
818
- name = lobj.get("name") or (
819
- lobj.get("source", {}).get("name") if isinstance(lobj.get("source"), dict) else None
820
- ) or f"Source {sid}"
821
- stype = lobj.get("type") or (
822
- lobj.get("source", {}).get("type") if isinstance(lobj.get("source"), dict) else None
823
- )
824
- except Exception:
825
- name = f"Source {sid}"
826
- stype = None
827
- val = lobj.get("validate")
828
- try:
829
- vnorm = (str(val).lower() if val is not None else None)
830
- except Exception:
831
- vnorm = None
832
- enriched.append({
833
- "id": sid,
834
- "name": name,
835
- "type": stype,
836
- "local_present": True,
837
- "local_validate": vnorm,
838
- })
839
- except Exception:
840
- pass
841
- return jsonify({"ok": True, "items": enriched})
842
- return jsonify({"ok": False, "status": r.status_code, "error": body}), 200
843
- except Exception as exc:
844
- return jsonify({"ok": False, "items": [], "message": str(exc)}), 200
845
-
846
-
847
- @bp.get("/sync-conversations")
848
- def sync_conversations():
849
- """Ensure local conversations for a given issue are present by pulling from backend if missing.
850
-
851
- Query: issue_id
852
- """
853
- issue_id = (request.args.get("issue_id") or "").strip()
854
- if not issue_id:
855
- return jsonify({"ok": False, "message": "Missing issue_id"}), 400
856
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
857
- backend_url: str | None = None
858
- token_value: str | None = None
859
- try:
860
- backend_url = getattr(cfg, "url", None)
861
- token_value = getattr(cfg, "token", None)
862
- except Exception:
863
- backend_url = None
864
- token_value = None
865
- # Try env file fallback
866
- try:
867
- if not backend_url:
868
- home = _qalita_home()
869
- pointer = os.path.join(home, ".current_env")
870
- if os.path.isfile(pointer):
871
- with open(pointer, "r", encoding="utf-8") as f:
872
- env_path = (f.read() or "").strip()
873
- if env_path and os.path.isfile(env_path):
874
- with open(env_path, "r", encoding="utf-8") as ef:
875
- for raw in ef.readlines():
876
- line = (raw or "").strip()
877
- if not line or line.startswith("#") or "=" not in line:
878
- continue
879
- k, v = line.split("=", 1)
880
- k = (k or "").strip().upper()
881
- v = (v or "").strip().strip('"').strip("'")
882
- if k in ("QALITA_AGENT_ENDPOINT", "AGENT_ENDPOINT", "QALITA_URL", "URL"):
883
- backend_url = v
884
- if k in ("QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN") and not token_value:
885
- token_value = v
886
- except Exception:
887
- pass
888
- if not backend_url:
889
- return jsonify({"ok": False, "message": "Missing backend URL"}), 200
890
- headers = {"Accept": "application/json"}
891
- if token_value:
892
- headers["Authorization"] = f"Bearer {token_value}"
893
- # List conversations
894
- try:
895
- base = str(backend_url).rstrip("/") + f"/api/v1/issues/{issue_id}/studio_conversations"
896
- except Exception:
897
- base = str(backend_url) + f"/api/v1/issues/{issue_id}/studio_conversations"
898
- try:
899
- r = requests.get(base, headers=headers, timeout=10)
900
- items = r.json() if r.ok else []
901
- except Exception:
902
- items = []
903
- # Download any missing files
904
- conv_dir = _studio_conv_dir()
905
- downloaded = 0
906
- try:
907
- for it in (items or []):
908
- try:
909
- fname = (it.get("filename") or (it.get("conv_id", "") + ".jsonl")).strip()
910
- except Exception:
911
- fname = None
912
- if not fname:
913
- continue
914
- local_path = os.path.join(conv_dir, fname)
915
- if os.path.isfile(local_path):
916
- continue
917
- # fetch download
918
- try:
919
- did = it.get("id")
920
- url = str(backend_url).rstrip("/") + f"/api/v1/issues/{issue_id}/studio_conversations/{did}/download"
921
- except Exception:
922
- continue
923
- try:
924
- dr = requests.get(url, headers=headers, timeout=20)
925
- if dr.status_code >= 400:
926
- continue
927
- os.makedirs(conv_dir, exist_ok=True)
928
- with open(local_path, "wb") as f:
929
- f.write(dr.content or b"")
930
- downloaded += 1
931
- except Exception:
932
- continue
933
- except Exception:
934
- pass
935
- return jsonify({"ok": True, "downloaded": downloaded})
936
-
937
-
938
- @bp.post("/upload-conversation")
939
- def upload_conversation():
940
- """Upload a local conversation file for an issue to the backend.
941
-
942
- Body: { conv_id: str, issue_id: str }
943
- """
944
- data = request.get_json(silent=True) or {}
945
- conv_id = _safe_conv_id((data.get("conv_id") or "").strip())
946
- issue_id = (data.get("issue_id") or "").strip()
947
- if not conv_id or not issue_id:
948
- return jsonify({"ok": False, "message": "Missing conv_id or issue_id"}), 400
949
- path = _studio_conv_file_for(conv_id)
950
- if not os.path.isfile(path):
951
- return jsonify({"ok": False, "message": "Local conversation not found"}), 404
952
- # Backend context
953
- cfg = current_app.config.get("QALITA_CONFIG_OBJ")
954
- backend_url: str | None = None
955
- token_value: str | None = None
956
- try:
957
- backend_url = getattr(cfg, "url", None)
958
- token_value = getattr(cfg, "token", None)
959
- except Exception:
960
- backend_url = None
961
- token_value = None
962
- # Try env file fallback
963
- try:
964
- if not backend_url:
965
- home = _qalita_home()
966
- pointer = os.path.join(home, ".current_env")
967
- if os.path.isfile(pointer):
968
- with open(pointer, "r", encoding="utf-8") as f:
969
- env_path = (f.read() or "").strip()
970
- if env_path and os.path.isfile(env_path):
971
- with open(env_path, "r", encoding="utf-8") as ef:
972
- for raw in ef.readlines():
973
- line = (raw or "").strip()
974
- if not line or line.startswith("#") or "=" not in line:
975
- continue
976
- k, v = line.split("=", 1)
977
- k = (k or "").strip().upper()
978
- v = (v or "").strip().strip('"').strip("'")
979
- if k in ("QALITA_AGENT_ENDPOINT", "AGENT_ENDPOINT", "QALITA_URL", "URL"):
980
- backend_url = v
981
- if k in ("QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN") and not token_value:
982
- token_value = v
983
- except Exception:
984
- pass
985
- if not backend_url:
986
- return jsonify({"ok": False, "message": "Missing backend URL"}), 200
987
- try:
988
- with open(path, "rb") as f:
989
- files = {
990
- "file": (f"{conv_id}.jsonl", f, "text/plain"),
991
- }
992
- data_form = {
993
- "conv_id": conv_id,
994
- "filename": f"{conv_id}.jsonl",
995
- }
996
- headers = {}
997
- if token_value:
998
- headers["Authorization"] = f"Bearer {token_value}"
999
- url = str(backend_url).rstrip("/") + f"/api/v1/issues/{issue_id}/studio_conversations"
1000
- r = requests.post(url, headers=headers, files=files, data=data_form, timeout=30)
1001
- if r.status_code >= 400:
1002
- try:
1003
- body = r.json()
1004
- except Exception:
1005
- body = {"detail": r.text[:200]}
1006
- return jsonify({"ok": False, "status": r.status_code, "error": body}), 200
1007
- return jsonify({"ok": True})
1008
- except Exception as exc:
1009
- return jsonify({"ok": False, "message": str(exc)}), 200
1010
-
1011
-
1012
- @bp.post("/chat")
1013
- def studio_chat():
1014
- data = request.get_json(silent=True) or {}
1015
- prompt = (data.get("prompt") or "").strip()
1016
- conv_id = _safe_conv_id((data.get("conv_id") or "").strip())
1017
- issue_id = (data.get("issue_id") or "").strip()
1018
- source_id = (data.get("source_id") or "").strip()
1019
- issue_details = data.get("issue_details") if isinstance(data.get("issue_details"), dict) else None
1020
- # Prefer model from request; else fall back to saved Studio config; else default
1021
- model = (data.get("model") or "").strip()
1022
- if not model:
1023
- try:
1024
- cfg_path = _studio_config_path()
1025
- if os.path.isfile(cfg_path):
1026
- with open(cfg_path, "r", encoding="utf-8") as f:
1027
- raw = f.read().strip()
1028
- if raw:
1029
- cfg = json.loads(raw)
1030
- model = (cfg.get("model") or "").strip()
1031
- except Exception:
1032
- # Ignore config read errors and continue to use default below
1033
- pass
1034
- if not model:
1035
- model = "gpt-oss:20b"
1036
- if not prompt:
1037
- return jsonify({"ok": False, "message": "Missing prompt"}), 400
1038
- # Streaming toggle via query or body
1039
- stream_flag_raw = (
1040
- (request.args.get("stream") or data.get("stream") or "").strip().lower()
1041
- )
1042
- stream_enabled = stream_flag_raw in ("1", "true", "yes", "on")
1043
- if stream_enabled:
1044
-
1045
- def generate_stream():
1046
- req = None
1047
- accumulated = ""
1048
- logged = False
1049
- try:
1050
- # Log user message at start of request
1051
- try:
1052
- _studio_conv_write(conv_id, {"role": "user", "text": prompt, "model": model, "issue_id": issue_id or None, "source_id": source_id or None, "issue_details": issue_details or None})
1053
- except Exception:
1054
- pass
1055
- # Try attach source details when present
1056
- src_details = None
1057
- try:
1058
- if source_id:
1059
- conf = _read_qalita_conf()
1060
- src_obj = _find_source_by_id(conf, source_id)
1061
- if isinstance(src_obj, dict):
1062
- src_details = src_obj
1063
- except Exception:
1064
- src_details = None
1065
-
1066
- req = requests.post(
1067
- "http://127.0.0.1:11434/api/generate",
1068
- json={"model": model, "prompt": _augment_prompt_with_context(prompt, issue_id, source_id, issue_details, src_details), "stream": True},
1069
- stream=True,
1070
- timeout=300,
1071
- )
1072
- if req.status_code != 200:
1073
- try:
1074
- body = req.json()
1075
- msg = (
1076
- (body.get("error") if isinstance(body, dict) else None)
1077
- or (body.get("message") if isinstance(body, dict) else None)
1078
- or str(body)
1079
- )
1080
- except Exception:
1081
- msg = f"Ollama error: {req.status_code}"
1082
- try:
1083
- _studio_conv_write(
1084
- conv_id,
1085
- {
1086
- "role": "assistant",
1087
- "text": accumulated or f"[ERROR] {msg}",
1088
- "model": model,
1089
- "ok": False,
1090
- "status": req.status_code,
1091
- "error": msg,
1092
- "stream": True,
1093
- "issue_id": issue_id or None,
1094
- "source_id": source_id or None,
1095
- },
1096
- )
1097
- logged = True
1098
- except Exception:
1099
- pass
1100
- yield f"[ERROR] {msg}"
1101
- return
1102
- for line in req.iter_lines(decode_unicode=True):
1103
- if not line:
1104
- continue
1105
- try:
1106
- obj = json.loads(line)
1107
- if obj.get("response"):
1108
- piece = obj["response"]
1109
- accumulated += piece
1110
- yield piece
1111
- if obj.get("done"):
1112
- break
1113
- except Exception:
1114
- # Fallback: passthrough raw line
1115
- accumulated += line
1116
- yield line
1117
- except GeneratorExit:
1118
- # Client disconnected/aborted
1119
- if req is not None:
1120
- try:
1121
- req.close()
1122
- except Exception:
1123
- pass
1124
- try:
1125
- if not logged:
1126
- _studio_conv_write(
1127
- conv_id,
1128
- {
1129
- "role": "assistant",
1130
- "text": accumulated,
1131
- "model": model,
1132
- "ok": True,
1133
- "interrupted": True,
1134
- "stream": True,
1135
- "issue_id": issue_id or None,
1136
- "source_id": source_id or None,
1137
- },
1138
- )
1139
- logged = True
1140
- except Exception:
1141
- pass
1142
- raise
1143
- except Exception as exc:
1144
- try:
1145
- if not logged:
1146
- _studio_conv_write(
1147
- conv_id,
1148
- {
1149
- "role": "assistant",
1150
- "text": accumulated or f"[ERROR] Failed to reach Ollama: {exc}",
1151
- "model": model,
1152
- "ok": False,
1153
- "error": str(exc),
1154
- "stream": True,
1155
- "issue_id": issue_id or None,
1156
- "source_id": source_id or None,
1157
- },
1158
- )
1159
- logged = True
1160
- except Exception:
1161
- pass
1162
- yield f"[ERROR] Failed to reach Ollama: {exc}"
1163
- finally:
1164
- if req is not None:
1165
- try:
1166
- req.close()
1167
- except Exception:
1168
- pass
1169
- try:
1170
- if not logged:
1171
- _studio_conv_write(
1172
- conv_id,
1173
- {
1174
- "role": "assistant",
1175
- "text": accumulated,
1176
- "model": model,
1177
- "ok": True,
1178
- "stream": True,
1179
- "issue_id": issue_id or None,
1180
- "source_id": source_id or None,
1181
- },
1182
- )
1183
- logged = True
1184
- except Exception:
1185
- pass
1186
-
1187
- return Response(stream_with_context(generate_stream()), mimetype="text/plain; charset=utf-8")
1188
- try:
1189
- # Log user message for non-streaming
1190
- try:
1191
- _studio_conv_write(conv_id, {"role": "user", "text": prompt, "model": model, "issue_id": issue_id or None, "source_id": source_id or None, "issue_details": issue_details or None})
1192
- except Exception:
1193
- pass
1194
- # Try attach source details when present
1195
- src_details = None
1196
- try:
1197
- if source_id:
1198
- conf = _read_qalita_conf()
1199
- src_obj = _find_source_by_id(conf, source_id)
1200
- if isinstance(src_obj, dict):
1201
- src_details = src_obj
1202
- except Exception:
1203
- src_details = None
1204
-
1205
- r = requests.post(
1206
- "http://127.0.0.1:11434/api/generate",
1207
- json={"model": model, "prompt": _augment_prompt_with_context(prompt, issue_id, source_id, issue_details, src_details), "stream": False},
1208
- timeout=60,
1209
- )
1210
- if r.status_code == 200:
1211
- out = r.json().get("response", "")
1212
- try:
1213
- _studio_conv_write(conv_id, {"role": "assistant", "text": out, "model": model, "ok": True, "stream": False, "issue_id": issue_id or None, "source_id": source_id or None})
1214
- except Exception:
1215
- pass
1216
- return jsonify({"ok": True, "response": out, "conv_id": conv_id})
1217
- if r.status_code == 404:
1218
- try:
1219
- _studio_conv_write(conv_id, {"role": "assistant", "text": "", "model": model, "ok": False, "status": r.status_code, "error": "model_not_found", "stream": False})
1220
- except Exception:
1221
- pass
1222
- return (
1223
- jsonify(
1224
- {
1225
- "ok": False,
1226
- "message": f"Model not found in Ollama: '{model}'. Install it with 'ollama pull {model}' or update your Studio model.",
1227
- }
1228
- ),
1229
- 500,
1230
- )
1231
- # Try to surface error body if available
1232
- try:
1233
- err_body = r.json()
1234
- except Exception:
1235
- err_body = {"detail": r.text[:200]}
1236
- try:
1237
- _studio_conv_write(conv_id, {"role": "assistant", "text": "", "model": model, "ok": False, "status": r.status_code, "error": err_body, "stream": False})
1238
- except Exception:
1239
- pass
1240
- return (
1241
- jsonify(
1242
- {
1243
- "ok": False,
1244
- "message": f"Ollama error: {r.status_code}",
1245
- "error": err_body,
1246
- }
1247
- ),
1248
- 500,
1249
- )
1250
- except Exception as exc:
1251
- try:
1252
- _studio_conv_write(conv_id, {"role": "assistant", "text": "", "model": model, "ok": False, "error": str(exc), "stream": False})
1253
- except Exception:
1254
- pass
1255
- return jsonify({"ok": False, "message": f"Failed to reach Ollama: {exc}"}), 502