Hug0endob commited on
Commit
aab85a4
·
verified ·
1 Parent(s): 45d7663

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +139 -225
app.py CHANGED
@@ -1,21 +1,36 @@
1
  #!/usr/bin/env python3
 
 
2
  from __future__ import annotations
3
- import os, shutil, subprocess, tempfile, base64, requests, time
 
 
 
 
 
 
4
  from io import BytesIO
5
  from typing import List, Tuple
 
 
6
  from PIL import Image, ImageFile, UnidentifiedImageError
7
  import gradio as gr
8
 
 
 
 
9
  try:
10
  from mistralai import Mistral
11
- except Exception:
12
  Mistral = None
13
 
14
- # Config
 
 
15
  DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
16
  PIXTRAL_MODEL = "pixtral-12b-2409"
17
  VIDEO_MODEL = "voxtral-mini-latest"
18
- STREAM_THRESHOLD = 20 * 1024 * 1024
19
  FFMPEG_BIN = shutil.which("ffmpeg")
20
  IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif")
21
  VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
@@ -27,9 +42,15 @@ SYSTEM_INSTRUCTION = (
27
  "Produce a concise, narrative that blends scientific precision with erotic detail. Do not invent sensory information not present in the media."
28
  )
29
 
 
 
 
30
  ImageFile.LOAD_TRUNCATED_IMAGES = True
31
  Image.MAX_IMAGE_PIXELS = 10000 * 10000
32
 
 
 
 
33
  def get_client(key: str | None = None):
34
  api_key = (key or "").strip() or DEFAULT_KEY
35
  if Mistral is None:
@@ -38,28 +59,39 @@ def get_client(key: str | None = None):
38
  return Dummy(api_key)
39
  return Mistral(api_key=api_key)
40
 
 
 
 
 
41
  def is_remote(src: str) -> bool:
42
  return bool(src) and src.startswith(("http://", "https://"))
43
 
 
44
  def ext_from_src(src: str) -> str:
45
- if not src: return ""
 
46
  _, ext = os.path.splitext((src or "").split("?")[0])
47
  return ext.lower()
48
 
 
49
  def safe_head(url: str, timeout: int = 6):
50
  try:
51
  r = requests.head(url, timeout=timeout, allow_redirects=True)
52
- if r.status_code >= 400: return None
 
53
  return r
54
  except Exception:
55
  return None
56
 
 
57
  def safe_get(url: str, timeout: int = 15):
58
  r = requests.get(url, timeout=timeout)
59
  r.raise_for_status()
60
  return r
61
 
 
62
  def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int = 60) -> bytes:
 
63
  if is_remote(src):
64
  head = safe_head(src)
65
  if head is not None:
@@ -73,31 +105,44 @@ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int
73
  try:
74
  with open(p, "wb") as fh:
75
  for chunk in r.iter_content(8192):
76
- if chunk: fh.write(chunk)
77
- with open(p, "rb") as fh: return fh.read()
 
 
78
  finally:
79
- try: os.remove(p)
80
- except Exception: pass
 
 
81
  except Exception:
82
  pass
83
  r = safe_get(src, timeout=timeout)
84
  return r.content
85
  else:
86
- with open(src, "rb") as f: return f.read()
 
 
87
 
88
  def save_bytes_to_temp(b: bytes, suffix: str) -> str:
89
  fd, path = tempfile.mkstemp(suffix=suffix)
90
  os.close(fd)
91
- with open(path, "wb") as f: f.write(b)
 
92
  return path
93
 
 
 
 
 
94
  def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
95
  img = Image.open(BytesIO(img_bytes))
96
  try:
97
- if getattr(img, "is_animated", False): img.seek(0)
 
98
  except Exception:
99
  pass
100
- if img.mode != "RGB": img = img.convert("RGB")
 
101
  h = base_h
102
  w = max(1, int(img.width * (h / img.height)))
103
  img = img.resize((w, h), Image.LANCZOS)
@@ -105,246 +150,115 @@ def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
105
  img.save(buf, format="JPEG", quality=85)
106
  return buf.getvalue()
