mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
3315 lines
140 KiB
Python
3315 lines
140 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import uuid
|
|
import datetime
|
|
import functools
|
|
import json
|
|
import sqlalchemy
|
|
from sqlalchemy.dialects import postgresql as postgresql_dialect
|
|
from sqlalchemy.dialects import sqlite as sqlite_dialect
|
|
|
|
from ....core import app
|
|
from ....entity.persistence import agent as persistence_agent
|
|
from ....entity.persistence import monitoring as persistence_monitoring
|
|
from ....entity.persistence import agent_run as persistence_agent_run
|
|
from ....entity.persistence import agent_interaction as persistence_agent_interaction
|
|
from ....entity.persistence.event_log import EventLog
|
|
from ..authz import WorkspaceRequiredError
|
|
from ..context import ExecutionContext
|
|
from .tenant import TenantContext, require_workspace_uuid
|
|
from .monitoring_execution_details import ExecutionDetailsMixin
|
|
|
|
|
|
_DEFAULT_MONITORING_PAGE_ROWS = 1000
|
|
_DEFAULT_MONITORING_EXPORT_ROWS = 10000
|
|
_DEFAULT_MONITORING_DETAIL_ROWS = 2000
|
|
_DEFAULT_MONITORING_TIMESERIES_BUCKETS = 1000
|
|
_DEFAULT_MONITORING_MAX_OFFSET = 1000000
|
|
_HARD_MAX_MONITORING_PAGE_ROWS = 5000
|
|
_HARD_MAX_MONITORING_EXPORT_ROWS = 50000
|
|
_HARD_MAX_MONITORING_DETAIL_ROWS = 10000
|
|
_HARD_MAX_MONITORING_TIMESERIES_BUCKETS = 10000
|
|
_HARD_MAX_MONITORING_OFFSET = 10000000
|
|
_DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
|
|
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
|
|
|
|
# Canonical execution status groups shared by the agent ledger and pipeline query
|
|
# records so both object types can be summarised with one vocabulary.
|
|
_AGENT_STATUS_GROUP = {
|
|
'completed': 'completed',
|
|
'failed': 'failed',
|
|
'timeout': 'failed',
|
|
'cancelled': 'cancelled',
|
|
'created': 'queued',
|
|
'queued': 'queued',
|
|
'claimed': 'running',
|
|
'running': 'running',
|
|
}
|
|
_PIPELINE_STATUS_GROUP = {
|
|
'success': 'completed',
|
|
'error': 'failed',
|
|
'pending': 'running',
|
|
'discarded': 'ignored',
|
|
'not_matched': 'ignored',
|
|
}
|
|
|
|
|
|
def _status_group(status: str, source: str) -> str:
|
|
table = _AGENT_STATUS_GROUP if source == 'agent' else _PIPELINE_STATUS_GROUP
|
|
return table.get(status, status)
|
|
|
|
|
|
def _epoch_ms(value: datetime.datetime | None) -> int | None:
|
|
"""Epoch milliseconds for a DB timestamp, treating naive values as UTC."""
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=datetime.timezone.utc)
|
|
return int(round(value.timestamp() * 1000))
|
|
|
|
|
|
def _percentile_ms(values: list[float], fraction: float) -> int | None:
|
|
"""Nearest-rank percentile (ms) over a non-empty, prefix-sorted list."""
|
|
if not values:
|
|
return None
|
|
rank = max(1, min(len(values), int(round(fraction * len(values)))))
|
|
return int(round(values[rank - 1]))
|
|
|
|
|
|
def _collect_text(node, out: list[str], depth: int = 0) -> None:
|
|
"""Best-effort extraction of human text from a serialised message chain."""
|
|
if depth > 6:
|
|
return
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
if key == 'text' and isinstance(value, str):
|
|
out.append(value)
|
|
elif isinstance(value, (dict, list)):
|
|
_collect_text(value, out, depth + 1)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
_collect_text(item, out, depth + 1)
|
|
|
|
|
|
def _message_preview(raw: str | None, limit: int = 160) -> str:
|
|
if not raw:
|
|
return ''
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return raw[:limit]
|
|
parts: list[str] = []
|
|
_collect_text(parsed, parts)
|
|
text = ' '.join(part.strip() for part in parts if part.strip())
|
|
return (text or raw)[:limit]
|
|
|
|
|
|
def _normalize_user_id(value: str | int | None) -> str | None:
|
|
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
|
|
|
|
Opaque string IDs (including whitespace and leading zeros) and missing
|
|
IDs must remain unchanged. Do not silently stringify unsupported objects.
|
|
"""
|
|
if value is None or isinstance(value, str):
|
|
return value
|
|
if isinstance(value, int) and not isinstance(value, bool):
|
|
return str(value)
|
|
raise TypeError('user_id must be a string, integer, or None')
|
|
|
|
|
|
def _workspace_transaction(method):
|
|
"""Run an explicit service entrypoint in one Workspace transaction."""
|
|
|
|
@functools.wraps(method)
|
|
async def wrapped(self, context, *args, **kwargs):
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
|
if callable(tenant_uow):
|
|
async with tenant_uow(workspace_uuid):
|
|
return await method(self, context, *args, **kwargs)
|
|
return await method(self, context, *args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
|
|
class MonitoringService(ExecutionDetailsMixin):
|
|
"""Monitoring service"""
|
|
|
|
ap: app.Application
|
|
|
|
def __init__(self, ap: app.Application) -> None:
|
|
self.ap = ap
|
|
|
|
def _configured_query_limit(self, name: str, default: int, hard_max: int) -> int:
|
|
config = (
|
|
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('monitoring', {}).get('query_limits', {})
|
|
)
|
|
try:
|
|
value = int(config.get(name, default))
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
return min(max(value, 1), hard_max)
|
|
|
|
def normalize_page_window(self, limit: int, offset: int = 0) -> tuple[int, int]:
|
|
"""Clamp tenant-controlled pagination before constructing a DB query."""
|
|
|
|
page_cap = self._configured_query_limit(
|
|
'page_rows',
|
|
_DEFAULT_MONITORING_PAGE_ROWS,
|
|
_HARD_MAX_MONITORING_PAGE_ROWS,
|
|
)
|
|
offset_cap = self._configured_query_limit(
|
|
'max_offset',
|
|
_DEFAULT_MONITORING_MAX_OFFSET,
|
|
_HARD_MAX_MONITORING_OFFSET,
|
|
)
|
|
try:
|
|
normalized_limit = int(limit)
|
|
except (TypeError, ValueError):
|
|
normalized_limit = 100
|
|
try:
|
|
normalized_offset = int(offset)
|
|
except (TypeError, ValueError):
|
|
normalized_offset = 0
|
|
return (
|
|
min(max(normalized_limit, 1), page_cap),
|
|
min(max(normalized_offset, 0), offset_cap),
|
|
)
|
|
|
|
def normalize_export_limit(self, limit: int) -> int:
|
|
"""Clamp exports that are currently materialized as an in-memory list."""
|
|
|
|
export_cap = self._configured_query_limit(
|
|
'export_rows',
|
|
_DEFAULT_MONITORING_EXPORT_ROWS,
|
|
_HARD_MAX_MONITORING_EXPORT_ROWS,
|
|
)
|
|
try:
|
|
normalized = int(limit)
|
|
except (TypeError, ValueError):
|
|
normalized = _DEFAULT_MONITORING_EXPORT_ROWS
|
|
return min(max(normalized, 1), export_cap)
|
|
|
|
def _detail_limit(self) -> int:
|
|
return self._configured_query_limit(
|
|
'detail_rows',
|
|
_DEFAULT_MONITORING_DETAIL_ROWS,
|
|
_HARD_MAX_MONITORING_DETAIL_ROWS,
|
|
)
|
|
|
|
def _timeseries_bucket_limit(self) -> int:
|
|
return self._configured_query_limit(
|
|
'timeseries_buckets',
|
|
_DEFAULT_MONITORING_TIMESERIES_BUCKETS,
|
|
_HARD_MAX_MONITORING_TIMESERIES_BUCKETS,
|
|
)
|
|
|
|
@staticmethod
|
|
def _token_bucket_expression(
|
|
timestamp_column: sqlalchemy.Column,
|
|
*,
|
|
bucket: str,
|
|
dialect_name: str,
|
|
):
|
|
"""Build a server-side hour/day bucket for supported business databases."""
|
|
|
|
if bucket not in {'hour', 'day'}:
|
|
bucket = 'hour'
|
|
if dialect_name == 'postgresql':
|
|
return sqlalchemy.func.date_trunc(bucket, timestamp_column)
|
|
if dialect_name == 'sqlite':
|
|
bucket_format = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
|
|
return sqlalchemy.func.strftime(bucket_format, timestamp_column)
|
|
raise RuntimeError(f'Unsupported monitoring database dialect: {dialect_name}')
|
|
|
|
@staticmethod
|
|
def _require_write_context(context: ExecutionContext | None) -> str:
|
|
"""Reject background/runtime writes that lost their execution fence."""
|
|
|
|
if not isinstance(context, ExecutionContext):
|
|
raise WorkspaceRequiredError('Monitoring writes require an ExecutionContext')
|
|
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
|
raise WorkspaceRequiredError('Monitoring writes require an instance and Workspace')
|
|
if context.placement_generation <= 0:
|
|
raise WorkspaceRequiredError('Monitoring writes require a positive placement generation')
|
|
return context.workspace_uuid
|
|
|
|
# ========== Cleanup Methods ==========
|
|
|
|
async def cleanup_expired_records(
|
|
self,
|
|
context: ExecutionContext,
|
|
retention_days: int,
|
|
batch_size: int = 1000,
|
|
max_batches_per_table: int | None = None,
|
|
) -> dict[str, int]:
|
|
"""Delete monitoring records older than the specified retention period.
|
|
|
|
Args:
|
|
retention_days: Number of days to retain records.
|
|
batch_size: Maximum rows to delete per table batch.
|
|
|
|
Returns:
|
|
A dict mapping table name to the number of deleted rows.
|
|
"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
if retention_days < 1:
|
|
raise ValueError('retention_days must be >= 1')
|
|
if batch_size < 1:
|
|
raise ValueError('batch_size must be >= 1')
|
|
if max_batches_per_table is None:
|
|
cleanup_config = (
|
|
getattr(getattr(self.ap, 'instance_config', None), 'data', {})
|
|
.get('monitoring', {})
|
|
.get('auto_cleanup', {})
|
|
)
|
|
max_batches_per_table = cleanup_config.get(
|
|
'max_batches_per_table_per_run',
|
|
_DEFAULT_CLEANUP_BATCHES_PER_TABLE,
|
|
)
|
|
try:
|
|
max_batches_per_table = int(max_batches_per_table)
|
|
except (TypeError, ValueError):
|
|
max_batches_per_table = _DEFAULT_CLEANUP_BATCHES_PER_TABLE
|
|
max_batches_per_table = min(
|
|
max(max_batches_per_table, 1),
|
|
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE,
|
|
)
|
|
|
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
|
days=retention_days
|
|
)
|
|
|
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
|
(
|
|
'monitoring_messages',
|
|
persistence_monitoring.MonitoringMessage,
|
|
persistence_monitoring.MonitoringMessage.timestamp,
|
|
persistence_monitoring.MonitoringMessage.id,
|
|
),
|
|
(
|
|
'monitoring_llm_calls',
|
|
persistence_monitoring.MonitoringLLMCall,
|
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
|
persistence_monitoring.MonitoringLLMCall.id,
|
|
),
|
|
(
|
|
'monitoring_tool_calls',
|
|
persistence_monitoring.MonitoringToolCall,
|
|
persistence_monitoring.MonitoringToolCall.timestamp,
|
|
persistence_monitoring.MonitoringToolCall.id,
|
|
),
|
|
(
|
|
'monitoring_embedding_calls',
|
|
persistence_monitoring.MonitoringEmbeddingCall,
|
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
|
persistence_monitoring.MonitoringEmbeddingCall.id,
|
|
),
|
|
(
|
|
'monitoring_errors',
|
|
persistence_monitoring.MonitoringError,
|
|
persistence_monitoring.MonitoringError.timestamp,
|
|
persistence_monitoring.MonitoringError.id,
|
|
),
|
|
(
|
|
'monitoring_sessions',
|
|
persistence_monitoring.MonitoringSession,
|
|
persistence_monitoring.MonitoringSession.last_activity,
|
|
persistence_monitoring.MonitoringSession.session_id,
|
|
),
|
|
(
|
|
'monitoring_feedback',
|
|
persistence_monitoring.MonitoringFeedback,
|
|
persistence_monitoring.MonitoringFeedback.timestamp,
|
|
persistence_monitoring.MonitoringFeedback.id,
|
|
),
|
|
]
|
|
|
|
async def delete_records() -> dict[str, int]:
|
|
deleted_counts: dict[str, int] = {}
|
|
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
|
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
|
context=context,
|
|
model_cls=model_cls,
|
|
ts_column=ts_column,
|
|
pk_column=pk_column,
|
|
cutoff=cutoff,
|
|
batch_size=batch_size,
|
|
max_batches=max_batches_per_table,
|
|
)
|
|
return deleted_counts
|
|
|
|
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
|
if callable(tenant_scope):
|
|
# Carry the Workspace across the complete cleanup without holding a
|
|
# connection. Each select+delete batch opens and commits its own UoW.
|
|
async with tenant_scope(workspace_uuid):
|
|
deleted_counts = await delete_records()
|
|
else:
|
|
deleted_counts = await delete_records()
|
|
|
|
if sum(deleted_counts.values()) > 0:
|
|
await self._release_sqlite_space()
|
|
|
|
return deleted_counts
|
|
|
|
async def _delete_expired_in_batches(
|
|
self,
|
|
context: ExecutionContext,
|
|
model_cls: type,
|
|
ts_column: sqlalchemy.Column,
|
|
pk_column: sqlalchemy.Column,
|
|
cutoff: datetime.datetime,
|
|
batch_size: int,
|
|
max_batches: int,
|
|
) -> int:
|
|
workspace_uuid = self._require_write_context(context)
|
|
deleted_total = 0
|
|
|
|
for _batch_number in range(max_batches):
|
|
|
|
async def delete_batch() -> tuple[int, int]:
|
|
key_columns = list(model_cls.__table__.primary_key.columns)
|
|
select_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(*key_columns)
|
|
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
|
.limit(batch_size)
|
|
)
|
|
pk_values = [tuple(row) for row in select_result.all()]
|
|
if not pk_values:
|
|
return 0, 0
|
|
|
|
delete_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.delete(model_cls).where(
|
|
model_cls.workspace_uuid == workspace_uuid,
|
|
sqlalchemy.tuple_(*key_columns).in_(pk_values),
|
|
ts_column < cutoff,
|
|
)
|
|
)
|
|
return len(pk_values), int(delete_result.rowcount or 0)
|
|
|
|
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
|
if callable(tenant_uow):
|
|
async with tenant_uow(workspace_uuid):
|
|
selected, deleted = await delete_batch()
|
|
else:
|
|
selected, deleted = await delete_batch()
|
|
|
|
deleted_total += deleted
|
|
if selected == 0:
|
|
break
|
|
if selected < batch_size:
|
|
break
|
|
|
|
return deleted_total
|
|
|
|
async def _release_sqlite_space(self) -> None:
|
|
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
|
if database_type != 'sqlite':
|
|
return
|
|
|
|
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
|
|
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
|
|
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
|
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
|
|
|
def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None:
|
|
"""Serialize tool arguments/results for monitoring storage."""
|
|
if payload is None:
|
|
return None
|
|
|
|
if isinstance(payload, str):
|
|
text = payload
|
|
else:
|
|
try:
|
|
text = json.dumps(payload, ensure_ascii=False, default=str)
|
|
except Exception:
|
|
text = str(payload)
|
|
|
|
if len(text) <= max_length:
|
|
return text
|
|
|
|
return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]'
|
|
|
|
async def _get_message_for_tool_context(
|
|
self,
|
|
context: ExecutionContext,
|
|
message_id: str | None = None,
|
|
session_id: str | None = None,
|
|
):
|
|
workspace_uuid = self._require_write_context(context)
|
|
context_columns = (
|
|
persistence_monitoring.MonitoringMessage.id,
|
|
persistence_monitoring.MonitoringMessage.bot_id,
|
|
persistence_monitoring.MonitoringMessage.bot_name,
|
|
persistence_monitoring.MonitoringMessage.pipeline_id,
|
|
persistence_monitoring.MonitoringMessage.pipeline_name,
|
|
persistence_monitoring.MonitoringMessage.session_id,
|
|
)
|
|
if message_id:
|
|
result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(*context_columns).where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.id == message_id,
|
|
)
|
|
)
|
|
row = result.first()
|
|
if row:
|
|
return row
|
|
|
|
if not session_id:
|
|
return None
|
|
|
|
user_query = (
|
|
sqlalchemy.select(*context_columns)
|
|
.where(
|
|
sqlalchemy.and_(
|
|
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
|
persistence_monitoring.MonitoringMessage.role == 'user',
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
)
|
|
)
|
|
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
|
.limit(1)
|
|
)
|
|
result = await self.ap.persistence_mgr.execute_async(user_query)
|
|
row = result.first()
|
|
if row:
|
|
return row
|
|
|
|
any_query = (
|
|
sqlalchemy.select(*context_columns)
|
|
.where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
|
)
|
|
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
|
.limit(1)
|
|
)
|
|
result = await self.ap.persistence_mgr.execute_async(any_query)
|
|
row = result.first()
|
|
return row
|
|
|
|
# ========== Recording Methods ==========
|
|
|
|
def _sanitize_message_content(self, content: str) -> str:
|
|
"""Strip raw base64 data to protect database storage from unbounded bloating."""
|
|
if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
|
|
return content
|
|
try:
|
|
data = json.loads(content)
|
|
|
|
def _strip_node(node):
|
|
if isinstance(node, list):
|
|
return [_strip_node(x) for x in node]
|
|
if isinstance(node, dict):
|
|
res = dict(node)
|
|
if res.get('type') == 'Image' and res.get('base64'):
|
|
res['base64'] = None
|
|
for k, v in list(res.items()):
|
|
if isinstance(v, (list, dict)):
|
|
res[k] = _strip_node(v)
|
|
return res
|
|
return node
|
|
|
|
return json.dumps(_strip_node(data), ensure_ascii=False)
|
|
except Exception:
|
|
return re.sub(
|
|
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
|
|
)
|
|
|
|
@_workspace_transaction
|
|
async def record_message(
|
|
self,
|
|
context: ExecutionContext,
|
|
bot_id: str,
|
|
bot_name: str,
|
|
pipeline_id: str,
|
|
pipeline_name: str,
|
|
message_content: str,
|
|
session_id: str,
|
|
status: str = 'success',
|
|
level: str = 'info',
|
|
platform: str | None = None,
|
|
user_id: str | int | None = None,
|
|
user_name: str | None = None,
|
|
runner_name: str | None = None,
|
|
variables: str | None = None,
|
|
role: str = 'user',
|
|
event_id: str | None = None,
|
|
run_id: str | None = None,
|
|
parent_message_id: str | None = None,
|
|
) -> str:
|
|
"""Record a message"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
from ....telemetry import trace
|
|
|
|
trace_state = trace.current()
|
|
event_id = event_id or (trace_state.event_id if trace_state else None)
|
|
run_id = run_id or trace.current_run() or None
|
|
message_id = str(uuid.uuid4())
|
|
message_content = self._sanitize_message_content(message_content)
|
|
message_data = {
|
|
'id': message_id,
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'bot_id': bot_id,
|
|
'bot_name': bot_name,
|
|
'pipeline_id': pipeline_id,
|
|
'pipeline_name': pipeline_name,
|
|
'message_content': message_content,
|
|
'session_id': session_id,
|
|
'status': status,
|
|
'level': level,
|
|
'platform': platform,
|
|
'user_id': _normalize_user_id(user_id),
|
|
'user_name': user_name,
|
|
'runner_name': runner_name,
|
|
'variables': variables,
|
|
'role': role,
|
|
'event_id': event_id,
|
|
'run_id': run_id,
|
|
'parent_message_id': parent_message_id,
|
|
}
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data)
|
|
)
|
|
|
|
return message_id
|
|
|
|
@_workspace_transaction
|
|
async def record_llm_call(
|
|
self,
|
|
context: ExecutionContext,
|
|
bot_id: str,
|
|
bot_name: str,
|
|
pipeline_id: str,
|
|
pipeline_name: str,
|
|
session_id: str,
|
|
model_name: str,
|
|
input_tokens: int,
|
|
output_tokens: int,
|
|
duration: int,
|
|
status: str = 'success',
|
|
cost: float | None = None,
|
|
error_message: str | None = None,
|
|
message_id: str | None = None,
|
|
) -> str:
|
|
"""Record an LLM call"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
call_id = str(uuid.uuid4())
|
|
call_data = {
|
|
'id': call_id,
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'model_name': model_name,
|
|
'input_tokens': input_tokens,
|
|
'output_tokens': output_tokens,
|
|
'total_tokens': input_tokens + output_tokens,
|
|
'duration': duration,
|
|
'cost': cost,
|
|
'status': status,
|
|
'bot_id': bot_id,
|
|
'bot_name': bot_name,
|
|
'pipeline_id': pipeline_id,
|
|
'pipeline_name': pipeline_name,
|
|
'session_id': session_id,
|
|
'error_message': error_message,
|
|
'message_id': message_id,
|
|
}
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data)
|
|
)
|
|
|
|
return call_id
|
|
|
|
@_workspace_transaction
|
|
async def record_tool_call(
|
|
self,
|
|
context: ExecutionContext,
|
|
tool_name: str,
|
|
tool_source: str,
|
|
duration: int,
|
|
status: str = 'success',
|
|
bot_id: str | None = None,
|
|
bot_name: str | None = None,
|
|
pipeline_id: str | None = None,
|
|
pipeline_name: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str | None = None,
|
|
arguments: object | None = None,
|
|
result: object | None = None,
|
|
error_message: str | None = None,
|
|
) -> str:
|
|
"""Record a tool call."""
|
|
workspace_uuid = self._require_write_context(context)
|
|
context_message = await self._get_message_for_tool_context(
|
|
context,
|
|
message_id=message_id,
|
|
session_id=session_id,
|
|
)
|
|
if context_message:
|
|
bot_id = bot_id or context_message.bot_id
|
|
bot_name = bot_name or context_message.bot_name
|
|
pipeline_id = pipeline_id or context_message.pipeline_id
|
|
pipeline_name = pipeline_name or context_message.pipeline_name
|
|
session_id = session_id or context_message.session_id
|
|
message_id = message_id or context_message.id
|
|
|
|
call_id = str(uuid.uuid4())
|
|
call_data = {
|
|
'id': call_id,
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'tool_name': tool_name,
|
|
'tool_source': tool_source,
|
|
'duration': max(0, duration),
|
|
'status': status,
|
|
'bot_id': bot_id or 'unknown',
|
|
'bot_name': bot_name or 'Unknown',
|
|
'pipeline_id': pipeline_id or 'unknown',
|
|
'pipeline_name': pipeline_name or 'Unknown',
|
|
'session_id': session_id,
|
|
'message_id': message_id,
|
|
'arguments': self._serialize_tool_payload(arguments),
|
|
'result': self._serialize_tool_payload(result),
|
|
'error_message': self._serialize_tool_payload(error_message),
|
|
}
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data)
|
|
)
|
|
|
|
return call_id
|
|
|
|
@_workspace_transaction
|
|
async def record_embedding_call(
|
|
self,
|
|
context: ExecutionContext,
|
|
model_name: str,
|
|
prompt_tokens: int,
|
|
total_tokens: int,
|
|
duration: int,
|
|
input_count: int,
|
|
status: str = 'success',
|
|
error_message: str | None = None,
|
|
knowledge_base_id: str | None = None,
|
|
query_text: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str | None = None,
|
|
call_type: str | None = None,
|
|
) -> str:
|
|
"""Record an embedding call"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
call_id = str(uuid.uuid4())
|
|
call_data = {
|
|
'id': call_id,
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'model_name': model_name,
|
|
'prompt_tokens': prompt_tokens,
|
|
'total_tokens': total_tokens,
|
|
'duration': duration,
|
|
'input_count': input_count,
|
|
'status': status,
|
|
'error_message': error_message,
|
|
'knowledge_base_id': knowledge_base_id,
|
|
'query_text': query_text,
|
|
'session_id': session_id,
|
|
'message_id': message_id,
|
|
'call_type': call_type,
|
|
}
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data)
|
|
)
|
|
|
|
return call_id
|
|
|
|
@_workspace_transaction
|
|
async def record_session_start(
|
|
self,
|
|
context: ExecutionContext,
|
|
session_id: str,
|
|
bot_id: str,
|
|
bot_name: str,
|
|
pipeline_id: str,
|
|
pipeline_name: str,
|
|
platform: str | None = None,
|
|
user_id: str | int | None = None,
|
|
user_name: str | None = None,
|
|
) -> None:
|
|
"""Record a new session"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
session_data = {
|
|
'workspace_uuid': workspace_uuid,
|
|
'session_id': session_id,
|
|
'bot_id': bot_id,
|
|
'bot_name': bot_name,
|
|
'pipeline_id': pipeline_id,
|
|
'pipeline_name': pipeline_name,
|
|
'message_count': 1,
|
|
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'is_active': True,
|
|
'platform': platform,
|
|
'user_id': _normalize_user_id(user_id),
|
|
'user_name': user_name,
|
|
}
|
|
|
|
model = persistence_monitoring.MonitoringSession
|
|
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
|
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
|
|
statement = insert(model).values(session_data)
|
|
await self.ap.persistence_mgr.execute_async(
|
|
statement.on_conflict_do_update(
|
|
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
|
|
set_={
|
|
'message_count': model.message_count + 1,
|
|
'last_activity': statement.excluded.last_activity,
|
|
'pipeline_id': statement.excluded.pipeline_id,
|
|
'pipeline_name': statement.excluded.pipeline_name,
|
|
},
|
|
)
|
|
)
|
|
|
|
@_workspace_transaction
|
|
async def update_session_activity(
|
|
self,
|
|
context: ExecutionContext,
|
|
session_id: str,
|
|
pipeline_id: str | None = None,
|
|
pipeline_name: str | None = None,
|
|
bot_id: str | None = None,
|
|
) -> bool:
|
|
"""Update session last activity time and increment message count.
|
|
|
|
Also updates pipeline info if the bot's pipeline has changed.
|
|
|
|
Returns:
|
|
True if session was found and updated, False if session doesn't exist.
|
|
"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
bot_id = bot_id if bot_id is not None else context.bot_uuid
|
|
if not bot_id:
|
|
raise ValueError('Session activity requires a bot_id')
|
|
update_values = {
|
|
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
|
}
|
|
|
|
# Update pipeline info if provided (handles pipeline switch)
|
|
if pipeline_id is not None:
|
|
update_values['pipeline_id'] = pipeline_id
|
|
if pipeline_name is not None:
|
|
update_values['pipeline_name'] = pipeline_name
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.update(persistence_monitoring.MonitoringSession)
|
|
.where(
|
|
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringSession.session_id == session_id,
|
|
persistence_monitoring.MonitoringSession.bot_id == bot_id,
|
|
)
|
|
.values(update_values)
|
|
)
|
|
# Check if any rows were updated
|
|
return result.rowcount > 0
|
|
|
|
@_workspace_transaction
|
|
async def record_error(
|
|
self,
|
|
context: ExecutionContext,
|
|
bot_id: str,
|
|
bot_name: str,
|
|
pipeline_id: str,
|
|
pipeline_name: str,
|
|
error_type: str,
|
|
error_message: str,
|
|
session_id: str | None = None,
|
|
stack_trace: str | None = None,
|
|
message_id: str | None = None,
|
|
) -> str:
|
|
"""Record an error"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
error_id = str(uuid.uuid4())
|
|
error_data = {
|
|
'id': error_id,
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
'error_type': error_type,
|
|
'error_message': error_message,
|
|
'bot_id': bot_id,
|
|
'bot_name': bot_name,
|
|
'pipeline_id': pipeline_id,
|
|
'pipeline_name': pipeline_name,
|
|
'session_id': session_id,
|
|
'stack_trace': stack_trace,
|
|
'message_id': message_id,
|
|
}
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data)
|
|
)
|
|
|
|
return error_id
|
|
|
|
@_workspace_transaction
|
|
async def update_message_status(
|
|
self,
|
|
context: ExecutionContext,
|
|
message_id: str,
|
|
status: str,
|
|
level: str | None = None,
|
|
variables: str | None = None,
|
|
) -> None:
|
|
"""Update message status and optionally variables"""
|
|
workspace_uuid = self._require_write_context(context)
|
|
update_values = {'status': status}
|
|
if level is not None:
|
|
update_values['level'] = level
|
|
if variables is not None:
|
|
update_values['variables'] = variables
|
|
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
|
|
.where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.id == message_id,
|
|
)
|
|
.values(update_values)
|
|
)
|
|
|
|
# ========== Query Methods ==========
|
|
|
|
async def get_overview_metrics(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> dict:
|
|
"""Get overview metrics"""
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
# Build base query conditions
|
|
message_conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
|
llm_conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
|
embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
|
session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
|
|
|
message_conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
llm_conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
session_conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
embedding_conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
|
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
|
session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
|
|
|
if pipeline_ids:
|
|
message_conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
llm_conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
session_conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
|
|
if start_time:
|
|
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
|
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
|
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
|
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
|
|
|
if end_time:
|
|
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
|
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
|
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
|
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
|
|
|
# Total messages
|
|
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
|
if message_conditions:
|
|
message_query = message_query.where(sqlalchemy.and_(*message_conditions))
|
|
|
|
total_messages_result = await self.ap.persistence_mgr.execute_async(message_query)
|
|
total_messages = total_messages_result.scalar() or 0
|
|
|
|
# Total LLM calls
|
|
llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))
|
|
if llm_conditions:
|
|
llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions))
|
|
|
|
llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
|
llm_calls = llm_calls_result.scalar() or 0
|
|
|
|
# Total Embedding calls
|
|
embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))
|
|
if embedding_conditions:
|
|
embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions))
|
|
|
|
embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query)
|
|
embedding_calls = embedding_calls_result.scalar() or 0
|
|
|
|
# Total model calls (LLM + Embedding)
|
|
model_calls = llm_calls + embedding_calls
|
|
|
|
# Success rate (based on messages)
|
|
success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(
|
|
persistence_monitoring.MonitoringMessage.status == 'success'
|
|
)
|
|
if message_conditions:
|
|
success_query = success_query.where(sqlalchemy.and_(*message_conditions))
|
|
|
|
success_result = await self.ap.persistence_mgr.execute_async(success_query)
|
|
success_count = success_result.scalar() or 0
|
|
success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100
|
|
|
|
# Active sessions
|
|
active_session_query = sqlalchemy.select(
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)
|
|
).where(persistence_monitoring.MonitoringSession.is_active == True)
|
|
if session_conditions:
|
|
active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions))
|
|
|
|
active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query)
|
|
active_sessions = active_sessions_result.scalar() or 0
|
|
|
|
# Execution-first KPI values (runs + pipeline queries, latency, token
|
|
# coverage) are served by `get_executions`, which applies the execution
|
|
# view filters. This endpoint stays the message/traffic rollup so the
|
|
# dashboard does not compute the same rollup twice per page load.
|
|
return {
|
|
'total_messages': total_messages,
|
|
'llm_calls': llm_calls,
|
|
'embedding_calls': embedding_calls,
|
|
'model_calls': model_calls,
|
|
'success_rate': round(success_rate, 2),
|
|
'active_sessions': active_sessions,
|
|
}
|
|
|
|
# ========== Execution view ==========
|
|
|
|
@staticmethod
|
|
def _debug_run_condition(model):
|
|
return sqlalchemy.or_(
|
|
model.binding_id.like('debug:%'),
|
|
model.conversation_id.like('debug:%'),
|
|
model.bot_id == 'websocket-proxy-bot',
|
|
)
|
|
|
|
@staticmethod
|
|
def _debug_pipeline_condition(model):
|
|
# Dashboard/API pipeline debug uses this reserved proxy identity.
|
|
# Real Web Page bots have their own bot UUID, even on the same adapter.
|
|
return sqlalchemy.func.coalesce(model.bot_id == 'websocket-proxy-bot', False)
|
|
|
|
def _monitoring_execution_condition(self, model, workspace_uuid, statuses=None, mode='all'):
|
|
"""Filter telemetry by its owning execution, not by the call's outcome."""
|
|
if not statuses and mode == 'all':
|
|
return sqlalchemy.true()
|
|
Run = persistence_agent_run.AgentRun
|
|
Message = persistence_monitoring.MonitoringMessage
|
|
roots = sqlalchemy.orm.aliased(Message)
|
|
run_conditions = [Run.workspace_id == workspace_uuid]
|
|
if statuses:
|
|
run_conditions.append(Run.status.in_(self._raw_agent_statuses(statuses)))
|
|
debug = self._debug_run_condition(Run)
|
|
if mode != 'all':
|
|
run_conditions.append(debug if mode == 'debug' else sqlalchemy.not_(sqlalchemy.func.coalesce(debug, False)))
|
|
runs = sqlalchemy.select(Run.run_id).where(*run_conditions)
|
|
root_conditions = [roots.workspace_uuid == workspace_uuid, roots.role != 'assistant']
|
|
if statuses:
|
|
root_conditions.append(roots.status.in_(self._raw_pipeline_statuses(statuses)))
|
|
if mode != 'all':
|
|
root_debug = self._debug_pipeline_condition(roots)
|
|
root_conditions.append(root_debug if mode == 'debug' else sqlalchemy.not_(root_debug))
|
|
root_ids = sqlalchemy.select(roots.id).where(*root_conditions)
|
|
linked = sqlalchemy.or_(
|
|
Message.id.in_(root_ids), Message.parent_message_id.in_(root_ids), Message.run_id.in_(runs)
|
|
)
|
|
message_ids = sqlalchemy.select(Message.id).where(Message.workspace_uuid == workspace_uuid, linked)
|
|
if model is Message:
|
|
return linked
|
|
if hasattr(model, 'message_id'):
|
|
return sqlalchemy.or_(model.message_id.in_(runs), model.message_id.in_(message_ids))
|
|
if model is persistence_monitoring.MonitoringSession:
|
|
return sqlalchemy.or_(
|
|
model.session_id.in_(sqlalchemy.select(roots.session_id).where(*root_conditions)),
|
|
model.session_id.in_(sqlalchemy.select(Run.conversation_id).where(*run_conditions)),
|
|
)
|
|
return sqlalchemy.false()
|
|
|
|
def _processor_runs(self, workspace_uuid, processor_ids):
|
|
Run = persistence_agent_run.AgentRun
|
|
return sqlalchemy.select(Run.run_id).where(
|
|
Run.workspace_id == workspace_uuid,
|
|
sqlalchemy.func.coalesce(self._execution_json_text(Run.metadata_json, 'processor_id'), Run.agent_id).in_(
|
|
processor_ids
|
|
),
|
|
)
|
|
|
|
def _monitoring_processor_condition(self, model, workspace_uuid, processor_ids):
|
|
direct = model.pipeline_id.in_(processor_ids)
|
|
if hasattr(model, 'message_id'):
|
|
Message = persistence_monitoring.MonitoringMessage
|
|
return sqlalchemy.or_(
|
|
direct,
|
|
model.message_id.in_(self._processor_runs(workspace_uuid, processor_ids)),
|
|
model.message_id.in_(
|
|
sqlalchemy.select(Message.id).where(
|
|
Message.workspace_uuid == workspace_uuid, Message.pipeline_id.in_(processor_ids)
|
|
)
|
|
),
|
|
)
|
|
return direct
|
|
|
|
def _agent_run_conditions(
|
|
self,
|
|
workspace_uuid: str,
|
|
*,
|
|
bot_ids: list[str] | None = None,
|
|
agent_ids: list[str] | None = None,
|
|
processor_ids: list[str] | None = None,
|
|
statuses: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
debug: bool | None = None,
|
|
) -> list:
|
|
AgentRun = persistence_agent_run.AgentRun
|
|
conditions = [
|
|
AgentRun.workspace_id == workspace_uuid,
|
|
~sqlalchemy.exists(
|
|
sqlalchemy.select(persistence_monitoring.MonitoringMessage.id).where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.run_id == AgentRun.run_id,
|
|
persistence_monitoring.MonitoringMessage.role != 'assistant',
|
|
)
|
|
),
|
|
]
|
|
if bot_ids:
|
|
conditions.append(AgentRun.bot_id.in_(bot_ids))
|
|
if agent_ids:
|
|
conditions.append(AgentRun.agent_id.in_(agent_ids))
|
|
if processor_ids:
|
|
conditions.append(AgentRun.run_id.in_(self._processor_runs(workspace_uuid, processor_ids)))
|
|
if statuses is not None:
|
|
conditions.append(AgentRun.status.in_(statuses) if statuses else sqlalchemy.false())
|
|
if start_time:
|
|
conditions.append(AgentRun.created_at >= start_time)
|
|
if end_time:
|
|
conditions.append(AgentRun.created_at <= end_time)
|
|
if debug is not None:
|
|
debug_condition = self._debug_run_condition(AgentRun)
|
|
conditions.append(
|
|
debug_condition if debug else sqlalchemy.not_(sqlalchemy.func.coalesce(debug_condition, False))
|
|
)
|
|
return conditions
|
|
|
|
def _pipeline_execution_conditions(
|
|
self,
|
|
workspace_uuid: str,
|
|
*,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
statuses: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
debug: bool | None = None,
|
|
) -> list:
|
|
MonitoringMessage = persistence_monitoring.MonitoringMessage
|
|
conditions = [
|
|
MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
# Assistant replies are outputs of a query, not an execution of their own.
|
|
sqlalchemy.or_(MonitoringMessage.role.is_(None), MonitoringMessage.role != 'assistant'),
|
|
]
|
|
if bot_ids:
|
|
conditions.append(MonitoringMessage.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(self._monitoring_processor_condition(MonitoringMessage, workspace_uuid, pipeline_ids))
|
|
if statuses is not None:
|
|
conditions.append(MonitoringMessage.status.in_(statuses) if statuses else sqlalchemy.false())
|
|
if start_time:
|
|
conditions.append(MonitoringMessage.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(MonitoringMessage.timestamp <= end_time)
|
|
if debug is not None:
|
|
debug_condition = self._debug_pipeline_condition(MonitoringMessage)
|
|
conditions.append(debug_condition if debug else sqlalchemy.not_(debug_condition))
|
|
return conditions
|
|
|
|
@staticmethod
|
|
def _raw_agent_statuses(groups: list[str] | None) -> list[str] | None:
|
|
"""Raw agent statuses for a group filter; empty means "no group applies"."""
|
|
if not groups:
|
|
return None
|
|
raw: list[str] = []
|
|
for group in groups:
|
|
raw.extend(status for status, mapped in _AGENT_STATUS_GROUP.items() if mapped == group)
|
|
return raw
|
|
|
|
@staticmethod
|
|
def _raw_pipeline_statuses(groups: list[str] | None) -> list[str] | None:
|
|
"""Raw pipeline statuses for a group filter; empty means "no group applies"."""
|
|
if not groups:
|
|
return None
|
|
raw: list[str] = []
|
|
for group in groups:
|
|
raw.extend(status for status, mapped in _PIPELINE_STATUS_GROUP.items() if mapped == group)
|
|
return raw
|
|
|
|
@staticmethod
|
|
def _model_rows(result) -> list:
|
|
"""Rows of one ``select(Model)`` executed through ``execute_async``.
|
|
|
|
The Core keeps the connection result contract, where ``select(Model)``
|
|
yields a *flat* row of the model's columns (``row.uuid``, ``row.status``)
|
|
rather than a one-item ORM row. ``.scalars()`` would therefore return the
|
|
first column - the id - instead of the row, so the rows are taken as-is.
|
|
"""
|
|
return list(result.all())
|
|
|
|
@staticmethod
|
|
def _deserialize_json(raw: str | None) -> dict:
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
value = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
async def _agent_names(self, workspace_uuid: str, rows: list) -> dict[str, str]:
|
|
"""The processor name of every Agent run in one page, in one query.
|
|
|
|
A run stores the Agent it executed as a uuid; the list has to name the
|
|
processor, so the names are resolved for the whole page at once.
|
|
"""
|
|
agent_ids = {self._deserialize_json(row.metadata_json).get('processor_id') or row.agent_id for row in rows} - {
|
|
None,
|
|
'',
|
|
}
|
|
if not agent_ids:
|
|
return {}
|
|
Agent = persistence_agent.Agent
|
|
result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(Agent.uuid, Agent.name).where(
|
|
sqlalchemy.and_(
|
|
Agent.workspace_uuid == workspace_uuid,
|
|
Agent.uuid.in_(agent_ids),
|
|
)
|
|
)
|
|
)
|
|
return {row.uuid: row.name for row in self._model_rows(result)}
|
|
|
|
def _serialize_agent_execution(self, row, agent_names: dict[str, str] | None = None) -> dict:
|
|
metadata = self._deserialize_json(row.metadata_json)
|
|
duration_ms = None
|
|
if row.started_at and row.finished_at:
|
|
duration_ms = int(round((row.finished_at - row.started_at).total_seconds() * 1000))
|
|
created_at_ms = _epoch_ms(row.created_at)
|
|
started_at_ms = _epoch_ms(row.started_at)
|
|
finished_at_ms = _epoch_ms(row.finished_at)
|
|
is_debug = (
|
|
str(row.binding_id or '').startswith('debug:')
|
|
or str(row.conversation_id or '').startswith('debug:')
|
|
or row.bot_id == 'websocket-proxy-bot'
|
|
)
|
|
return {
|
|
'source': 'agent',
|
|
'id': row.run_id,
|
|
'event_id': row.event_id,
|
|
'status': row.status,
|
|
'status_group': _status_group(row.status, 'agent'),
|
|
'title': metadata.get('event_type') or row.queue_name or row.runner_id,
|
|
'target_kind': metadata.get('processor_type') or ('agent' if row.agent_id else 'processor'),
|
|
'target_id': metadata.get('processor_id') or row.agent_id or row.binding_id,
|
|
'target_name': metadata.get('agent_name')
|
|
or metadata.get('target_name')
|
|
or (agent_names or {}).get(metadata.get('processor_id') or row.agent_id or ''),
|
|
'bot_id': row.bot_id,
|
|
'bot_name': metadata.get('bot_name'),
|
|
'pipeline_id': None,
|
|
'pipeline_name': None,
|
|
'runner_id': row.runner_id,
|
|
'conversation_id': row.conversation_id,
|
|
'session_id': row.conversation_id,
|
|
'created_at_ms': created_at_ms,
|
|
'started_at_ms': started_at_ms,
|
|
'finished_at_ms': finished_at_ms,
|
|
'duration_ms': duration_ms,
|
|
'usage': self._deserialize_json(row.usage_json) or None,
|
|
'cost': self._deserialize_json(row.cost_json) or None,
|
|
'queue_name': row.queue_name,
|
|
'debug': is_debug,
|
|
'has_error': row.status in {'failed', 'timeout'},
|
|
'status_reason': row.status_reason,
|
|
'input_preview': _message_preview(json.dumps(metadata.get('input') or {}, ensure_ascii=False), 200),
|
|
}
|
|
|
|
def _serialize_pipeline_execution(self, row) -> dict:
|
|
created_at_ms = _epoch_ms(row.timestamp)
|
|
return {
|
|
'source': 'pipeline',
|
|
'id': row.id,
|
|
'event_id': row.event_id,
|
|
'status': row.status,
|
|
'status_group': _status_group(row.status, 'pipeline'),
|
|
'title': _message_preview(row.message_content),
|
|
'target_kind': 'pipeline',
|
|
'target_id': row.pipeline_id,
|
|
'target_name': row.pipeline_name,
|
|
'bot_id': row.bot_id,
|
|
'bot_name': row.bot_name,
|
|
'pipeline_id': row.pipeline_id,
|
|
'pipeline_name': row.pipeline_name,
|
|
'runner_id': row.runner_name,
|
|
'conversation_id': None,
|
|
'session_id': row.session_id,
|
|
'created_at_ms': created_at_ms,
|
|
'started_at_ms': None,
|
|
'finished_at_ms': None,
|
|
'duration_ms': None,
|
|
'usage': None,
|
|
'cost': None,
|
|
'queue_name': None,
|
|
'debug': row.bot_id == 'websocket-proxy-bot',
|
|
'has_error': row.status == 'error',
|
|
'input_preview': _message_preview(row.message_content),
|
|
'platform': row.platform,
|
|
'user_id': row.user_id,
|
|
'user_name': row.user_name,
|
|
}
|
|
|
|
async def _get_token_coverage(
|
|
self,
|
|
workspace_uuid: str,
|
|
*,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> dict:
|
|
LLMCall = persistence_monitoring.MonitoringLLMCall
|
|
conditions = [LLMCall.workspace_uuid == workspace_uuid]
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(LLMCall.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids))
|
|
if start_time:
|
|
conditions.append(LLMCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(LLMCall.timestamp <= end_time)
|
|
result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(LLMCall.id),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.total_tokens > 0, 1), else_=0)),
|
|
).where(sqlalchemy.and_(*conditions))
|
|
)
|
|
row = result.first()
|
|
calls, total, calls_with_usage = row if row else (0, 0, 0)
|
|
return {
|
|
'total_tokens': int(total or 0),
|
|
'calls': calls or 0,
|
|
'calls_with_usage': calls_with_usage or 0,
|
|
}
|
|
|
|
async def _get_execution_summary(
|
|
self,
|
|
workspace_uuid: str,
|
|
*,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> dict:
|
|
AgentRun = persistence_agent_run.AgentRun
|
|
run_conditions = self._agent_run_conditions(
|
|
workspace_uuid,
|
|
bot_ids=bot_ids,
|
|
processor_ids=pipeline_ids,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
statuses=self._raw_agent_statuses(statuses),
|
|
debug=True if mode == 'debug' else False if mode == 'real' else None,
|
|
)
|
|
run_status_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(AgentRun.status, sqlalchemy.func.count(AgentRun.id))
|
|
.where(sqlalchemy.and_(*run_conditions))
|
|
.group_by(AgentRun.status)
|
|
)
|
|
agent_counts = {row[0]: row[1] for row in run_status_result.all()}
|
|
agent_total = sum(agent_counts.values())
|
|
|
|
debug_count_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where(
|
|
sqlalchemy.and_(
|
|
*run_conditions,
|
|
self._debug_run_condition(AgentRun),
|
|
)
|
|
)
|
|
)
|
|
debug_count = debug_count_result.scalar() or 0
|
|
|
|
# Percentiles are computed in Python, so the sample must stay bounded:
|
|
# take the most recent completed runs of the window and label the count
|
|
# so the UI can state how many runs the numbers rest on.
|
|
timing_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at)
|
|
.where(
|
|
sqlalchemy.and_(
|
|
*run_conditions,
|
|
AgentRun.status == 'completed',
|
|
AgentRun.started_at.is_not(None),
|
|
AgentRun.finished_at.is_not(None),
|
|
)
|
|
)
|
|
.order_by(AgentRun.created_at.desc())
|
|
.limit(self._detail_limit())
|
|
)
|
|
durations = sorted((row[1] - row[0]).total_seconds() * 1000 for row in timing_result.all() if row[0] and row[1])
|
|
|
|
pipeline_conditions = self._pipeline_execution_conditions(
|
|
workspace_uuid,
|
|
bot_ids=bot_ids,
|
|
pipeline_ids=pipeline_ids,
|
|
statuses=self._raw_pipeline_statuses(statuses),
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
debug=True if mode == 'debug' else False if mode == 'real' else None,
|
|
)
|
|
Message = persistence_monitoring.MonitoringMessage
|
|
pipeline_timing = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at)
|
|
.select_from(Message)
|
|
.join(AgentRun, sqlalchemy.and_(
|
|
AgentRun.workspace_id == workspace_uuid,
|
|
AgentRun.run_id == Message.run_id,
|
|
))
|
|
.where(
|
|
*pipeline_conditions,
|
|
Message.status == 'success',
|
|
AgentRun.started_at.is_not(None),
|
|
AgentRun.finished_at.is_not(None),
|
|
)
|
|
.order_by(Message.timestamp.desc())
|
|
.limit(self._detail_limit())
|
|
)
|
|
durations.extend(
|
|
(row[1] - row[0]).total_seconds() * 1000
|
|
for row in pipeline_timing.all() if row[0] and row[1]
|
|
)
|
|
durations.sort()
|
|
pipeline_status_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
persistence_monitoring.MonitoringMessage.status,
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id),
|
|
)
|
|
.where(sqlalchemy.and_(*pipeline_conditions))
|
|
.group_by(persistence_monitoring.MonitoringMessage.status)
|
|
)
|
|
pipeline_counts = {row[0]: row[1] for row in pipeline_status_result.all()}
|
|
pipeline_total = sum(pipeline_counts.values())
|
|
pipeline_debug_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(
|
|
*pipeline_conditions, self._debug_pipeline_condition(persistence_monitoring.MonitoringMessage)
|
|
)
|
|
)
|
|
debug_count += pipeline_debug_result.scalar() or 0
|
|
|
|
# Pending interactions are gated by the same bot/window scope as the runs
|
|
# so "waiting input" cannot drift from the rest of the card.
|
|
waiting_conditions = [
|
|
persistence_agent_interaction.AgentInteraction.workspace_id == workspace_uuid,
|
|
persistence_agent_interaction.AgentInteraction.status == 'pending',
|
|
]
|
|
if bot_ids:
|
|
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
waiting_conditions.append(
|
|
persistence_agent_interaction.AgentInteraction.run_id.in_(
|
|
self._processor_runs(workspace_uuid, pipeline_ids)
|
|
)
|
|
)
|
|
if start_time:
|
|
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at >= start_time)
|
|
if end_time:
|
|
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at <= end_time)
|
|
waiting_conditions.append(
|
|
persistence_agent_interaction.AgentInteraction.run_id.in_(
|
|
sqlalchemy.select(AgentRun.run_id).where(*run_conditions)
|
|
)
|
|
)
|
|
waiting_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(sqlalchemy.func.count(persistence_agent_interaction.AgentInteraction.id)).where(
|
|
sqlalchemy.and_(*waiting_conditions)
|
|
)
|
|
)
|
|
waiting = waiting_result.scalar() or 0
|
|
|
|
by_status: dict[str, int] = {}
|
|
for status, count in agent_counts.items():
|
|
group = _status_group(status, 'agent')
|
|
by_status[group] = by_status.get(group, 0) + count
|
|
for status, count in pipeline_counts.items():
|
|
group = _status_group(status, 'pipeline')
|
|
by_status[group] = by_status.get(group, 0) + count
|
|
|
|
completed = by_status.get('completed', 0)
|
|
failed = by_status.get('failed', 0)
|
|
denominator = completed + failed
|
|
total = agent_total + pipeline_total
|
|
return {
|
|
'total': total,
|
|
'by_source': {'agent': agent_total, 'pipeline': pipeline_total},
|
|
'by_status': by_status,
|
|
'completed': completed,
|
|
'failed': failed,
|
|
'running': by_status.get('running', 0),
|
|
'queued': by_status.get('queued', 0),
|
|
'cancelled': by_status.get('cancelled', 0),
|
|
'ignored': by_status.get('ignored', 0),
|
|
'waiting': waiting,
|
|
'success_rate': round(completed / denominator * 100, 2) if denominator > 0 else None,
|
|
'denominator': denominator,
|
|
'p50_duration_ms': _percentile_ms(durations, 0.5),
|
|
'p95_duration_ms': _percentile_ms(durations, 0.95),
|
|
'duration_sample': len(durations),
|
|
'debug': debug_count,
|
|
'real': total - debug_count,
|
|
}
|
|
|
|
async def get_executions(
|
|
self,
|
|
context: TenantContext,
|
|
*,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
agent_ids: list[str] | None = None,
|
|
statuses: list[str] | None = None,
|
|
source: str = 'all',
|
|
mode: str = 'all',
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> dict:
|
|
"""List executions from both object types (agent runs and pipeline queries)."""
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
if source not in {'all', 'agent', 'pipeline', 'event'}:
|
|
source = 'all'
|
|
if mode not in {'all', 'real', 'debug'}:
|
|
mode = 'all'
|
|
debug = None if mode == 'all' else mode == 'debug'
|
|
branches = []
|
|
AgentRun = persistence_agent_run.AgentRun
|
|
Message = persistence_monitoring.MonitoringMessage
|
|
if source in {'all', 'agent'}:
|
|
conditions = self._agent_run_conditions(
|
|
workspace_uuid,
|
|
bot_ids=bot_ids,
|
|
agent_ids=agent_ids,
|
|
processor_ids=pipeline_ids,
|
|
statuses=self._raw_agent_statuses(statuses),
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
debug=debug,
|
|
)
|
|
branches.append(
|
|
sqlalchemy.select(
|
|
sqlalchemy.literal('agent').label('source'),
|
|
AgentRun.run_id.label('id'),
|
|
AgentRun.created_at.label('time'),
|
|
).where(*conditions)
|
|
)
|
|
if source in {'all', 'pipeline'} and (not agent_ids or pipeline_ids):
|
|
conditions = self._pipeline_execution_conditions(
|
|
workspace_uuid,
|
|
bot_ids=bot_ids,
|
|
pipeline_ids=pipeline_ids,
|
|
statuses=self._raw_pipeline_statuses(statuses),
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
debug=debug,
|
|
)
|
|
branches.append(
|
|
sqlalchemy.select(
|
|
sqlalchemy.literal('pipeline').label('source'),
|
|
Message.id.label('id'),
|
|
Message.timestamp.label('time'),
|
|
).where(*conditions)
|
|
)
|
|
if source in {'all', 'event'} and mode != 'debug' and not pipeline_ids and not agent_ids:
|
|
conditions = self._unhandled_event_conditions(workspace_uuid, bot_ids, start_time, end_time)
|
|
if statuses:
|
|
conditions.append(self._execution_json_text(EventLog.metadata_json, 'status').in_(statuses))
|
|
branches.append(
|
|
sqlalchemy.select(
|
|
sqlalchemy.literal('event').label('source'),
|
|
EventLog.event_id.label('id'),
|
|
EventLog.created_at.label('time'),
|
|
).where(*conditions)
|
|
)
|
|
total = 0
|
|
page = []
|
|
if branches:
|
|
combined = sqlalchemy.union_all(*branches).subquery()
|
|
count = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(sqlalchemy.func.count()).select_from(combined)
|
|
)
|
|
total = count.scalar() or 0
|
|
refs = (
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(combined)
|
|
.order_by(combined.c.time.desc(), combined.c.source, combined.c.id)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
).all()
|
|
by_key = {}
|
|
for kind, model, identity_column in [
|
|
('agent', AgentRun, AgentRun.run_id),
|
|
('pipeline', Message, Message.id),
|
|
('event', EventLog, EventLog.event_id),
|
|
]:
|
|
ids = [r.id for r in refs if r.source == kind]
|
|
if not ids:
|
|
continue
|
|
workspace_column = model.workspace_uuid if kind == 'pipeline' else model.workspace_id
|
|
records = await self._execution_rows(
|
|
workspace_uuid,
|
|
sqlalchemy.select(model).where(workspace_column == workspace_uuid, identity_column.in_(ids)),
|
|
)
|
|
names = await self._agent_names(workspace_uuid, records) if kind == 'agent' else {}
|
|
for record in records:
|
|
item = (
|
|
self._serialize_agent_execution(record, names)
|
|
if kind == 'agent'
|
|
else self._serialize_pipeline_execution(record)
|
|
if kind == 'pipeline'
|
|
else self._serialize_event_execution(record)
|
|
)
|
|
by_key[(kind, item['id'])] = item
|
|
page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key]
|
|
await self.enrich_execution_rows(workspace_uuid, page)
|
|
return {
|
|
'items': page,
|
|
'total': total,
|
|
'limit': limit,
|
|
'offset': offset,
|
|
'has_more': total > offset + len(page),
|
|
'summary': {
|
|
'executions': await self._get_execution_summary(
|
|
workspace_uuid,
|
|
statuses=statuses,
|
|
mode=mode,
|
|
bot_ids=bot_ids,
|
|
pipeline_ids=pipeline_ids,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
),
|
|
'tokens': await self._get_token_coverage(
|
|
workspace_uuid,
|
|
execution_statuses=statuses,
|
|
mode=mode,
|
|
bot_ids=bot_ids,
|
|
pipeline_ids=pipeline_ids,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
),
|
|
},
|
|
}
|
|
|
|
async def get_token_statistics(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
bucket: str = 'hour',
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> dict:
|
|
"""Get detailed token usage statistics for production observability.
|
|
|
|
Returns:
|
|
- summary: aggregate token counters and call/latency stats over the window
|
|
- by_model: per-model token + call breakdown (sorted by total tokens desc)
|
|
- timeseries: token usage bucketed by `bucket` ('hour' or 'day')
|
|
|
|
Only successful LLM calls are counted toward token totals; error calls are
|
|
reported separately so a spike in failures is visible without polluting
|
|
token accounting.
|
|
"""
|
|
LLMCall = persistence_monitoring.MonitoringLLMCall
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
if bucket not in {'hour', 'day'}:
|
|
bucket = 'hour'
|
|
|
|
conditions = [LLMCall.workspace_uuid == workspace_uuid]
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(LLMCall.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids))
|
|
if start_time:
|
|
conditions.append(LLMCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(LLMCall.timestamp <= end_time)
|
|
|
|
def _apply(query):
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
return query
|
|
|
|
# ---- Summary aggregates ----
|
|
summary_query = _apply(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(LLMCall.id),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)),
|
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
|
# Count of successful calls that nonetheless recorded zero tokens —
|
|
# a data-quality signal that usage reporting may be broken upstream.
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1),
|
|
else_=0,
|
|
)
|
|
),
|
|
)
|
|
)
|
|
summary_result = await self.ap.persistence_mgr.execute_async(summary_query)
|
|
row = summary_result.first()
|
|
(
|
|
total_calls,
|
|
total_input_tokens,
|
|
total_output_tokens,
|
|
total_tokens,
|
|
total_duration,
|
|
total_cost,
|
|
success_calls,
|
|
error_calls,
|
|
zero_token_success_calls,
|
|
) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0)
|
|
|
|
total_calls = total_calls or 0
|
|
success_calls = success_calls or 0
|
|
error_calls = error_calls or 0
|
|
zero_token_success_calls = zero_token_success_calls or 0
|
|
|
|
summary = {
|
|
'total_calls': total_calls,
|
|
'success_calls': success_calls,
|
|
'error_calls': error_calls,
|
|
'total_input_tokens': int(total_input_tokens or 0),
|
|
'total_output_tokens': int(total_output_tokens or 0),
|
|
'total_tokens': int(total_tokens or 0),
|
|
'total_cost': round(float(total_cost or 0.0), 6),
|
|
'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0,
|
|
'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0,
|
|
'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2)
|
|
if total_duration and total_duration > 0
|
|
else 0,
|
|
'zero_token_success_calls': zero_token_success_calls,
|
|
}
|
|
|
|
# ---- Per-model breakdown ----
|
|
model_total_tokens = sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0)
|
|
model_limit, _unused_offset = self.normalize_page_window(_HARD_MAX_MONITORING_PAGE_ROWS)
|
|
by_model_query = (
|
|
_apply(
|
|
sqlalchemy.select(
|
|
LLMCall.model_name,
|
|
sqlalchemy.func.count(LLMCall.id),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
|
model_total_tokens,
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
|
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
|
).group_by(LLMCall.model_name)
|
|
)
|
|
.order_by(model_total_tokens.desc())
|
|
.limit(model_limit + 1)
|
|
)
|
|
by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query)
|
|
by_model_rows = by_model_result.all()
|
|
by_model_truncated = len(by_model_rows) > model_limit
|
|
by_model = []
|
|
for mrow in by_model_rows[:model_limit]:
|
|
(
|
|
model_name,
|
|
m_calls,
|
|
m_in,
|
|
m_out,
|
|
m_total,
|
|
m_duration,
|
|
m_cost,
|
|
m_errors,
|
|
) = mrow
|
|
m_calls = m_calls or 0
|
|
by_model.append(
|
|
{
|
|
'model_name': model_name,
|
|
'calls': m_calls,
|
|
'error_calls': m_errors or 0,
|
|
'input_tokens': int(m_in or 0),
|
|
'output_tokens': int(m_out or 0),
|
|
'total_tokens': int(m_total or 0),
|
|
'cost': round(float(m_cost or 0.0), 6),
|
|
'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0,
|
|
'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0,
|
|
}
|
|
)
|
|
# ---- Time-bucketed series ----
|
|
# Aggregate before materialization. Requests may omit their time window,
|
|
# so fetching every historical call and bucketing in Python is unsafe.
|
|
engine = self.ap.persistence_mgr.get_db_engine()
|
|
bucket_expression = self._token_bucket_expression(
|
|
LLMCall.timestamp,
|
|
bucket=bucket,
|
|
dialect_name=engine.dialect.name,
|
|
)
|
|
bucket_limit = self._timeseries_bucket_limit()
|
|
series_query = (
|
|
_apply(
|
|
sqlalchemy.select(
|
|
bucket_expression.label('bucket'),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
|
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
|
sqlalchemy.func.count(LLMCall.id),
|
|
).group_by(bucket_expression)
|
|
)
|
|
.order_by(bucket_expression.desc())
|
|
.limit(bucket_limit + 1)
|
|
)
|
|
series_result = await self.ap.persistence_mgr.execute_async(series_query)
|
|
|
|
bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
|
|
series_rows = series_result.all()
|
|
timeseries_truncated = len(series_rows) > bucket_limit
|
|
timeseries = []
|
|
for bucket_value, s_in, s_out, s_total, calls in reversed(series_rows[:bucket_limit]):
|
|
if bucket_value is None:
|
|
continue
|
|
bucket_key = (
|
|
bucket_value.strftime(bucket_fmt)
|
|
if isinstance(bucket_value, (datetime.datetime, datetime.date))
|
|
else str(bucket_value)
|
|
)
|
|
timeseries.append(
|
|
{
|
|
'bucket': bucket_key,
|
|
'input_tokens': int(s_in or 0),
|
|
'output_tokens': int(s_out or 0),
|
|
'total_tokens': int(s_total or 0),
|
|
'calls': int(calls or 0),
|
|
}
|
|
)
|
|
|
|
return {
|
|
'summary': summary,
|
|
'by_model': by_model,
|
|
'by_model_truncated': by_model_truncated,
|
|
'timeseries': timeseries,
|
|
'timeseries_truncated': timeseries_truncated,
|
|
'bucket': bucket,
|
|
}
|
|
|
|
async def get_messages(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
session_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get messages with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
if bot_ids and session_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
|
source = self._session_message_source(workspace_uuid, bot_ids)
|
|
query = sqlalchemy.select(source).where(source.c.session_id.in_(session_ids))
|
|
if start_time:
|
|
query = query.where(source.c.timestamp >= start_time)
|
|
if end_time:
|
|
query = query.where(source.c.timestamp <= end_time)
|
|
return await self._session_projection_page(
|
|
workspace_uuid, query.order_by(source.c.timestamp.desc(), source.c.id.desc()), limit, offset,
|
|
)
|
|
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if session_ids:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids))
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get messages
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(
|
|
persistence_monitoring.MonitoringMessage.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
messages_rows = result.all()
|
|
|
|
serialized = []
|
|
for row in messages_rows:
|
|
# Extract model instance from Row (SQLAlchemy returns Row objects)
|
|
msg = row[0] if isinstance(row, tuple) else row
|
|
serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg)
|
|
serialized.append(serialized_msg)
|
|
|
|
return (serialized, total)
|
|
|
|
async def get_llm_calls(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get LLM calls with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get LLM calls
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(
|
|
persistence_monitoring.MonitoringLLMCall.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
llm_calls_rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in llm_calls_rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def get_tool_calls(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
session_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get tool calls with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringToolCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringToolCall, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if session_ids:
|
|
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
|
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by(
|
|
persistence_monitoring.MonitoringToolCall.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
tool_calls_rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in tool_calls_rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def get_embedding_calls(
|
|
self,
|
|
context: TenantContext,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
knowledge_base_id: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get embedding calls with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
|
if knowledge_base_id:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get embedding calls
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(
|
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
embedding_calls_rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in embedding_calls_rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def get_sessions(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
user_query: str | None = None,
|
|
is_active: bool | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get sessions with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
if bot_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
|
return await self.get_bot_conversation_sessions(
|
|
workspace_uuid, bot_ids, start_time, end_time, user_query, is_active, limit, offset,
|
|
)
|
|
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
|
if user_query and user_query.strip():
|
|
user_pattern = f'%{user_query.strip()}%'
|
|
conditions.append(
|
|
sqlalchemy.or_(
|
|
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
|
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
|
)
|
|
)
|
|
if is_active is not None:
|
|
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get sessions
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
|
persistence_monitoring.MonitoringSession.last_activity.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
sessions_rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in sessions_rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def get_errors(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get errors with filters"""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringError, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get errors
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(
|
|
persistence_monitoring.MonitoringError.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
errors_rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in errors_rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def get_session_analysis(
|
|
self,
|
|
context: TenantContext,
|
|
session_id: str,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
bot_id: str | None = None,
|
|
) -> dict:
|
|
"""Get bounded session details with full statistics computed in SQL."""
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
detail_limit = self._detail_limit()
|
|
# Get session info
|
|
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
|
|
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringSession.session_id == session_id,
|
|
)
|
|
if bot_id is not None:
|
|
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
|
|
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
|
|
session_rows = session_result.all()
|
|
if len(session_rows) > 1:
|
|
return {'session_id': session_id, 'found': False, 'ambiguous': True}
|
|
session_row = session_rows[0] if session_rows else None
|
|
|
|
if not session_row:
|
|
return {
|
|
'session_id': session_id,
|
|
'found': False,
|
|
}
|
|
|
|
session = session_row[0] if isinstance(session_row, tuple) else session_row
|
|
bot_id = session.bot_id
|
|
|
|
message_stats_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id).label('total'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringMessage.status == 'success', 1),
|
|
else_=0,
|
|
)
|
|
).label('success'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringMessage.status == 'error', 1),
|
|
else_=0,
|
|
)
|
|
).label('error'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringMessage.status == 'pending', 1),
|
|
else_=0,
|
|
)
|
|
).label('pending'),
|
|
sqlalchemy.func.min(persistence_monitoring.MonitoringMessage.timestamp).label('first_timestamp'),
|
|
sqlalchemy.func.max(persistence_monitoring.MonitoringMessage.timestamp).label('last_timestamp'),
|
|
).where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
|
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
|
|
)
|
|
)
|
|
message_stats = message_stats_result.one()
|
|
|
|
llm_stats_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id).label('total_calls'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.input_tokens),
|
|
0,
|
|
).label('total_input_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.output_tokens),
|
|
0,
|
|
).label('total_output_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.total_tokens),
|
|
0,
|
|
).label('total_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.duration),
|
|
0,
|
|
).label('total_duration'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringLLMCall.status == 'success', 1),
|
|
else_=0,
|
|
)
|
|
).label('success_calls'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringLLMCall.status != 'success', 1),
|
|
else_=0,
|
|
)
|
|
).label('error_calls'),
|
|
).where(
|
|
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
|
|
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
|
|
)
|
|
)
|
|
llm_stats = llm_stats_result.one()
|
|
|
|
tool_stats_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id).label('total_calls'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringToolCall.duration),
|
|
0,
|
|
).label('total_duration'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringToolCall.status == 'success', 1),
|
|
else_=0,
|
|
)
|
|
).label('success_calls'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case(
|
|
(persistence_monitoring.MonitoringToolCall.status != 'success', 1),
|
|
else_=0,
|
|
)
|
|
).label('error_calls'),
|
|
).where(
|
|
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
|
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
|
)
|
|
)
|
|
tool_stats = tool_stats_result.one()
|
|
tool_conditions = [
|
|
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
|
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
|
]
|
|
if start_time is not None:
|
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
|
if end_time is not None:
|
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
|
tool_query = (
|
|
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
|
.where(*tool_conditions)
|
|
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
|
.limit(detail_limit + 1)
|
|
)
|
|
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
|
|
tool_rows = tool_result.all()
|
|
tool_calls_truncated = len(tool_rows) > detail_limit
|
|
tool_rows = tool_rows[:detail_limit]
|
|
|
|
tool_calls = [
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in tool_rows
|
|
]
|
|
|
|
error_query = (
|
|
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
|
.where(
|
|
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringError.session_id == session_id,
|
|
persistence_monitoring.MonitoringError.bot_id == bot_id,
|
|
)
|
|
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
|
.limit(detail_limit + 1)
|
|
)
|
|
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
|
error_rows = error_result.all()
|
|
errors_truncated = len(error_rows) > detail_limit
|
|
error_rows = error_rows[:detail_limit]
|
|
|
|
errors = [
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in error_rows
|
|
]
|
|
|
|
if message_stats.first_timestamp is not None and message_stats.last_timestamp is not None:
|
|
session_duration_seconds = int(
|
|
(message_stats.last_timestamp - message_stats.first_timestamp).total_seconds()
|
|
)
|
|
else:
|
|
session_duration_seconds = 0
|
|
total_llm_calls = int(llm_stats.total_calls or 0)
|
|
total_tool_calls = int(tool_stats.total_calls or 0)
|
|
|
|
return {
|
|
'session_id': session_id,
|
|
'found': True,
|
|
'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),
|
|
'message_stats': {
|
|
'total': int(message_stats.total or 0),
|
|
'success': int(message_stats.success or 0),
|
|
'error': int(message_stats.error or 0),
|
|
'pending': int(message_stats.pending or 0),
|
|
},
|
|
'llm_stats': {
|
|
'total_calls': total_llm_calls,
|
|
'success_calls': int(llm_stats.success_calls or 0),
|
|
'error_calls': int(llm_stats.error_calls or 0),
|
|
'total_input_tokens': int(llm_stats.total_input_tokens or 0),
|
|
'total_output_tokens': int(llm_stats.total_output_tokens or 0),
|
|
'total_tokens': int(llm_stats.total_tokens or 0),
|
|
'average_duration_ms': (int(llm_stats.total_duration / total_llm_calls) if total_llm_calls > 0 else 0),
|
|
},
|
|
'tool_calls': tool_calls,
|
|
'tool_stats': {
|
|
'total_calls': total_tool_calls,
|
|
'success_calls': int(tool_stats.success_calls or 0),
|
|
'error_calls': int(tool_stats.error_calls or 0),
|
|
'total_duration_ms': int(tool_stats.total_duration or 0),
|
|
'average_duration_ms': (
|
|
int(tool_stats.total_duration / total_tool_calls) if total_tool_calls > 0 else 0
|
|
),
|
|
},
|
|
'errors': errors,
|
|
'detail_truncated': {
|
|
'tool_calls': tool_calls_truncated,
|
|
'errors': errors_truncated,
|
|
},
|
|
'session_duration_seconds': session_duration_seconds,
|
|
}
|
|
|
|
async def get_message_details(
|
|
self,
|
|
context: TenantContext,
|
|
message_id: str,
|
|
) -> dict:
|
|
"""Get bounded message details with full statistics computed in SQL."""
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
detail_limit = self._detail_limit()
|
|
# Get message info
|
|
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
|
|
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringMessage.id == message_id,
|
|
)
|
|
message_result = await self.ap.persistence_mgr.execute_async(message_query)
|
|
message_row = message_result.first()
|
|
|
|
if not message_row:
|
|
return {
|
|
'message_id': message_id,
|
|
'found': False,
|
|
}
|
|
|
|
message = message_row[0] if isinstance(message_row, tuple) else message_row
|
|
|
|
llm_stats_result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id).label('total_calls'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.input_tokens),
|
|
0,
|
|
).label('total_input_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.output_tokens),
|
|
0,
|
|
).label('total_output_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.total_tokens),
|
|
0,
|
|
).label('total_tokens'),
|
|
sqlalchemy.func.coalesce(
|
|
sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.duration),
|
|
0,
|
|
).label('total_duration'),
|
|
).where(
|
|
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringLLMCall.message_id == message_id,
|
|
)
|
|
)
|
|
llm_stats = llm_stats_result.one()
|
|
llm_query = (
|
|
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
|
|
.where(
|
|
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringLLMCall.message_id == message_id,
|
|
)
|
|
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
|
|
.limit(detail_limit + 1)
|
|
)
|
|
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
|
llm_rows = llm_result.all()
|
|
llm_calls_truncated = len(llm_rows) > detail_limit
|
|
llm_rows = llm_rows[:detail_limit]
|
|
|
|
llm_calls = [
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in llm_rows
|
|
]
|
|
|
|
error_query = (
|
|
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
|
.where(
|
|
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
|
persistence_monitoring.MonitoringError.message_id == message_id,
|
|
)
|
|
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
|
|
.limit(detail_limit + 1)
|
|
)
|
|
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
|
error_rows = error_result.all()
|
|
errors_truncated = len(error_rows) > detail_limit
|
|
error_rows = error_rows[:detail_limit]
|
|
|
|
errors = [
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in error_rows
|
|
]
|
|
total_llm_calls = int(llm_stats.total_calls or 0)
|
|
|
|
return {
|
|
'message_id': message_id,
|
|
'found': True,
|
|
'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),
|
|
'llm_calls': llm_calls,
|
|
'llm_stats': {
|
|
'total_calls': total_llm_calls,
|
|
'total_input_tokens': int(llm_stats.total_input_tokens or 0),
|
|
'total_output_tokens': int(llm_stats.total_output_tokens or 0),
|
|
'total_tokens': int(llm_stats.total_tokens or 0),
|
|
'total_duration_ms': int(llm_stats.total_duration or 0),
|
|
'average_duration_ms': (int(llm_stats.total_duration / total_llm_calls) if total_llm_calls > 0 else 0),
|
|
},
|
|
'errors': errors,
|
|
'detail_truncated': {
|
|
'llm_calls': llm_calls_truncated,
|
|
'errors': errors_truncated,
|
|
},
|
|
}
|
|
|
|
# ========== Export Methods ==========
|
|
|
|
def _escape_csv_field(self, field: str | None) -> str:
|
|
"""Escape a field for CSV output"""
|
|
if field is None:
|
|
return ''
|
|
# Convert non-string types to string first
|
|
if not isinstance(field, str):
|
|
field = str(field)
|
|
# Replace common escape sequences
|
|
field = field.replace('\r\n', '\n').replace('\r', '\n')
|
|
# If field contains comma, double quote, or newline, wrap in quotes
|
|
if ',' in field or '"' in field or '\n' in field:
|
|
# Escape double quotes by doubling them
|
|
field = '"' + field.replace('"', '""') + '"'
|
|
return field
|
|
|
|
def _format_timestamp(self, dt: datetime.datetime) -> str:
|
|
"""Format datetime to ISO format string"""
|
|
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
def _extract_message_text(self, message_content: str) -> str:
|
|
"""Extract plain text from message chain JSON"""
|
|
if not message_content:
|
|
return ''
|
|
|
|
try:
|
|
import json
|
|
|
|
message_chain = json.loads(message_content)
|
|
if not isinstance(message_chain, list):
|
|
return message_content
|
|
|
|
text_parts = []
|
|
for component in message_chain:
|
|
if not isinstance(component, dict):
|
|
continue
|
|
component_type = component.get('type')
|
|
if component_type == 'Plain':
|
|
text = component.get('text', '')
|
|
text_parts.append(text)
|
|
elif component_type == 'At':
|
|
display = component.get('display', '')
|
|
target = component.get('target', '')
|
|
if display:
|
|
text_parts.append(f'@{display}')
|
|
elif target:
|
|
text_parts.append(f'@{target}')
|
|
elif component_type == 'AtAll':
|
|
text_parts.append('@All')
|
|
elif component_type == 'Image':
|
|
text_parts.append('[Image]')
|
|
elif component_type == 'File':
|
|
name = component.get('name', 'File')
|
|
text_parts.append(f'[File: {name}]')
|
|
elif component_type == 'Voice':
|
|
length = component.get('length', 0)
|
|
text_parts.append(f'[Voice {length}s]')
|
|
elif component_type == 'Quote':
|
|
# Quote content is in 'origin' field
|
|
origin = component.get('origin', [])
|
|
if isinstance(origin, list):
|
|
for item in origin:
|
|
if isinstance(item, dict) and item.get('type') == 'Plain':
|
|
text_parts.append(f'> {item.get("text", "")}')
|
|
elif component_type == 'Source':
|
|
# Skip Source component
|
|
continue
|
|
else:
|
|
# Other unknown types
|
|
text_parts.append(f'[{component_type}]')
|
|
|
|
return ''.join(text_parts)
|
|
except (json.JSONDecodeError, TypeError, KeyError):
|
|
# If not valid JSON, return as-is
|
|
return message_content
|
|
|
|
async def export_messages(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export messages as list of dictionaries for CSV conversion"""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
|
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(
|
|
persistence_monitoring.MonitoringMessage.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
|
'runner_name': row[0].runner_name if isinstance(row, tuple) else row.runner_name,
|
|
'message_content': row[0].message_content if isinstance(row, tuple) else row.message_content,
|
|
'message_text': self._extract_message_text(
|
|
row[0].message_content if isinstance(row, tuple) else row.message_content
|
|
),
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'status': row[0].status if isinstance(row, tuple) else row.status,
|
|
'level': row[0].level if isinstance(row, tuple) else row.level,
|
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
async def export_llm_calls(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export LLM calls as list of dictionaries for CSV conversion"""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
|
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(
|
|
persistence_monitoring.MonitoringLLMCall.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
|
'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name,
|
|
'input_tokens': row[0].input_tokens if isinstance(row, tuple) else row.input_tokens,
|
|
'output_tokens': row[0].output_tokens if isinstance(row, tuple) else row.output_tokens,
|
|
'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens,
|
|
'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration,
|
|
'cost': row[0].cost if isinstance(row, tuple) else row.cost,
|
|
'status': row[0].status if isinstance(row, tuple) else row.status,
|
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
|
'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
async def export_embedding_calls(
|
|
self,
|
|
context: TenantContext,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
knowledge_base_id: str | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export embedding calls as list of dictionaries for CSV conversion"""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
|
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
|
if knowledge_base_id:
|
|
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(
|
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
|
'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name,
|
|
'prompt_tokens': row[0].prompt_tokens if isinstance(row, tuple) else row.prompt_tokens,
|
|
'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens,
|
|
'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration,
|
|
'input_count': row[0].input_count if isinstance(row, tuple) else row.input_count,
|
|
'status': row[0].status if isinstance(row, tuple) else row.status,
|
|
'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,
|
|
'knowledge_base_id': row[0].knowledge_base_id if isinstance(row, tuple) else row.knowledge_base_id,
|
|
'query_text': row[0].query_text if isinstance(row, tuple) else row.query_text,
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
|
'call_type': row[0].call_type if isinstance(row, tuple) else row.call_type,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
async def export_errors(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export errors as list of dictionaries for CSV conversion"""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
|
|
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(
|
|
persistence_monitoring.MonitoringError.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
|
'error_type': row[0].error_type if isinstance(row, tuple) else row.error_type,
|
|
'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message,
|
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
|
'stack_trace': row[0].stack_trace if isinstance(row, tuple) else row.stack_trace,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
async def export_sessions(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export sessions as list of dictionaries for CSV conversion"""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
|
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
|
persistence_monitoring.MonitoringSession.last_activity.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
|
'message_count': row[0].message_count if isinstance(row, tuple) else row.message_count,
|
|
'start_time': self._format_timestamp(row[0].start_time if isinstance(row, tuple) else row.start_time),
|
|
'last_activity': self._format_timestamp(
|
|
row[0].last_activity if isinstance(row, tuple) else row.last_activity
|
|
),
|
|
'is_active': str(row[0].is_active if isinstance(row, tuple) else row.is_active),
|
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
# ========== Feedback Methods ==========
|
|
|
|
@_workspace_transaction
|
|
async def record_feedback(
|
|
self,
|
|
context: ExecutionContext,
|
|
feedback_id: str,
|
|
feedback_type: int,
|
|
feedback_content: str | None = None,
|
|
inaccurate_reasons: list[str] | None = None,
|
|
bot_id: str | None = None,
|
|
bot_name: str | None = None,
|
|
pipeline_id: str | None = None,
|
|
pipeline_name: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str | None = None,
|
|
stream_id: str | None = None,
|
|
user_id: str | int | None = None,
|
|
platform: str | None = None,
|
|
) -> str | None:
|
|
"""Record user feedback (like/dislike) from AI Bot conversation.
|
|
|
|
Args:
|
|
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
|
|
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
|
|
feedback_content: Optional user feedback text
|
|
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
|
|
bot_id: Bot ID
|
|
bot_name: Bot name
|
|
pipeline_id: Pipeline ID
|
|
pipeline_name: Pipeline name
|
|
session_id: Session ID
|
|
message_id: Message ID
|
|
stream_id: Stream ID (for WeChat Work streaming messages)
|
|
user_id: User ID
|
|
platform: Platform name (e.g., 'wecom')
|
|
|
|
Returns:
|
|
The record ID
|
|
"""
|
|
import json
|
|
|
|
workspace_uuid = self._require_write_context(context)
|
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
|
|
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
|
|
|
# Handle cancel feedback (type=3): delete existing record
|
|
if feedback_type == 3:
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.delete(MonitoringFeedback).where(
|
|
MonitoringFeedback.workspace_uuid == workspace_uuid,
|
|
MonitoringFeedback.feedback_id == feedback_id,
|
|
)
|
|
)
|
|
return None
|
|
|
|
record_data = {
|
|
'id': str(uuid.uuid4()),
|
|
'workspace_uuid': workspace_uuid,
|
|
'timestamp': now,
|
|
'feedback_id': feedback_id,
|
|
'feedback_type': feedback_type,
|
|
'feedback_content': feedback_content,
|
|
'inaccurate_reasons': reasons_json,
|
|
'bot_id': bot_id,
|
|
'bot_name': bot_name,
|
|
'pipeline_id': pipeline_id,
|
|
'pipeline_name': pipeline_name,
|
|
'session_id': session_id,
|
|
'message_id': message_id,
|
|
'stream_id': stream_id,
|
|
'user_id': _normalize_user_id(user_id),
|
|
'platform': platform,
|
|
}
|
|
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
|
if dialect_name == 'postgresql':
|
|
statement = postgresql_dialect.insert(MonitoringFeedback).values(record_data)
|
|
elif dialect_name == 'sqlite':
|
|
statement = sqlite_dialect.insert(MonitoringFeedback).values(record_data)
|
|
else:
|
|
raise RuntimeError(f'Monitoring feedback upsert does not support {dialect_name!r}')
|
|
|
|
excluded = statement.excluded
|
|
|
|
def preserve_existing(column):
|
|
return sqlalchemy.func.coalesce(sqlalchemy.func.nullif(getattr(excluded, column.key), ''), column)
|
|
|
|
statement = statement.on_conflict_do_update(
|
|
index_elements=[MonitoringFeedback.workspace_uuid, MonitoringFeedback.feedback_id],
|
|
set_={
|
|
'timestamp': excluded.timestamp,
|
|
'feedback_type': excluded.feedback_type,
|
|
'feedback_content': excluded.feedback_content,
|
|
'inaccurate_reasons': excluded.inaccurate_reasons,
|
|
'bot_id': preserve_existing(MonitoringFeedback.bot_id),
|
|
'bot_name': preserve_existing(MonitoringFeedback.bot_name),
|
|
'pipeline_id': preserve_existing(MonitoringFeedback.pipeline_id),
|
|
'pipeline_name': preserve_existing(MonitoringFeedback.pipeline_name),
|
|
'session_id': preserve_existing(MonitoringFeedback.session_id),
|
|
'message_id': preserve_existing(MonitoringFeedback.message_id),
|
|
'stream_id': preserve_existing(MonitoringFeedback.stream_id),
|
|
'user_id': preserve_existing(MonitoringFeedback.user_id),
|
|
'platform': preserve_existing(MonitoringFeedback.platform),
|
|
},
|
|
).returning(MonitoringFeedback.id)
|
|
result = await self.ap.persistence_mgr.execute_async(statement)
|
|
return str(result.scalar_one())
|
|
|
|
async def get_feedback_stats(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> dict:
|
|
"""Get feedback statistics.
|
|
|
|
Returns:
|
|
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
|
"""
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
|
|
|
# Get total likes (feedback_type = 1)
|
|
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
|
persistence_monitoring.MonitoringFeedback.feedback_type == 1
|
|
)
|
|
if conditions:
|
|
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
|
|
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
|
|
total_likes = likes_result.scalar() or 0
|
|
|
|
# Get total dislikes (feedback_type = 2)
|
|
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
|
persistence_monitoring.MonitoringFeedback.feedback_type == 2
|
|
)
|
|
if conditions:
|
|
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
|
|
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
|
|
total_dislikes = dislikes_result.scalar() or 0
|
|
|
|
# Get total feedback count
|
|
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
|
if conditions:
|
|
total_query = total_query.where(sqlalchemy.and_(*conditions))
|
|
total_result = await self.ap.persistence_mgr.execute_async(total_query)
|
|
total_feedback = total_result.scalar() or 0
|
|
|
|
# Calculate satisfaction rate
|
|
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
|
|
|
|
# Get feedback by bot
|
|
bot_stats_query = sqlalchemy.select(
|
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
|
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
|
|
).label('likes'),
|
|
sqlalchemy.func.sum(
|
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
|
|
).label('dislikes'),
|
|
).group_by(
|
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
|
)
|
|
if conditions:
|
|
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
|
|
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
|
|
bot_stats = [
|
|
{
|
|
'bot_id': row.bot_id,
|
|
'bot_name': row.bot_name,
|
|
'total': row.total,
|
|
'likes': row.likes or 0,
|
|
'dislikes': row.dislikes or 0,
|
|
}
|
|
for row in bot_stats_result.all()
|
|
]
|
|
|
|
return {
|
|
'total_feedback': total_feedback,
|
|
'total_likes': total_likes,
|
|
'total_dislikes': total_dislikes,
|
|
'satisfaction_rate': round(satisfaction_rate, 2),
|
|
'by_bot': bot_stats,
|
|
}
|
|
|
|
async def get_feedback_list(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
feedback_type: int | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
execution_statuses: list[str] | None = None,
|
|
mode: str = 'all',
|
|
) -> tuple[list[dict], int]:
|
|
"""Get feedback list with filters."""
|
|
limit, offset = self.normalize_page_window(limit, offset)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
|
|
|
conditions.append(
|
|
self._monitoring_execution_condition(
|
|
persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode
|
|
)
|
|
)
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if feedback_type is not None:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
|
|
|
# Get total count
|
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
|
if conditions:
|
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
# Get feedback list
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
query = query.limit(limit).offset(offset)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return (
|
|
[
|
|
self.ap.persistence_mgr.serialize_model(
|
|
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
|
|
)
|
|
for row in rows
|
|
],
|
|
total,
|
|
)
|
|
|
|
async def export_feedback(
|
|
self,
|
|
context: TenantContext,
|
|
bot_ids: list[str] | None = None,
|
|
pipeline_ids: list[str] | None = None,
|
|
start_time: datetime.datetime | None = None,
|
|
end_time: datetime.datetime | None = None,
|
|
limit: int = 100000,
|
|
) -> list[dict]:
|
|
"""Export feedback as list of dictionaries for CSV conversion."""
|
|
limit = self.normalize_export_limit(limit)
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
|
|
|
if bot_ids:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
|
if pipeline_ids:
|
|
conditions.append(
|
|
self._monitoring_processor_condition(
|
|
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
|
|
)
|
|
)
|
|
if start_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
|
if end_time:
|
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
|
|
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
|
)
|
|
if conditions:
|
|
query = query.where(sqlalchemy.and_(*conditions))
|
|
query = query.limit(limit)
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
|
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
|
|
'feedback_type': 'like'
|
|
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
|
|
else 'dislike',
|
|
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
|
|
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
|
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
|
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
|
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
|
}
|
|
for row in rows
|
|
]
|