vedana-backoffice 0.1.0__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.
@@ -0,0 +1,368 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import traceback
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Iterable, Tuple
7
+ from uuid import UUID, uuid4
8
+
9
+ import orjson as json
10
+ import reflex as rx
11
+ import requests
12
+ from datapipe.compute import Catalog, run_pipeline
13
+ from jims_core.llms.llm_provider import env_settings as llm_settings
14
+ from jims_core.thread.thread_controller import ThreadController
15
+ from jims_core.util import uuid7
16
+ from vedana_core.settings import settings as core_settings
17
+ from vedana_etl.app import app as etl_app
18
+ from vedana_etl.pipeline import get_data_model_pipeline
19
+
20
+ from vedana_backoffice.states.common import MemLogger, get_vedana_app
21
+ from vedana_backoffice.states.jims import ThreadViewState
22
+
23
+
24
+ class ChatState(rx.State):
25
+ """Minimal chatbot with per-answer technical details."""
26
+
27
+ input_text: str = ""
28
+ is_running: bool = False
29
+ messages: list[dict[str, Any]] = []
30
+ chat_thread_id: str = ""
31
+ data_model_text: str = ""
32
+ is_refreshing_dm: bool = False
33
+ provider: str = "openai" # default llm provider
34
+ model: str = core_settings.model
35
+ _default_models: tuple[str, ...] = (
36
+ "gpt-5.1-chat-latest",
37
+ "gpt-5.1",
38
+ "gpt-5-chat-latest",
39
+ "gpt-5",
40
+ "gpt-5-mini",
41
+ "gpt-5-nano",
42
+ "gpt-4.1",
43
+ "gpt-4.1-mini",
44
+ "gpt-4.1-nano",
45
+ "gpt-4o",
46
+ "gpt-4o-mini",
47
+ "o4-mini",
48
+ )
49
+ custom_openrouter_key: str = ""
50
+ default_openrouter_key_present: bool = bool(os.environ.get("OPENROUTER_API_KEY")) # to require api_key input
51
+ openai_models: list[str] = list(set(list(_default_models) + [core_settings.model]))
52
+ openrouter_models: list[str] = []
53
+ available_models: list[str] = list(set(list(_default_models) + [core_settings.model]))
54
+ enable_dm_filtering: bool = bool(os.environ.get("ENABLE_DM_FILTERING", False))
55
+
56
+ async def mount(self) -> None:
57
+ self.fetch_openrouter_models()
58
+ self._sync_available_models()
59
+
60
+ def set_input(self, value: str) -> None:
61
+ self.input_text = value
62
+
63
+ def set_model(self, value: str) -> None:
64
+ if value in self.available_models:
65
+ self.model = value
66
+
67
+ def set_custom_openrouter_key(self, value: str) -> None:
68
+ self.custom_openrouter_key = value # need validating?
69
+
70
+ def set_enable_dm_filtering(self, value: bool) -> None:
71
+ self.enable_dm_filtering = value
72
+
73
+ def set_provider(self, value: str) -> None:
74
+ self.provider = value
75
+ self._sync_available_models()
76
+
77
+ def _filter_chat_capable(self, models: Iterable[dict]) -> list[str]:
78
+ result: list[str] = []
79
+ for m in models:
80
+ model_id = str(m.get("id", "")).strip()
81
+ if not model_id:
82
+ continue
83
+
84
+ has_chat = False
85
+ architecture = m.get("architecture", {})
86
+ if architecture:
87
+ if "text" in architecture.get("input_modalities", []) and "text" in architecture.get(
88
+ "output_modalities", []
89
+ ):
90
+ has_chat = True
91
+
92
+ has_tools = False # only accept models with tool calls
93
+ if "tools" in m.get("supported_parameters", []):
94
+ has_tools = True
95
+
96
+ if has_chat and has_tools:
97
+ result.append(model_id)
98
+
99
+ return result
100
+
101
+ def fetch_openrouter_models(self) -> None:
102
+ try:
103
+ resp = requests.get(
104
+ f"{llm_settings.openrouter_api_base_url}/models",
105
+ # headers={"Authorization": f"Bearer {openrouter_api_key}"}, # actually works without a token as well
106
+ timeout=10,
107
+ )
108
+ resp.raise_for_status()
109
+ payload = resp.json()
110
+ models = payload.get("data", [])
111
+ parsed = self._filter_chat_capable(models)
112
+ except Exception as exc: # pragma: no cover - best effort
113
+ logging.warning(f"Failed to fetch OpenRouter models: {exc}")
114
+ parsed = []
115
+ self.openrouter_models = sorted(list(parsed))
116
+
117
+ def _sync_available_models(self) -> None:
118
+ """
119
+ Recompute available_models based on selected provider, and realign
120
+ the selected model if it is no longer valid.
121
+ """
122
+
123
+ if self.provider == "openrouter":
124
+ models = self.openrouter_models
125
+ if not models:
126
+ self.provider = "openai"
127
+ models = self.openai_models
128
+ else:
129
+ models = self.openai_models
130
+
131
+ self.available_models = list(models)
132
+
133
+ if self.model not in self.available_models and self.available_models:
134
+ self.model = self.available_models[0]
135
+
136
+ def toggle_details_by_id(self, message_id: str) -> None:
137
+ for idx, m in enumerate(self.messages):
138
+ if str(m.get("id")) == str(message_id):
139
+ msg = dict(m)
140
+ msg["show_details"] = not bool(msg.get("show_details"))
141
+ self.messages[idx] = msg
142
+ break
143
+
144
+ def reset_session(self) -> None:
145
+ """Clear current chat history and start a fresh session (new thread on next send)."""
146
+ self.messages = []
147
+ self.input_text = ""
148
+ self.chat_thread_id = ""
149
+
150
+ @rx.var
151
+ def total_conversation_cost(self) -> float:
152
+ """Calculate total cost by summing requests_cost from all assistant messages."""
153
+ total = 0.0
154
+ for msg in self.messages:
155
+ if msg.get("is_assistant") and msg.get("model_stats"):
156
+ model_stats = msg.get("model_stats", {})
157
+ if isinstance(model_stats, dict):
158
+ # model_stats can be {model_name: {requests_cost: value, ...}}
159
+ for model_name, stats in model_stats.items():
160
+ if isinstance(stats, dict):
161
+ cost = stats.get("requests_cost")
162
+ if cost is not None:
163
+ try:
164
+ total += float(cost)
165
+ except (ValueError, TypeError):
166
+ pass
167
+ return total
168
+
169
+ @rx.var
170
+ def total_conversation_cost_str(self) -> str:
171
+ """Formatted string representation of total conversation cost."""
172
+ cost = self.total_conversation_cost
173
+ if cost > 0:
174
+ return f"${cost:.6f}"
175
+ return ""
176
+
177
+ def _append_message(
178
+ self,
179
+ role: str,
180
+ content: str,
181
+ technical_info: dict[str, Any] | None = None,
182
+ debug_logs: str | None = None,
183
+ ) -> None:
184
+ message: dict[str, Any] = {
185
+ "id": str(uuid4()),
186
+ "role": role,
187
+ "content": content,
188
+ "created_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
189
+ "is_assistant": role == "assistant",
190
+ "has_tech": False,
191
+ "show_details": False,
192
+ "tags": [],
193
+ "comments": [],
194
+ }
195
+
196
+ if technical_info:
197
+ vts_list = [str(x) for x in technical_info.get("vts_queries") or []]
198
+ cypher_list = [str(x) for x in technical_info.get("cypher_queries") or []]
199
+ models_raw = technical_info.get("model_stats", {})
200
+ model_pairs: list[tuple[str, str]] = []
201
+ if isinstance(models_raw, dict):
202
+ for mk, mv in models_raw.items():
203
+ try:
204
+ model_pairs.append((str(mk), json.dumps(mv).decode()))
205
+ except Exception:
206
+ model_pairs.append((str(mk), str(mv)))
207
+ models_list = [f"{k}: {v}" for k, v in model_pairs]
208
+
209
+ message["vts_str"] = "\n".join(vts_list)
210
+ message["cypher_str"] = "\n".join(cypher_list)
211
+ message["models_str"] = "\n".join(models_list)
212
+ message["has_vts"] = bool(vts_list)
213
+ message["has_cypher"] = bool(cypher_list)
214
+ message["has_models"] = bool(models_list)
215
+ message["has_tech"] = bool(vts_list or cypher_list or models_list)
216
+ message["model_stats"] = models_raw
217
+
218
+ logs = (debug_logs or "").replace("\r\n", "\n").rstrip()
219
+ message["logs_str"] = logs
220
+ message["has_logs"] = bool(logs)
221
+
222
+ self.messages.append(message)
223
+
224
+ async def _ensure_thread(self) -> str:
225
+ vedana_app = await get_vedana_app()
226
+ try:
227
+ existing = await ThreadController.from_thread_id(vedana_app.sessionmaker, UUID(self.chat_thread_id))
228
+ except Exception:
229
+ existing = None
230
+
231
+ if existing is None:
232
+ thread_id = uuid7()
233
+ ctl = await ThreadController.new_thread(
234
+ vedana_app.sessionmaker,
235
+ contact_id=f"reflex:{thread_id}",
236
+ thread_id=thread_id,
237
+ thread_config={"interface": "reflex"},
238
+ )
239
+ return str(ctl.thread.thread_id)
240
+
241
+ return self.chat_thread_id
242
+
243
+ async def _run_message(self, thread_id: str, user_text: str) -> Tuple[str, Dict[str, Any], str]:
244
+ vedana_app = await get_vedana_app()
245
+ try:
246
+ tid = UUID(thread_id)
247
+ except Exception:
248
+ tid = uuid7()
249
+
250
+ ctl = await ThreadController.from_thread_id(vedana_app.sessionmaker, tid)
251
+ if ctl is None:
252
+ thr_id = uuid7()
253
+ ctl = await ThreadController.new_thread(
254
+ vedana_app.sessionmaker,
255
+ contact_id=f"reflex:{thr_id}",
256
+ thread_id=thr_id,
257
+ thread_config={"interface": "reflex"},
258
+ )
259
+
260
+ mem_logger = MemLogger("rag_debug", level=logging.DEBUG)
261
+
262
+ await ctl.store_user_message(uuid7(), user_text)
263
+
264
+ pipeline = vedana_app.pipeline
265
+ pipeline.logger = mem_logger
266
+ pipeline.model = f"{self.provider}/{self.model}"
267
+ pipeline.enable_filtering = self.enable_dm_filtering
268
+
269
+ ctx = await ctl.make_context()
270
+
271
+ # override model api_key if custom api_key is provided
272
+ if self.custom_openrouter_key and self.provider == "openrouter":
273
+ ctx.llm.model_api_key = self.custom_openrouter_key
274
+ # embeddings model is not customisable in chat, it's configured on project level.
275
+
276
+ events = await ctl.run_pipeline_with_context(pipeline, ctx)
277
+
278
+ answer: str = ""
279
+ tech: dict[str, Any] = {}
280
+ for ev in events:
281
+ if ev.event_type == "comm.assistant_message":
282
+ answer = str(ev.event_data.get("content", ""))
283
+ elif ev.event_type == "rag.query_processed":
284
+ tech = dict(ev.event_data.get("technical_info", {}))
285
+
286
+ logs = mem_logger.get_logs()
287
+ return answer, tech, logs
288
+
289
+ # mypy raises error: "EventNamespace" not callable [operator], though event definition is according to reflex docs
290
+ @rx.event(background=True) # type: ignore[operator]
291
+ async def send_background(self, user_text: str):
292
+ """This runs in background (non-blocking), after send() submission."""
293
+ # Need context to modify state safely
294
+ async with self:
295
+ try:
296
+ thread_id = await self._ensure_thread()
297
+ answer, tech, logs = await self._run_message(thread_id, user_text)
298
+ # update shared session thread id
299
+ self.chat_thread_id = thread_id
300
+ except Exception as e:
301
+ answer, tech, logs = (f"Error: {e}", {}, traceback.format_exc())
302
+
303
+ self._append_message("assistant", answer, technical_info=tech, debug_logs=logs)
304
+ self.is_running = False
305
+
306
+ def send(self):
307
+ """form submit / button click"""
308
+ if self.is_running:
309
+ return
310
+
311
+ user_text = (self.input_text or "").strip()
312
+ if not user_text:
313
+ return
314
+
315
+ self._append_message("user", user_text)
316
+ self.input_text = ""
317
+ self.is_running = True
318
+ yield # trigger UI update
319
+
320
+ yield ChatState.send_background(user_text)
321
+
322
+ def open_jims_thread(self):
323
+ """Open the JIMS page and preselect the current chat thread."""
324
+ if not (self.chat_thread_id or "").strip():
325
+ return
326
+
327
+ # First set selection on JIMS state, then navigate to /jims
328
+ yield ThreadViewState.select_thread(thread_id=self.chat_thread_id) # type: ignore[operator]
329
+ yield rx.redirect("/jims") # type: ignore[operator]
330
+
331
+ @rx.event(background=True) # type: ignore[operator]
332
+ async def load_data_model_text(self):
333
+ async with self:
334
+ va = await get_vedana_app()
335
+ try:
336
+ self.data_model_text = await va.data_model.to_text_descr()
337
+ except Exception:
338
+ self.data_model_text = "(failed to load data model text)"
339
+
340
+ self.is_refreshing_dm = False # make the update button available
341
+ yield
342
+
343
+ def reload_data_model(self):
344
+ """Connecting button with a background task. Used to trigger animations properly."""
345
+ if self.is_refreshing_dm:
346
+ return
347
+ self.is_refreshing_dm = True
348
+ yield
349
+ yield ChatState.reload_data_model_background()
350
+
351
+ @rx.event(background=True) # type: ignore[operator]
352
+ async def reload_data_model_background(self):
353
+ try:
354
+ """Reload the data model by running all data_model_steps from the pipeline."""
355
+ await asyncio.to_thread(run_pipeline, etl_app.ds, Catalog({}), get_data_model_pipeline())
356
+ async with self:
357
+ va = await get_vedana_app()
358
+ self.data_model_text = await va.data_model.to_text_descr()
359
+ yield rx.toast.success("Data model reloaded")
360
+ except Exception as e:
361
+ async with self:
362
+ error_msg = str(e)
363
+ self.data_model_text = f"(error reloading data model: {error_msg})"
364
+ yield rx.toast.error(f"Failed to reload data model\n{error_msg}")
365
+ finally:
366
+ async with self:
367
+ self.is_refreshing_dm = False
368
+ yield
@@ -0,0 +1,66 @@
1
+ import io
2
+ import logging
3
+ import os
4
+
5
+ import reflex as rx
6
+ import requests
7
+ from vedana_core.app import VedanaApp, make_vedana_app
8
+
9
+ vedana_app: VedanaApp | None = None
10
+
11
+ EVAL_ENABLED = bool(os.environ.get("GRIST_TEST_SET_DOC_ID"))
12
+
13
+
14
+ async def get_vedana_app():
15
+ global vedana_app
16
+ if vedana_app is None:
17
+ vedana_app = await make_vedana_app()
18
+ return vedana_app
19
+
20
+
21
+ class MemLogger(logging.Logger):
22
+ """Logger that captures logs to a string buffer for debugging purposes."""
23
+
24
+ def __init__(self, name: str, level: int = 0) -> None:
25
+ super().__init__(name, level)
26
+ self.parent = logging.getLogger(__name__)
27
+ self._buf = io.StringIO()
28
+ handler = logging.StreamHandler(self._buf)
29
+ handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
30
+ self.addHandler(handler)
31
+
32
+ def get_logs(self) -> str:
33
+ return self._buf.getvalue()
34
+
35
+ def clear(self) -> None:
36
+ self._buf.truncate(0)
37
+ self._buf.seek(0)
38
+
39
+
40
+ class AppVersionState(rx.State):
41
+ version: str = f"`{os.environ.get('VERSION', 'unspecified_version')}`" # md-formatted
42
+ eval_enabled: bool = EVAL_ENABLED
43
+
44
+
45
+ class TelegramBotState(rx.State):
46
+ """State for Telegram bot information."""
47
+
48
+ bot_username: str = ""
49
+ bot_url: str = ""
50
+ has_bot: bool = False
51
+
52
+ def load_bot_info(self) -> None:
53
+ token = os.environ.get("TELEGRAM_BOT_TOKEN")
54
+ if not token:
55
+ return
56
+
57
+ try:
58
+ bot_status = requests.get(f"https://api.telegram.org/bot{token}/getMe")
59
+ if bot_status.status_code == 200:
60
+ bot_status = bot_status.json()
61
+ if bot_status["ok"]:
62
+ self.bot_username = bot_status["result"]["username"]
63
+ self.bot_url = f"https://t.me/{self.bot_username}"
64
+ self.has_bot = True
65
+ except Exception as e:
66
+ logging.warning(f"Failed to load Telegram bot info: {e}")