107
 
 
108
  def b64_bytes(b: bytes, mime: str = "image/jpeg") -> str:
109
  return f"data:{mime};base64," + base64.b64encode(b).decode("utf-8")
110
 
111
- def extract_best_frames_bytes(media_path: str, sample_count: int = 5, timeout_extract: int = 15) -> List[bytes]:
112
- frames = []
113
- if not FFMPEG_BIN or not os.path.exists(media_path): return frames
114
- duration_pts = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
115
- for i, t in enumerate(duration_pts):
116
- fd, tmp = tempfile.mkstemp(suffix=f"_{i}.jpg"); os.close(fd)
117
- cmd = [FFMPEG_BIN, "-nostdin", "-y", "-ss", str(t), "-i", media_path, "-frames:v", "1", "-q:v", "2", tmp]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  try:
119
- subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_extract)
 
 
 
 
 
120
  if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
121
- with open(tmp, "rb") as f: frames.append(f.read())
 
122
  except Exception:
123
  pass
124
  finally:
125
- try: os.remove(tmp)
126
- except Exception: pass
 
 
127
  return frames
128
 
 
 
 
 
129
  def chat_complete(client, model: str, messages, timeout: int = 120) -> str:
 
130
  try:
131
  if hasattr(client, "chat") and hasattr(client.chat, "complete"):
132
  res = client.chat.complete(model=model, messages=messages, stream=False)
133
  else:
134
  api_key = getattr(client, "api_key", "") or DEFAULT_KEY
135
  url = "https://api.mistral.ai/v1/chat/completions"
136
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} if api_key else {"Content-Type":"application/json"}
137
- r = requests.post(url, json={"model": model, "messages": messages}, headers=headers, timeout=timeout)
 
 
 
 
 
 
 
 
 
138
  r.raise_for_status()
139
  res = r.json()
140
  choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
141
- if not choices: return str(res)
 
142
  first = choices[0]
143
- msg = first.message if hasattr(first, "message") else (first.get("message") if isinstance(first, dict) else first)
 
 
 
 
144
  content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
145
- if isinstance(content, str): return content.strip()
146
- return str(content)
147
  except Exception as e:
148
  return f"Error during model call: {e}"
149
 
150
- def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch") -> str:
 
 
 
 
 
 
 
 
151
  fname = filename or os.path.basename(path)
 
152
  try:
153
  if hasattr(client, "files") and hasattr(client.files, "upload"):
154
  with open(path, "rb") as fh:
155
- res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
 
 
156
  fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
157
- if not fid: fid = res["data"][0]["id"]
 
158
  return fid
159
  except Exception:
