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,508 @@
1
+ import pprint
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Any
5
+ from uuid import UUID
6
+
7
+ import orjson as json
8
+ import reflex as rx
9
+ import sqlalchemy as sa
10
+ from jims_core.db import ThreadEventDB
11
+ from jims_core.util import uuid7
12
+
13
+ from vedana_backoffice.states.common import get_vedana_app
14
+
15
+
16
+ @dataclass
17
+ class ThreadEventVis:
18
+ event_id: str
19
+ created_at: datetime
20
+ created_at_str: str
21
+ event_type: str
22
+ role: str
23
+ content: str
24
+ tags: list[str]
25
+ event_data_str: str
26
+ technical_vts_queries: list[str]
27
+ technical_cypher_queries: list[str]
28
+ technical_models: list[tuple[str, str]]
29
+ vts_str: str
30
+ cypher_str: str
31
+ models_str: str
32
+ has_technical_info: bool
33
+ has_vts: bool
34
+ has_cypher: bool
35
+ has_models: bool
36
+ # Aggregated annotations from jims.backoffice.* events
37
+ visible_tags: list[str] = field(default_factory=list)
38
+ feedback_comments: list[dict[str, Any]] = field(default_factory=list)
39
+ generic_meta: bool = False
40
+
41
+ @classmethod
42
+ def create(cls, event_id: Any, created_at: datetime, event_type: str, event_data: dict) -> "ThreadEventVis":
43
+ # Parse technical_info if present
44
+ tech: dict = event_data.get("technical_info", {})
45
+ has_technical_info = bool(tech)
46
+
47
+ # Extract message-like fields
48
+ # Role: Only comm.user_message is user; all others assistant.
49
+ role = "user" if event_type == "comm.user_message" else "assistant"
50
+ content: str = event_data.get("content", "")
51
+ # tags may be stored in event_data["tags"] as list[str]
52
+ tags_value = event_data.get("tags") # todo check fmt
53
+ tags: list[str] = list(tags_value or []) if isinstance(tags_value, (list, tuple)) else []
54
+
55
+ vts_queries: list[str] = list(tech.get("vts_queries", []) or [])
56
+ cypher_queries: list[str] = list(tech.get("cypher_queries", []) or [])
57
+
58
+ model_stats = tech.get("model_stats", {}) or tech.get("model_used", {}) or {}
59
+ models_list: list[tuple[str, str]] = []
60
+ try:
61
+ # If nested dict like {model: {...}} flatten to stringified value
62
+ for mk, mv in model_stats.items() if isinstance(model_stats, dict) else []:
63
+ try:
64
+ models_list.append((f'"{mk}"', json.dumps(mv, option=json.OPT_INDENT_2).decode()))
65
+ except Exception:
66
+ models_list.append((f'"{mk}"', str(mv)))
67
+ except Exception:
68
+ pass
69
+
70
+ vts_str = "\n".join(vts_queries)
71
+ cypher_str = "\n".join([pprint.pformat(x)[1:-1].replace("'", "") for x in cypher_queries]) # format to fit
72
+ models_str = "\n".join([f"{k}: {v}" for k, v in models_list])
73
+
74
+ # Show meta (event_data) for events that are NOT comm.* and NOT rag.query_processed
75
+ generic_meta = False
76
+ if not event_type.startswith("comm.") and event_type != "rag.query_processed":
77
+ generic_meta = True
78
+
79
+ return cls(
80
+ event_id=str(event_id),
81
+ created_at=created_at.replace(microsecond=0),
82
+ created_at_str=datetime.strftime(created_at, "%Y-%m-%d %H:%M:%S"),
83
+ event_type=event_type,
84
+ role=role,
85
+ content=content,
86
+ tags=tags,
87
+ event_data_str=json.dumps(event_data, option=json.OPT_INDENT_2).decode(),
88
+ technical_vts_queries=vts_queries,
89
+ technical_cypher_queries=cypher_queries,
90
+ technical_models=models_list,
91
+ vts_str=vts_str,
92
+ cypher_str=cypher_str,
93
+ models_str=models_str,
94
+ has_technical_info=has_technical_info,
95
+ has_vts=bool(vts_queries),
96
+ has_cypher=bool(cypher_queries),
97
+ has_models=bool(models_list),
98
+ visible_tags=list(tags),
99
+ feedback_comments=[],
100
+ generic_meta=generic_meta,
101
+ )
102
+
103
+
104
+ class ThreadViewState(rx.State):
105
+ loading: bool = True
106
+ events: list[ThreadEventVis] = []
107
+ new_tag_text: str = ""
108
+ note_text: str = ""
109
+ note_severity: str = "Low"
110
+ note_text_by_event: dict[str, str] = {}
111
+ note_severity_by_event: dict[str, str] = {}
112
+ selected_thread_id: str = ""
113
+ expanded_event_id: str = ""
114
+ tag_dialog_open_for_event: str = ""
115
+ selected_tags_for_event: dict[str, list[str]] = {}
116
+ new_tag_text_for_event: dict[str, str] = {}
117
+ available_tags: list[str] = []
118
+
119
+ async def _reload(self) -> None:
120
+ vedana_app = await get_vedana_app()
121
+
122
+ async with vedana_app.sessionmaker() as session:
123
+ stmt = (
124
+ sa.select(ThreadEventDB)
125
+ .where(
126
+ ThreadEventDB.thread_id == self.selected_thread_id,
127
+ )
128
+ .order_by(ThreadEventDB.created_at.asc())
129
+ )
130
+ all_events = (await session.execute(stmt)).scalars().all()
131
+
132
+ # Split base convo events vs backoffice annotations
133
+ base_events: list[Any] = []
134
+ backoffice_events: list[Any] = []
135
+ for ev in all_events:
136
+ etype = str(getattr(ev, "event_type", ""))
137
+ if etype.startswith("jims.backoffice."):
138
+ backoffice_events.append(ev)
139
+ elif etype.startswith("jims."):
140
+ # ignore other jims.* noise
141
+ continue
142
+ else:
143
+ base_events.append(ev)
144
+
145
+ # Prepare aggregations
146
+ # 1) Tags per original event
147
+ tags_by_event: dict[str, set[str]] = {}
148
+ for ev in base_events:
149
+ eid = str(getattr(ev, "event_id", ""))
150
+ try:
151
+ base_tags = getattr(ev, "event_data", {}).get("tags") or []
152
+ tags_by_event[eid] = set([str(t) for t in base_tags])
153
+ except Exception:
154
+ tags_by_event[eid] = set()
155
+
156
+ # Apply tag add/remove in chronological order
157
+ for ev in backoffice_events:
158
+ etype = str(getattr(ev, "event_type", ""))
159
+ edata = dict(getattr(ev, "event_data", {}) or {})
160
+ if etype == "jims.backoffice.tag_added":
161
+ tid = str(edata.get("target_event_id", ""))
162
+ tag = str(edata.get("tag", "")).strip()
163
+ if tid:
164
+ tags_by_event.setdefault(tid, set()).add(tag)
165
+ elif etype == "jims.backoffice.tag_removed":
166
+ tid = str(edata.get("target_event_id", ""))
167
+ tag = str(edata.get("tag", "")).strip()
168
+ if tid and tag:
169
+ try:
170
+ tags_by_event.setdefault(tid, set()).discard(tag)
171
+ except Exception:
172
+ pass
173
+
174
+ # 2) Comments per original event + status mapping
175
+ comments_by_event: dict[str, list[dict[str, Any]]] = {}
176
+ # status by comment id (event_id of feedback)
177
+ comment_status: dict[str, str] = {}
178
+ for ev in backoffice_events:
179
+ etype = str(getattr(ev, "event_type", ""))
180
+ if etype == "jims.backoffice.feedback":
181
+ edata = dict(getattr(ev, "event_data", {}) or {})
182
+ target = str(edata.get("event_id", ""))
183
+ if not target:
184
+ continue
185
+ note_text = str(edata.get("note", ""))
186
+ severity = str(edata.get("severity", "Low"))
187
+ created_at = getattr(ev, "created_at", datetime.utcnow()).replace(microsecond=0)
188
+ comments_by_event.setdefault(target, []).append(
189
+ {
190
+ "id": str(getattr(ev, "event_id", "")),
191
+ "note": note_text,
192
+ "severity": severity,
193
+ "created_at": datetime.strftime(created_at, "%Y-%m-%d %H:%M:%S"),
194
+ "status": "open",
195
+ }
196
+ )
197
+ elif etype in ("jims.backoffice.comment_resolved", "jims.backoffice.comment_closed"):
198
+ ed = dict(getattr(ev, "event_data", {}) or {})
199
+ cid = str(ed.get("comment_id", ""))
200
+ if not cid:
201
+ continue
202
+ comment_status[cid] = "resolved" if etype.endswith("comment_resolved") else "closed"
203
+
204
+ # Convert base events into visual items and attach aggregations
205
+ ev_items: list[ThreadEventVis] = []
206
+ for bev in base_events:
207
+ item = ThreadEventVis.create(
208
+ event_id=bev.event_id,
209
+ created_at=bev.created_at,
210
+ event_type=bev.event_type,
211
+ event_data=bev.event_data,
212
+ )
213
+ eid = item.event_id
214
+ try:
215
+ item.visible_tags = sorted(list(tags_by_event.get(eid, set())))
216
+ except Exception:
217
+ item.visible_tags = list(item.tags or [])
218
+ try:
219
+ cmts = []
220
+ for c in comments_by_event.get(eid, []) or []:
221
+ c = dict(c)
222
+ cid = str(c.get("id", ""))
223
+ if cid in comment_status:
224
+ c["status"] = comment_status[cid]
225
+ cmts.append(c)
226
+ item.feedback_comments = cmts
227
+ except Exception:
228
+ item.feedback_comments = []
229
+ ev_items.append(item)
230
+
231
+ # Present in chronological order as originally shown (created_at asc)
232
+ self.events = ev_items
233
+
234
+ # Collect all available tags from all threads
235
+ all_tags: set[str] = set()
236
+ # From current thread
237
+ for tags_set in tags_by_event.values():
238
+ all_tags.update(tags_set)
239
+
240
+ # From all threads in the database
241
+ try:
242
+ async with vedana_app.sessionmaker() as session:
243
+ # Query all tag_added events to get all tags ever used
244
+ tag_stmt = sa.select(ThreadEventDB.event_data).where(
245
+ ThreadEventDB.event_type == "jims.backoffice.tag_added"
246
+ )
247
+ tag_results = (await session.execute(tag_stmt)).scalars().all()
248
+ for edata in tag_results:
249
+ try:
250
+ ed = dict(edata or {})
251
+ tag = str(ed.get("tag", "")).strip()
252
+ if tag:
253
+ all_tags.add(tag)
254
+ except Exception:
255
+ pass
256
+ except Exception:
257
+ pass
258
+
259
+ self.available_tags = sorted(list(all_tags))
260
+ self.loading = False
261
+
262
+ @rx.event
263
+ async def get_data(self):
264
+ await self._reload()
265
+
266
+ @rx.event
267
+ async def select_thread(self, thread_id: str) -> None:
268
+ self.selected_thread_id = thread_id
269
+ await self._reload()
270
+
271
+ # UI field updates
272
+ @rx.event
273
+ def set_new_tag_text(self, value: str) -> None:
274
+ self.new_tag_text = value
275
+
276
+ @rx.event
277
+ def set_note_text(self, value: str) -> None:
278
+ self.note_text = value
279
+
280
+ @rx.event
281
+ def set_note_severity(self, value: str) -> None:
282
+ self.note_severity = value
283
+
284
+ @rx.event
285
+ def toggle_details(self, event_id: str) -> None:
286
+ self.expanded_event_id = "" if self.expanded_event_id == event_id else event_id
287
+
288
+ # Per-message note editing
289
+ # todo check if necessary or just keep jims sending only
290
+ @rx.event
291
+ def set_note_text_for(self, value: str, event_id: str) -> None:
292
+ self.note_text_by_event[event_id] = value
293
+
294
+ @rx.event
295
+ def set_note_severity_for(self, value: str, event_id: str) -> None:
296
+ self.note_severity_by_event[event_id] = value
297
+
298
+ # Tag dialog management
299
+ @rx.event
300
+ def open_tag_dialog(self, event_id: str) -> None:
301
+ """Open tag dialog for a specific event and initialize selected tags with current tags."""
302
+ self.tag_dialog_open_for_event = event_id
303
+ # Initialize selected tags with current visible tags for this event
304
+ current_event = next((e for e in self.events if e.event_id == event_id), None)
305
+ if current_event:
306
+ self.selected_tags_for_event[event_id] = list(current_event.visible_tags or [])
307
+ else:
308
+ self.selected_tags_for_event[event_id] = []
309
+
310
+ @rx.event
311
+ def close_tag_dialog(self) -> None:
312
+ """Close tag dialog and clear temporary state."""
313
+ event_id = self.tag_dialog_open_for_event
314
+ self.tag_dialog_open_for_event = ""
315
+ # Optionally clear temporary state
316
+ if event_id in self.new_tag_text_for_event:
317
+ del self.new_tag_text_for_event[event_id]
318
+
319
+ @rx.event
320
+ def handle_tag_dialog_open_change(self, is_open: bool) -> None:
321
+ """Handle dialog open/close state changes."""
322
+ if not is_open:
323
+ self.close_tag_dialog() # type: ignore[operator]
324
+
325
+ @rx.event
326
+ def set_new_tag_text_for_event(self, value: str, event_id: str) -> None:
327
+ """Set new tag text for a specific event."""
328
+ self.new_tag_text_for_event[event_id] = value
329
+
330
+ @rx.event
331
+ def toggle_tag_selection_for_event(self, tag: str, event_id: str, checked: bool) -> None:
332
+ """Toggle tag selection for a specific event."""
333
+ selected = self.selected_tags_for_event.get(event_id, [])
334
+ if checked:
335
+ if tag not in selected:
336
+ self.selected_tags_for_event[event_id] = [*selected, tag]
337
+ else:
338
+ self.selected_tags_for_event[event_id] = [t for t in selected if t != tag]
339
+
340
+ @rx.event
341
+ async def add_new_tag_to_available(self, event_id: str) -> None:
342
+ """Add a new tag to available tags list."""
343
+ new_tag = (self.new_tag_text_for_event.get(event_id) or "").strip()
344
+ if new_tag and new_tag not in self.available_tags:
345
+ self.available_tags = sorted([*self.available_tags, new_tag])
346
+ # Also add to selected tags for this event
347
+ selected = self.selected_tags_for_event.get(event_id, [])
348
+ if new_tag not in selected:
349
+ self.selected_tags_for_event[event_id] = [*selected, new_tag]
350
+ # Clear the input
351
+ self.new_tag_text_for_event[event_id] = ""
352
+
353
+ @rx.event
354
+ async def apply_tags_to_event(self, event_id: str):
355
+ """Apply selected tags to an event by adding/removing tags as needed."""
356
+ current_event = next((e for e in self.events if e.event_id == event_id), None)
357
+ if not current_event:
358
+ return
359
+
360
+ current_tags = set(current_event.visible_tags or [])
361
+ selected_tags = set(self.selected_tags_for_event.get(event_id, []))
362
+
363
+ tags_to_add = selected_tags - current_tags
364
+ tags_to_remove = current_tags - selected_tags
365
+
366
+ vedana_app = await get_vedana_app()
367
+ async with vedana_app.sessionmaker() as session:
368
+ thread_uuid = (
369
+ UUID(self.selected_thread_id) if isinstance(self.selected_thread_id, str) else self.selected_thread_id
370
+ )
371
+
372
+ for tag in tags_to_add:
373
+ tag_event = ThreadEventDB(
374
+ thread_id=thread_uuid,
375
+ event_id=uuid7(),
376
+ event_type="jims.backoffice.tag_added",
377
+ event_data={"target_event_id": event_id, "tag": tag},
378
+ )
379
+ session.add(tag_event)
380
+ await session.flush()
381
+
382
+ for tag in tags_to_remove:
383
+ tag_event = ThreadEventDB(
384
+ thread_id=thread_uuid,
385
+ event_id=uuid7(),
386
+ event_type="jims.backoffice.tag_removed",
387
+ event_data={"target_event_id": event_id, "tag": tag},
388
+ )
389
+ session.add(tag_event)
390
+ await session.flush()
391
+
392
+ await session.commit()
393
+
394
+ # Close dialog and reload
395
+ self.tag_dialog_open_for_event = ""
396
+ await self._reload()
397
+ try:
398
+ # local import to avoid cycles
399
+ from vedana_backoffice.pages.jims_thread_list_page import ThreadListState
400
+
401
+ yield ThreadListState.get_data() # type: ignore[operator]
402
+ except Exception:
403
+ pass
404
+
405
+ @rx.event
406
+ async def remove_tag(self, event_id: str, tag: str):
407
+ vedana_app = await get_vedana_app()
408
+ async with vedana_app.sessionmaker() as session:
409
+ tag_event = ThreadEventDB(
410
+ thread_id=self.selected_thread_id,
411
+ event_id=uuid7(),
412
+ event_type="jims.backoffice.tag_removed",
413
+ event_data={"target_event_id": event_id, "tag": tag},
414
+ )
415
+ session.add(tag_event)
416
+ await session.commit()
417
+ await self._reload()
418
+ try:
419
+ from vedana_backoffice.pages.jims_thread_list_page import ThreadListState # local import to avoid cycles
420
+
421
+ yield ThreadListState.get_data() # type: ignore[operator]
422
+ except Exception:
423
+ pass
424
+
425
+ @rx.event
426
+ async def submit_note_for(self, event_id: str):
427
+ text = (self.note_text_by_event.get(event_id) or "").strip()
428
+ if not text:
429
+ return
430
+ # Collect current tags from the target event if present
431
+ try:
432
+ target = next((e for e in self.events if e.event_id == event_id), None)
433
+ tags_list = list(getattr(target, "tags", []) or []) if target is not None else []
434
+ except Exception:
435
+ tags_list = []
436
+ vedana_app = await get_vedana_app()
437
+ async with vedana_app.sessionmaker() as session:
438
+ severity_val = self.note_severity_by_event.get(event_id, self.note_severity or "Low") # todo check
439
+ note_event = ThreadEventDB(
440
+ thread_id=self.selected_thread_id,
441
+ event_id=uuid7(),
442
+ event_type="jims.backoffice.feedback",
443
+ event_data={
444
+ "event_id": event_id,
445
+ "tags": tags_list,
446
+ "note": text,
447
+ "severity": severity_val,
448
+ },
449
+ )
450
+ session.add(note_event)
451
+ await session.commit()
452
+ try:
453
+ del self.note_text_by_event[event_id]
454
+ except Exception:
455
+ pass
456
+ try:
457
+ del self.note_severity_by_event[event_id]
458
+ except Exception:
459
+ pass
460
+ await self._reload()
461
+ try:
462
+ # todo check
463
+ from vedana_backoffice.pages.jims_thread_list_page import ThreadListState # local import
464
+
465
+ yield ThreadListState.get_data() # type: ignore[operator]
466
+ except Exception:
467
+ pass
468
+
469
+ # --- Comment status actions ---
470
+ @rx.event
471
+ async def mark_comment_resolved(self, comment_id: str):
472
+ vedana_app = await get_vedana_app()
473
+ async with vedana_app.sessionmaker() as session:
474
+ ev = ThreadEventDB(
475
+ thread_id=self.selected_thread_id,
476
+ event_id=uuid7(),
477
+ event_type="jims.backoffice.comment_resolved",
478
+ event_data={"comment_id": comment_id},
479
+ )
480
+ session.add(ev)
481
+ await session.commit()
482
+ await self._reload()
483
+ try:
484
+ from vedana_backoffice.pages.jims_thread_list_page import ThreadListState
485
+
486
+ yield ThreadListState.get_data() # type: ignore[operator]
487
+ except Exception:
488
+ pass
489
+
490
+ @rx.event
491
+ async def mark_comment_closed(self, comment_id: str):
492
+ vedana_app = await get_vedana_app()
493
+ async with vedana_app.sessionmaker() as session:
494
+ ev = ThreadEventDB(
495
+ thread_id=self.selected_thread_id,
496
+ event_id=uuid7(),
497
+ event_type="jims.backoffice.comment_closed",
498
+ event_data={"comment_id": comment_id},
499
+ )
500
+ session.add(ev)
501
+ await session.commit()
502
+ await self._reload()
503
+ try:
504
+ from vedana_backoffice.pages.jims_thread_list_page import ThreadListState
505
+
506
+ yield ThreadListState.get_data() # type: ignore[operator]
507
+ except Exception:
508
+ pass