160
- pass
161
- api_key = getattr(client, "api_key", "") or DEFAULT_KEY
162
- url = "https://api.mistral.ai/v1/files"
163
- headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
164
- with open(path, "rb") as fh:
165
- files = {"file": (fname, fh)}
166
- data = {"purpose": purpose}
167
- r = requests.post(url, headers=headers, files=files, data=data, timeout=120)
168
- r.raise_for_status()
169
- jr = r.json()
170
- return jr.get("id") or jr.get("data", [{}])[0].get("id")
171
-
172
- def analyze_image_structured(client, img_bytes: bytes, prompt: str) -> str:
173
- jpeg = convert_to_jpeg_bytes(img_bytes, base_h=1024)
174
- data_url = b64_bytes(jpeg, mime="image/jpeg")
175
- messages = [
176
- {"role": "system", "content": SYSTEM_INSTRUCTION},
177
- {"role": "user", "content": [
178
- {"type": "text", "text": prompt},
179
- {"type": "image_url", "image_url": data_url}
180
- ]}
181
- ]
182
- return chat_complete(client, PIXTRAL_MODEL, messages)
183
-
184
- def analyze_video_cohesive(client, video_path: str, prompt: str) -> str:
185
- try:
186
- file_id = upload_file_to_mistral(client, video_path, filename=os.path.basename(video_path))
187
- extra_msg = (
188
- f"Uploaded video file id: {file_id}\n\n"
189
- "Instruction: Analyze the entire video and produce a single cohesive narrative describing consistent observations."
190
- )
191
- messages = [
192
- {"role": "system", "content": SYSTEM_INSTRUCTION},
193
- {"role": "user", "content": extra_msg + "\n\n" + prompt}
194
- ]
195
- return chat_complete(client, VIDEO_MODEL, messages)
196
- except Exception:
197
- frames = extract_best_frames_bytes(video_path, sample_count=6)
198
- if not frames:
199
- return "Error: could not upload video and no frames extracted (ffmpeg missing or failed)."
200
- images_entries = []
201
- for i, fb in enumerate(frames, start=1):
202
- try:
203
- j = convert_to_jpeg_bytes(fb, base_h=720)
204
- images_entries.append({"type": "image_url", "image_url": b64_bytes(j, mime="image/jpeg"), "meta": {"frame_index": i}})
205
- except Exception:
206
- continue
207
- content_list = [{"type":"text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + images_entries
208
- messages = [{"role": "system", "content": SYSTEM_INSTRUCTION}, {"role": "user", "content": content_list}]
209
- return chat_complete(client, PIXTRAL_MODEL, messages)
210
-
211
- def determine_media_type(src: str) -> Tuple[bool, bool]:
212
- is_image = False; is_video = False
213
- ext = ext_from_src(src)
214
- if ext in IMAGE_EXTS: is_image = True
215
- if ext in VIDEO_EXTS: is_video = True
216
- if is_remote(src):
217
- head = safe_head(src)
218
- if head:
219
- ctype = (head.headers.get("content-type") or "").lower()
220
- if ctype.startswith("image/"): is_image, is_video = True, False
221
- elif ctype.startswith("video/"): is_video, is_image = True, False
222
- return is_image, is_video
223
-
224
- def process_media(src: str, custom_prompt: str, api_key: str, progress=gr.Progress()) -> str:
225
- client = get_client(api_key)
226
- prompt = (custom_prompt or "").strip() or "Please provide a detailed visual review."
227
- if not src: return "No URL or path provided."
228
- progress(0.05, desc="Determining media type")
229
- is_image, is_video = determine_media_type(src)
230
- if is_image:
231
- try:
232
- raw = fetch_bytes(src)
233
- except Exception as e:
234
- return f"Error fetching image: {e}"
235
- progress(0.2, desc="Preparing image")
236
- try:
237
- out = analyze_image_structured(client, raw, prompt)
238
- progress(1.0, desc="Done")
239
- return out
240
- except UnidentifiedImageError:
241
- return "Error: provided file is not a valid image."
242
- except Exception as e:
243
- return f"Error analyzing image: {e}"
244
- if is_video:
245
- try:
246
- raw = fetch_bytes(src, timeout=120)
247
- except Exception as e:
248
- return f"Error fetching video: {e}"
249
- tmp = save_bytes_to_temp(raw, suffix=ext_from_src(src) or ".mp4")
250
- try:
251
- progress(0.2, desc="Uploading video / extracting frames")
252
- out = analyze_video_cohesive(client, tmp, prompt)
253
- progress(1.0, desc="Done")
254
- return out
255
- finally:
256
- try: os.remove(tmp)
257
- except Exception: pass
258
- try:
259
- raw = fetch_bytes(src)
260
- progress(0.2, desc="Treating as image")
261
- out = analyze_image_structured(client, raw, prompt)
262
- progress(1.0, desc="Done")
263
- return out
264
- except Exception as e:
265
- return f"Unable to determine media type or fetch file: {e}"
266
-
267
- css = ".preview_media img, .preview_media video { max-width: 100%; height: auto; }"
268
-
269
- def load_preview(url: str):
270
- empty_img = gr.update(value=None, visible=False)
271
- empty_vid = gr.update(value=None, visible=False)
272
- if not url:
273
- return empty_img, empty_vid
274
- if not is_remote(url) and os.path.exists(url):
275
- ext = ext_from_src(url)
276
- if ext in VIDEO_EXTS:
277
- return empty_img, gr.update(value=os.path.abspath(url), visible=True)
278
- if ext in IMAGE_EXTS:
279
- try:
280
- img = Image.open(url)
281
- if getattr(img, "is_animated", False): img.seek(0)
282
- return gr.update(value=img.convert("RGB"), visible=True), empty_vid
283
- except Exception:
284
- return empty_img, empty_vid
285
- head = safe_head(url)
286
- if head:
287
- ctype = (head.headers.get("content-type") or "").lower()
288
- if ctype.startswith("video/") or any(url.lower().split("?")[0].endswith(ext) for ext in VIDEO_EXTS):
289
- return empty_img, gr.update(value=url, visible=True)
290
- try:
291
- r = safe_get(url, timeout=15)
292
- img = Image.open(BytesIO(r.content))
293
- if getattr(img, "is_animated", False): img.seek(0)
294
- return gr.update(value=img.convert("RGB"), visible=True), empty_vid
295
- except Exception:
296
- return empty_img, empty_vid
297
-
298
- def _btn_label_for_status(status: str) -> str:
299
- return {"idle": "Submit", "busy": "Processing…", "done": "Submit", "error": "Retry"}.get(status or "idle", "Submit")
300
-
301
- def create_demo():
302
- with gr.Blocks(title="Flux Multimodal (Pixtral fixed)", css=css) as demo:
303
- with gr.Row():
304
- with gr.Column(scale=1):
305
- url_input = gr.Textbox(label="Image / Video URL or local path", placeholder="https://... or /path/to/file", lines=1)
306
- custom_prompt = gr.Textbox(label="Prompt (optional)", lines=2, value="")
307
- with gr.Accordion("Mistral API Key (optional)", open=False):
308
- api_key = gr.Textbox(label="API Key", type="password", max_lines=1)
309
- submit_btn = gr.Button("Submit")
310
- clear_btn = gr.Button("Clear")
311
- preview_image = gr.Image(label="Preview Image", type="pil", elem_classes="preview_media", visible=False)
312
- preview_video = gr.Video(label="Preview Video", elem_classes="preview_media", visible=False)
313
- with gr.Column(scale=2):
314
- final_md = gr.Markdown(value="")
315
-
316
- url_input.change(fn=load_preview, inputs=[url_input], outputs=[preview_image, preview_video])
317
- clear_btn.click(lambda: (gr.update(value=""), gr.update(value=None, visible=False), gr.update(value=None, visible=False)), inputs=[], outputs=[url_input, preview_image, preview_video])
318
-
319
- status = gr.State("idle")
320
-
321
- def start_busy() -> str:
322
- return "busy"
323
-
324
- def worker(url: str, prompt: str, key: str, progress=gr.Progress()):
325
- try:
326
- return process_media(url or "", prompt or "", key or "", progress=progress)
327
- except Exception as e:
328
- return f"Unhandled error in worker: {e}"
329
-
330
- def finish(status_text: str) -> tuple[str, str]:
331
- if (not status_text) or status_text.lower().startswith("error") or status_text.lower().startswith("unhandled"):
332
- return "error", status_text
333
- return "done", status_text
334
-
335
- def btn_props_from_status(s: str):
336
- s = s or "idle"
337
- interactive = False if s == "busy" else True
338
- label = _btn_label_for_status(s)
339
- return gr.update(interactive=interactive, value=label)
340
-
341
- submit_btn.click(fn=start_busy, inputs=[], outputs=[status])
342
- submit_btn.click(fn=worker, inputs=[url_input, custom_prompt, api_key], outputs=[final_md], queue=True)
343
- submit_btn.click(fn=finish, inputs=[final_md], outputs=[status, final_md])
344
- status.change(fn=btn_props_from_status, inputs=[status], outputs=[submit_btn])
345
-
346
- return demo
347
-
348
- if __name__ == "__main__":
349
- demo = create_demo()
350
- demo.queue().launch()
 
1
  #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
  from __future__ import annotations
5
+
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import base64
11
+ import json
12
  from io import BytesIO
13
  from typing import List, Tuple
14
+
15
+ import requests
16
  from PIL import Image, ImageFile, UnidentifiedImageError
17
  import gradio as gr
18
 
19
+ # ----------------------------------------------------------------------
20
+ # Optional: Mistral SDK – fall back to raw HTTP if not installed
21
+ # ----------------------------------------------------------------------
22
  try:
23
  from mistralai import Mistral
24
+ except Exception: # pragma: no cover
25
  Mistral = None
26
 
27
+ # ----------------------------------------------------------------------
28
+ # Configuration constants
29
+ # ----------------------------------------------------------------------
30
  DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
31
  PIXTRAL_MODEL = "pixtral-12b-2409"
32
  VIDEO_MODEL = "voxtral-mini-latest"
33
+ STREAM_THRESHOLD = 20 * 1024 * 1024 # 20 MiB
34
  FFMPEG_BIN = shutil.which("ffmpeg")
35
  IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif")
36
  VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
 
42
  "Produce a concise, narrative that blends scientific precision with erotic detail. Do not invent sensory information not present in the media."
43
  )
44
 
45
+ # ----------------------------------------------------------------------
46
+ # Pillow safety tweaks
47
+ # ----------------------------------------------------------------------
48
  ImageFile.LOAD_TRUNCATED_IMAGES = True
49
  Image.MAX_IMAGE_PIXELS = 10000 * 10000
50
 
51
+ # ----------------------------------------------------------------------
52
+ # Helper: Mistral client
53
+ # ----------------------------------------------------------------------
54
  def get_client(key: str | None = None):
55
  api_key = (key or "").strip() or DEFAULT_KEY
56
  if Mistral is None:
 
59
  return Dummy(api_key)
60
  return Mistral(api_key=api_key)
61
 
62
+
63
+ # ----------------------------------------------------------------------
64
+ # URL / file utilities
65
+ # ----------------------------------------------------------------------
66
  def is_remote(src: str) -> bool:
67
  return bool(src) and src.startswith(("http://", "https://"))
68
 
69
+
70
  def ext_from_src(src: str) -> str:
71
+ if not src:
72
+ return ""
73
  _, ext = os.path.splitext((src or "").split("?")[0])
74
  return ext.lower()
75
 
76
+
77
  def safe_head(url: str, timeout: int = 6):
78
  try:
79
  r = requests.head(url, timeout=timeout, allow_redirects=True)
80
+ if r.status_code >= 400:
81
+ return None
82
  return r
83
  except Exception:
84
  return None
85
 
86
+
87
  def safe_get(url: str, timeout: int = 15):
88
  r = requests.get(url, timeout=timeout)
89
  r.raise_for_status()
90
  return r
91
 
92
+
93
  def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int = 60) -> bytes:
94
+ """Download remote files or read local ones, streaming large objects."""
95
  if is_remote(src):
96
  head = safe_head(src)
97
  if head is not None:
 
105
  try:
106
  with open(p, "wb") as fh:
107
  for chunk in r.iter_content(8192):
108
+ if chunk:
109
+ fh.write(chunk)
110
+ with open(p, "rb") as fh:
111
+ return fh.read()
112
  finally:
113
+ try:
114
+ os.remove(p)
115
+ except Exception:
116
+ pass
117
  except Exception:
118
  pass
119
  r = safe_get(src, timeout=timeout)
120
  return r.content
121
  else:
122
+ with open(src, "rb") as f:
123
+ return f.read()
124
+
125
 
126
  def save_bytes_to_temp(b: bytes, suffix: str) -> str:
127
  fd, path = tempfile.mkstemp(suffix=suffix)
128
  os.close(fd)
129
+ with open(path, "wb") as f:
130
+ f.write(b)
131
  return path
132
 
133
+
134
+ # ----------------------------------------------------------------------
135
+ # Image preprocessing
136
+ # ----------------------------------------------------------------------
137
  def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
138
  img = Image.open(BytesIO(img_bytes))
139
  try:
140
+ if getattr(img, "is_animated", False):
141
+ img.seek(0)
142
  except Exception:
143
  pass
144
+ if img.mode != "RGB":
145
+ img = img.convert("RGB")
146
  h = base_h
147
  w = max(1, int(img.width * (h / img.height)))
148
  img = img.resize((w, h), Image.LANCZOS)
 
150
  img.save(buf, format="JPEG", quality=85)
151
  return buf.getvalue()
152
 
153
+
154
  def b64_bytes(b: bytes, mime: str = "image/jpeg") -> str:
155
  return f"data:{mime};base64," + base64.b64encode(b).decode("utf-8")
156
 
157
+
158
+ # ----------------------------------------------------------------------
159
+ # Video frame extraction (fallback)
160
+ # ----------------------------------------------------------------------
161
+ def extract_best_frames_bytes(
162
+ media_path: str, sample_count: int = 5, timeout_extract: int = 15
163
+ ) -> List[bytes]:
164
+ frames: List[bytes] = []
165
+ if not FFMPEG_BIN or not os.path.exists(media_path):
166
+ return frames
167
+ timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
168
+ for i, t in enumerate(timestamps):
169
+ fd, tmp = tempfile.mkstemp(suffix=f"_{i}.jpg")
170
+ os.close(fd)
171
+ cmd = [
172
+ FFMPEG_BIN,
173
+ "-nostdin",
174
+ "-y",
175
+ "-ss",
176
+ str(t),
177
+ "-i",
178
+ media_path,
179
+ "-frames:v",
180
+ "1",
181
+ "-q:v",
182
+ "2",
183
+ tmp,
184
+ ]
185
  try:
186
+ subprocess.run(
187
+ cmd,
188
+ stdout=subprocess.DEVNULL,
189
+ stderr=subprocess.DEVNULL,
190
+ timeout=timeout_extract,
191
+ )
192
  if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
193
+ with open(tmp, "rb") as f:
194
+ frames.append(f.read())
195
  except Exception:
196
  pass
197
  finally:
198
+ try:
199
+ os.remove(tmp)
200
+ except Exception:
201
+ pass
202
  return frames
203
 
204
+
205
+ # ----------------------------------------------------------------------
206
+ # Model interaction helpers
207
+ # ----------------------------------------------------------------------
208
  def chat_complete(client, model: str, messages, timeout: int = 120) -> str:
209
+ """Wrap SDK and raw‑HTTP calls to Mistral chat completions."""
210
  try:
211
  if hasattr(client, "chat") and hasattr(client.chat, "complete"):
212
  res = client.chat.complete(model=model, messages=messages, stream=False)
213
  else:
214
  api_key = getattr(client, "api_key", "") or DEFAULT_KEY
215
  url = "https://api.mistral.ai/v1/chat/completions"
216
+ headers = (
217
+ {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
218
+ if api_key
219
+ else {"Content-Type": "application/json"}
220
+ )
221
+ r = requests.post(
222
+ url,
223
+ json={"model": model, "messages": messages},
224
+ headers=headers,
225
+ timeout=timeout,
226
+ )
227
  r.raise_for_status()
228
  res = r.json()
229
  choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
230
+ if not choices:
231
+ return str(res)
232
  first = choices[0]
233
+ msg = (
234
+ first.message
235
+ if hasattr(first, "message")
236
+ else (first.get("message") if isinstance(first, dict) else first)
237
+ )
238
  content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
239
+ return content.strip() if isinstance(content, str) else str(content)
 
240
  except Exception as e:
241
  return f"Error during model call: {e}"
242
 
243
+
244
+ def upload_file_to_mistral(
245
+ client,
246
+ path: str,
247
+ filename: str | None = None,
248
+ purpose: str = "batch",
249
+ timeout: int = 120,
250
+ ) -> str:
251
+ """Upload a file to Mistral and return its file‑id."""
252
  fname = filename or os.path.basename(path)
253
+ # SDK path -------------------------------------------------
254
  try:
255
  if hasattr(client, "files") and hasattr(client.files, "upload"):
256
  with open(path, "rb") as fh:
257
+ res = client.files.upload(
258
+ file={"file_name": fname, "content": fh}, purpose=purpose
259
+ )
260
  fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
261
+ if not fid:
262
+ fid = res["data"][0]["id"]
263
  return fid
264
  except Exception: