Coverage for mcpgateway/services/prompt_service.py: 63%
270 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +0100
1# -*- coding: utf-8 -*-
2"""Prompt Service Implementation.
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Mihai Criveti
8This module implements prompt template management according to the MCP specification.
9It handles:
10- Prompt template registration and retrieval
11- Prompt argument validation
12- Template rendering with arguments
13- Resource embedding in prompts
14- Active/inactive prompt management
15"""
17# Standard
18import asyncio
19from datetime import datetime, timezone
20import logging
21from string import Formatter
22from typing import Any, AsyncGenerator, Dict, List, Optional, Set
24# Third-Party
25from jinja2 import Environment, meta, select_autoescape
26from sqlalchemy import delete, func, not_, select
27from sqlalchemy.exc import IntegrityError
28from sqlalchemy.orm import Session
30# First-Party
31from mcpgateway.db import Prompt as DbPrompt
32from mcpgateway.db import PromptMetric, server_prompt_association
33from mcpgateway.models import Message, PromptResult, Role, TextContent
34from mcpgateway.schemas import PromptCreate, PromptRead, PromptUpdate
36logger = logging.getLogger(__name__)
39class PromptError(Exception):
40 """Base class for prompt-related errors."""
43class PromptNotFoundError(PromptError):
44 """Raised when a requested prompt is not found."""
47class PromptNameConflictError(PromptError):
48 """Raised when a prompt name conflicts with existing (active or inactive) prompt."""
50 def __init__(self, name: str, is_active: bool = True, prompt_id: Optional[int] = None):
51 """Initialize the error with prompt information.
53 Args:
54 name: The conflicting prompt name
55 is_active: Whether the existing prompt is active
56 prompt_id: ID of the existing prompt if available
57 """
58 self.name = name
59 self.is_active = is_active
60 self.prompt_id = prompt_id
61 message = f"Prompt already exists with name: {name}"
62 if not is_active: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 message += f" (currently inactive, ID: {prompt_id})"
64 super().__init__(message)
67class PromptValidationError(PromptError):
68 """Raised when prompt validation fails."""
71class PromptService:
72 """Service for managing prompt templates.
74 Handles:
75 - Template registration and retrieval
76 - Argument validation
77 - Template rendering
78 - Resource embedding
79 - Active/inactive status management
80 """
82 def __init__(self) -> None:
83 """
84 Initialize the prompt service.
86 Sets up the Jinja2 environment for rendering prompt templates.
87 Although these templates are rendered as JSON for the API, if the output is ever
88 embedded into an HTML page, unescaped content could be exploited for cross-site scripting (XSS) attacks.
89 Enabling autoescaping for 'html' and 'xml' templates via select_autoescape helps mitigate this risk.
90 """
91 self._event_subscribers: List[asyncio.Queue] = []
92 self._jinja_env = Environment(autoescape=select_autoescape(["html", "xml"]), trim_blocks=True, lstrip_blocks=True)
94 async def initialize(self) -> None:
95 """Initialize the service."""
96 logger.info("Initializing prompt service")
98 async def shutdown(self) -> None:
99 """Shutdown the service."""
100 self._event_subscribers.clear()
101 logger.info("Prompt service shutdown complete")
103 def _convert_db_prompt(self, db_prompt: DbPrompt) -> Dict[str, Any]:
104 """
105 Convert a DbPrompt instance to a dictionary matching the PromptRead schema,
106 including aggregated metrics computed from the associated PromptMetric records.
108 Args:
109 db_prompt: Db prompt to convert
111 Returns:
112 dict: Dictionary matching the PromptRead schema
113 """
114 arg_schema = db_prompt.argument_schema or {}
115 properties = arg_schema.get("properties", {})
116 required_list = arg_schema.get("required", [])
117 arguments_list = []
118 for arg_name, prop in properties.items():
119 arguments_list.append(
120 {
121 "name": arg_name,
122 "description": prop.get("description") or "",
123 "required": arg_name in required_list,
124 }
125 )
126 total = len(db_prompt.metrics) if hasattr(db_prompt, "metrics") and db_prompt.metrics is not None else 0
127 successful = sum(1 for m in db_prompt.metrics if m.is_success) if total > 0 else 0
128 failed = sum(1 for m in db_prompt.metrics if not m.is_success) if total > 0 else 0
129 failure_rate = failed / total if total > 0 else 0.0
130 min_rt = min((m.response_time for m in db_prompt.metrics), default=None) if total > 0 else None
131 max_rt = max((m.response_time for m in db_prompt.metrics), default=None) if total > 0 else None
132 avg_rt = (sum(m.response_time for m in db_prompt.metrics) / total) if total > 0 else None
133 last_time = max((m.timestamp for m in db_prompt.metrics), default=None) if total > 0 else None
135 return {
136 "id": db_prompt.id,
137 "name": db_prompt.name,
138 "description": db_prompt.description,
139 "template": db_prompt.template,
140 "arguments": arguments_list,
141 "created_at": db_prompt.created_at,
142 "updated_at": db_prompt.updated_at,
143 "is_active": db_prompt.is_active,
144 "metrics": {
145 "totalExecutions": total,
146 "successfulExecutions": successful,
147 "failedExecutions": failed,
148 "failureRate": failure_rate,
149 "minResponseTime": min_rt,
150 "maxResponseTime": max_rt,
151 "avgResponseTime": avg_rt,
152 "lastExecutionTime": last_time,
153 },
154 }
156 async def register_prompt(self, db: Session, prompt: PromptCreate) -> PromptRead:
157 """Register a new prompt template.
159 Args:
160 db: Database session
161 prompt: Prompt creation schema
163 Returns:
164 Created prompt information
166 Raises:
167 PromptNameConflictError: If prompt name already exists
168 PromptError: For other prompt registration errors
169 """
170 try:
171 # Check for name conflicts (both active and inactive)
172 existing_prompt = db.execute(select(DbPrompt).where(DbPrompt.name == prompt.name)).scalar_one_or_none()
174 if existing_prompt:
175 raise PromptNameConflictError(
176 prompt.name,
177 is_active=existing_prompt.is_active,
178 prompt_id=existing_prompt.id,
179 )
181 # Validate template syntax
182 self._validate_template(prompt.template)
184 # Extract required arguments from template
185 required_args = self._get_required_arguments(prompt.template)
187 # Create argument schema
188 argument_schema = {
189 "type": "object",
190 "properties": {},
191 "required": list(required_args),
192 }
193 for arg in prompt.arguments: 193 ↛ 194line 193 didn't jump to line 194 because the loop on line 193 never started
194 schema = {"type": "string"}
195 if arg.description is not None:
196 schema["description"] = arg.description
197 argument_schema["properties"][arg.name] = schema
199 # Create DB model
200 db_prompt = DbPrompt(
201 name=prompt.name,
202 description=prompt.description,
203 template=prompt.template,
204 argument_schema=argument_schema,
205 )
207 # Add to DB
208 db.add(db_prompt)
209 db.commit()
210 db.refresh(db_prompt)
212 # Notify subscribers
213 await self._notify_prompt_added(db_prompt)
215 logger.info(f"Registered prompt: {prompt.name}")
216 prompt_dict = self._convert_db_prompt(db_prompt)
217 return PromptRead.model_validate(prompt_dict)
219 except IntegrityError:
220 db.rollback()
221 raise PromptError(f"Prompt already exists: {prompt.name}")
222 except Exception as e:
223 db.rollback()
224 raise PromptError(f"Failed to register prompt: {str(e)}")
226 async def list_prompts(self, db: Session, include_inactive: bool = False, cursor: Optional[str] = None) -> List[PromptRead]:
227 """
228 Retrieve a list of prompt templates from the database.
230 This method retrieves prompt templates from the database and converts them into a list
231 of PromptRead objects. It supports filtering out inactive prompts based on the
232 include_inactive parameter. The cursor parameter is reserved for future pagination support
233 but is currently not implemented.
235 Args:
236 db (Session): The SQLAlchemy database session.
237 include_inactive (bool): If True, include inactive prompts in the result.
238 Defaults to False.
239 cursor (Optional[str], optional): An opaque cursor token for pagination. Currently,
240 this parameter is ignored. Defaults to None.
242 Returns:
243 List[PromptRead]: A list of prompt templates represented as PromptRead objects.
244 """
245 query = select(DbPrompt)
246 if not include_inactive: 246 ↛ 249line 246 didn't jump to line 249 because the condition on line 246 was always true
247 query = query.where(DbPrompt.is_active)
248 # Cursor-based pagination logic can be implemented here in the future.
249 logger.debug(cursor)
250 prompts = db.execute(query).scalars().all()
251 return [PromptRead.model_validate(self._convert_db_prompt(p)) for p in prompts]
253 async def list_server_prompts(self, db: Session, server_id: str, include_inactive: bool = False, cursor: Optional[str] = None) -> List[PromptRead]:
254 """
255 Retrieve a list of prompt templates from the database.
257 This method retrieves prompt templates from the database and converts them into a list
258 of PromptRead objects. It supports filtering out inactive prompts based on the
259 include_inactive parameter. The cursor parameter is reserved for future pagination support
260 but is currently not implemented.
262 Args:
263 db (Session): The SQLAlchemy database session.
264 server_id (str): Server ID
265 include_inactive (bool): If True, include inactive prompts in the result.
266 Defaults to False.
267 cursor (Optional[str], optional): An opaque cursor token for pagination. Currently,
268 this parameter is ignored. Defaults to None.
270 Returns:
271 List[PromptRead]: A list of prompt templates represented as PromptRead objects.
272 """
273 query = select(DbPrompt).join(server_prompt_association, DbPrompt.id == server_prompt_association.c.prompt_id).where(server_prompt_association.c.server_id == server_id)
274 if not include_inactive:
275 query = query.where(DbPrompt.is_active)
276 # Cursor-based pagination logic can be implemented here in the future.
277 logger.debug(cursor)
278 prompts = db.execute(query).scalars().all()
279 return [PromptRead.model_validate(self._convert_db_prompt(p)) for p in prompts]
281 async def get_prompt(self, db: Session, name: str, arguments: Optional[Dict[str, str]] = None) -> PromptResult:
282 """Get a prompt template and optionally render it.
284 Args:
285 db: Database session
286 name: Name of prompt to get
287 arguments: Optional arguments for rendering
289 Returns:
290 Prompt result with rendered messages
292 Raises:
293 PromptNotFoundError: If prompt not found
294 PromptError: For other prompt errors
295 """
296 # Find prompt
297 prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name).where(DbPrompt.is_active)).scalar_one_or_none()
299 if not prompt:
300 inactive_prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name).where(not_(DbPrompt.is_active))).scalar_one_or_none()
301 if inactive_prompt: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 raise PromptNotFoundError(f"Prompt '{name}' exists but is inactive")
304 raise PromptNotFoundError(f"Prompt not found: {name}")
306 if not arguments: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 return PromptResult(
308 messages=[
309 Message(
310 role=Role.USER,
311 content=TextContent(type="text", text=prompt.template),
312 )
313 ],
314 description=prompt.description,
315 )
317 try:
318 prompt.validate_arguments(arguments)
319 rendered = self._render_template(prompt.template, arguments)
320 messages = self._parse_messages(rendered)
321 return PromptResult(messages=messages, description=prompt.description)
322 except Exception as e:
323 raise PromptError(f"Failed to process prompt: {str(e)}")
325 async def update_prompt(self, db: Session, name: str, prompt_update: PromptUpdate) -> PromptRead:
326 """Update an existing prompt.
328 Args:
329 db: Database session
330 name: Name of prompt to update
331 prompt_update: Updated prompt data
333 Returns:
334 Updated prompt information
336 Raises:
337 PromptNotFoundError: If prompt not found
338 PromptError: For other update errors
339 PromptNameConflictError: When prompt name conflict happens
340 """
341 try:
342 prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name).where(DbPrompt.is_active)).scalar_one_or_none()
343 if not prompt: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 inactive_prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name).where(not_(DbPrompt.is_active))).scalar_one_or_none()
345 if inactive_prompt:
346 raise PromptNotFoundError(f"Prompt '{name}' exists but is inactive")
348 raise PromptNotFoundError(f"Prompt not found: {name}")
350 if prompt_update.name is not None and prompt_update.name != prompt.name:
351 existing_prompt = db.execute(select(DbPrompt).where(DbPrompt.name == prompt_update.name).where(DbPrompt.id != prompt.id)).scalar_one_or_none()
352 if existing_prompt: 352 ↛ 359line 352 didn't jump to line 359 because the condition on line 352 was always true
353 raise PromptNameConflictError(
354 prompt_update.name,
355 is_active=existing_prompt.is_active,
356 prompt_id=existing_prompt.id,
357 )
359 if prompt_update.name is not None: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 prompt.name = prompt_update.name
361 if prompt_update.description is not None: 361 ↛ 363line 361 didn't jump to line 363 because the condition on line 361 was always true
362 prompt.description = prompt_update.description
363 if prompt_update.template is not None: 363 ↛ 366line 363 didn't jump to line 366 because the condition on line 363 was always true
364 prompt.template = prompt_update.template
365 self._validate_template(prompt.template)
366 if prompt_update.arguments is not None: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true
367 required_args = self._get_required_arguments(prompt.template)
368 argument_schema = {
369 "type": "object",
370 "properties": {},
371 "required": list(required_args),
372 }
373 for arg in prompt_update.arguments:
374 schema = {"type": "string"}
375 if arg.description is not None:
376 schema["description"] = arg.description
377 argument_schema["properties"][arg.name] = schema
378 prompt.argument_schema = argument_schema
380 prompt.updated_at = datetime.now(timezone.utc)
381 db.commit()
382 db.refresh(prompt)
384 await self._notify_prompt_updated(prompt)
385 return PromptRead.model_validate(self._convert_db_prompt(prompt))
387 except Exception as e:
388 db.rollback()
389 raise PromptError(f"Failed to update prompt: {str(e)}")
391 async def toggle_prompt_status(self, db: Session, prompt_id: int, activate: bool) -> PromptRead:
392 """Toggle prompt active status.
394 Args:
395 db: Database session
396 prompt_id: Prompt ID to toggle
397 activate: True to activate, False to deactivate
399 Returns:
400 Updated prompt information
402 Raises:
403 PromptNotFoundError: If prompt not found
404 PromptError: For other errors
405 """
406 try:
407 prompt = db.get(DbPrompt, prompt_id)
408 if not prompt: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true
409 raise PromptNotFoundError(f"Prompt not found: {prompt_id}")
410 if prompt.is_active != activate: 410 ↛ 420line 410 didn't jump to line 420 because the condition on line 410 was always true
411 prompt.is_active = activate
412 prompt.updated_at = datetime.now(timezone.utc)
413 db.commit()
414 db.refresh(prompt)
415 if activate: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true
416 await self._notify_prompt_activated(prompt)
417 else:
418 await self._notify_prompt_deactivated(prompt)
419 logger.info(f"Prompt {prompt.name} {'activated' if activate else 'deactivated'}")
420 return PromptRead.model_validate(self._convert_db_prompt(prompt))
421 except Exception as e:
422 db.rollback()
423 raise PromptError(f"Failed to toggle prompt status: {str(e)}")
425 # Get prompt details for admin ui
426 async def get_prompt_details(self, db: Session, name: str, include_inactive: bool = False) -> Dict[str, Any]:
427 """Get prompt details for admin UI.
429 Args:
430 db: Database session
431 name: Name of prompt
432 include_inactive: Whether to include inactive prompts
434 Returns:
435 Prompt details
437 Raises:
438 PromptNotFoundError: If prompt not found
439 """
440 query = select(DbPrompt).where(DbPrompt.name == name)
441 if not include_inactive:
442 query = query.where(DbPrompt.is_active)
443 prompt = db.execute(query).scalar_one_or_none()
444 if not prompt:
445 if not include_inactive:
446 inactive_prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name).where(not_(DbPrompt.is_active))).scalar_one_or_none()
447 if inactive_prompt:
448 raise PromptNotFoundError(f"Prompt '{name}' exists but is inactive")
449 raise PromptNotFoundError(f"Prompt not found: {name}")
450 # Return the fully converted prompt including metrics
451 return self._convert_db_prompt(prompt)
453 async def delete_prompt(self, db: Session, name: str) -> None:
454 """Permanently delete a registered prompt.
456 Args:
457 db: Database session
458 name: Name of prompt to delete
460 Raises:
461 PromptNotFoundError: If prompt not found
462 PromptError: For other deletion errors
463 Exception: If prompt not found
464 """
465 try:
466 prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name)).scalar_one_or_none()
467 if not prompt:
468 raise PromptNotFoundError(f"Prompt not found: {name}")
469 prompt_info = {"id": prompt.id, "name": prompt.name}
470 db.delete(prompt)
471 db.commit()
472 await self._notify_prompt_deleted(prompt_info)
473 logger.info(f"Permanently deleted prompt: {name}")
474 except Exception as e:
475 db.rollback()
476 if isinstance(e, PromptNotFoundError): 476 ↛ 478line 476 didn't jump to line 478 because the condition on line 476 was always true
477 raise e
478 raise PromptError(f"Failed to delete prompt: {str(e)}")
480 async def subscribe_events(self) -> AsyncGenerator[Dict[str, Any], None]:
481 """Subscribe to prompt events.
483 Yields:
484 Prompt event messages
485 """
486 queue: asyncio.Queue = asyncio.Queue()
487 self._event_subscribers.append(queue)
488 try:
489 while True:
490 event = await queue.get()
491 yield event
492 finally:
493 self._event_subscribers.remove(queue)
495 def _validate_template(self, template: str) -> None:
496 """Validate template syntax.
498 Args:
499 template: Template to validate
501 Raises:
502 PromptValidationError: If template is invalid
503 """
504 try:
505 self._jinja_env.parse(template)
506 except Exception as e:
507 raise PromptValidationError(f"Invalid template syntax: {str(e)}")
509 def _get_required_arguments(self, template: str) -> Set[str]:
510 """Extract required arguments from template.
512 Args:
513 template: Template to analyze
515 Returns:
516 Set of required argument names
517 """
518 ast = self._jinja_env.parse(template)
519 variables = meta.find_undeclared_variables(ast)
520 formatter = Formatter()
521 format_vars = {field_name for _, field_name, _, _ in formatter.parse(template) if field_name is not None}
522 return variables.union(format_vars)
524 def _render_template(self, template: str, arguments: Dict[str, str]) -> str:
525 """Render template with arguments.
527 Args:
528 template: Template to render
529 arguments: Arguments for rendering
531 Returns:
532 Rendered template text
534 Raises:
535 PromptError: If rendering fails
536 """
537 try:
538 jinja_template = self._jinja_env.from_string(template)
539 return jinja_template.render(**arguments)
540 except Exception:
541 try:
542 return template.format(**arguments)
543 except Exception as e:
544 raise PromptError(f"Failed to render template: {str(e)}")
546 def _parse_messages(self, text: str) -> List[Message]:
547 """Parse rendered text into messages.
549 Args:
550 text: Text to parse
552 Returns:
553 List of parsed messages
554 """
555 messages = []
556 current_role = Role.USER
557 current_text = []
558 for line in text.split("\n"):
559 if line.startswith("# Assistant:"): 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true
560 if current_text:
561 messages.append(
562 Message(
563 role=current_role,
564 content=TextContent(type="text", text="\n".join(current_text).strip()),
565 )
566 )
567 current_role = Role.ASSISTANT
568 current_text = []
569 elif line.startswith("# User:"): 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true
570 if current_text:
571 messages.append(
572 Message(
573 role=current_role,
574 content=TextContent(type="text", text="\n".join(current_text).strip()),
575 )
576 )
577 current_role = Role.USER
578 current_text = []
579 else:
580 current_text.append(line)
581 if current_text: 581 ↛ 588line 581 didn't jump to line 588 because the condition on line 581 was always true
582 messages.append(
583 Message(
584 role=current_role,
585 content=TextContent(type="text", text="\n".join(current_text).strip()),
586 )
587 )
588 return messages
590 async def _notify_prompt_added(self, prompt: DbPrompt) -> None:
591 """
592 Notify subscribers of prompt addition.
594 Args:
595 prompt: Prompt to add
596 """
597 event = {
598 "type": "prompt_added",
599 "data": {
600 "id": prompt.id,
601 "name": prompt.name,
602 "description": prompt.description,
603 "is_active": prompt.is_active,
604 },
605 "timestamp": datetime.now(timezone.utc).isoformat(),
606 }
607 await self._publish_event(event)
609 async def _notify_prompt_updated(self, prompt: DbPrompt) -> None:
610 """
611 Notify subscribers of prompt update.
613 Args:
614 prompt: Prompt to update
615 """
616 event = {
617 "type": "prompt_updated",
618 "data": {
619 "id": prompt.id,
620 "name": prompt.name,
621 "description": prompt.description,
622 "is_active": prompt.is_active,
623 },
624 "timestamp": datetime.now(timezone.utc).isoformat(),
625 }
626 await self._publish_event(event)
628 async def _notify_prompt_activated(self, prompt: DbPrompt) -> None:
629 """
630 Notify subscribers of prompt activation.
632 Args:
633 prompt: Prompt to activate
634 """
635 event = {
636 "type": "prompt_activated",
637 "data": {"id": prompt.id, "name": prompt.name, "is_active": True},
638 "timestamp": datetime.now(timezone.utc).isoformat(),
639 }
640 await self._publish_event(event)
642 async def _notify_prompt_deactivated(self, prompt: DbPrompt) -> None:
643 """
644 Notify subscribers of prompt deactivation.
646 Args:
647 prompt: Prompt to deactivate
648 """
649 event = {
650 "type": "prompt_deactivated",
651 "data": {"id": prompt.id, "name": prompt.name, "is_active": False},
652 "timestamp": datetime.now(timezone.utc).isoformat(),
653 }
654 await self._publish_event(event)
656 async def _notify_prompt_deleted(self, prompt_info: Dict[str, Any]) -> None:
657 """
658 Notify subscribers of prompt deletion.
660 Args:
661 prompt_info: Dict on prompt to notify as deleted
662 """
663 event = {
664 "type": "prompt_deleted",
665 "data": prompt_info,
666 "timestamp": datetime.now(timezone.utc).isoformat(),
667 }
668 await self._publish_event(event)
670 async def _notify_prompt_removed(self, prompt: DbPrompt) -> None:
671 """
672 Notify subscribers of prompt removal (deactivation).
674 Args:
675 prompt: Prompt to remove
676 """
677 event = {
678 "type": "prompt_removed",
679 "data": {"id": prompt.id, "name": prompt.name, "is_active": False},
680 "timestamp": datetime.now(timezone.utc).isoformat(),
681 }
682 await self._publish_event(event)
684 async def _publish_event(self, event: Dict[str, Any]) -> None:
685 """
686 Publish event to all subscribers.
688 Args:
689 event: Dictionary containing event info
690 """
691 for queue in self._event_subscribers:
692 await queue.put(event)
694 # --- Metrics ---
695 async def aggregate_metrics(self, db: Session) -> Dict[str, Any]:
696 """
697 Aggregate metrics for all prompt invocations.
699 Args:
700 db: Database Session
702 Returns:
703 Dict[str, Any]: Aggregated prompt metrics with keys:
704 - total_executions
705 - successful_executions
706 - failed_executions
707 - failure_rate
708 - min_response_time
709 - max_response_time
710 - avg_response_time
711 - last_execution_time
712 """
714 total = db.execute(select(func.count(PromptMetric.id))).scalar() or 0 # pylint: disable=not-callable
715 successful = db.execute(select(func.count(PromptMetric.id)).where(PromptMetric.is_success)).scalar() or 0 # pylint: disable=not-callable
716 failed = db.execute(select(func.count(PromptMetric.id)).where(not_(PromptMetric.is_success))).scalar() or 0 # pylint: disable=not-callable
717 failure_rate = failed / total if total > 0 else 0.0
718 min_rt = db.execute(select(func.min(PromptMetric.response_time))).scalar()
719 max_rt = db.execute(select(func.max(PromptMetric.response_time))).scalar()
720 avg_rt = db.execute(select(func.avg(PromptMetric.response_time))).scalar()
721 last_time = db.execute(select(func.max(PromptMetric.timestamp))).scalar()
723 return {
724 "total_executions": total,
725 "successful_executions": successful,
726 "failed_executions": failed,
727 "failure_rate": failure_rate,
728 "min_response_time": min_rt,
729 "max_response_time": max_rt,
730 "avg_response_time": avg_rt,
731 "last_execution_time": last_time,
732 }
734 async def reset_metrics(self, db: Session) -> None:
735 """
736 Reset all prompt metrics by deleting all records from the prompt metrics table.
738 Args:
739 db: Database Session
740 """
742 db.execute(delete(PromptMetric))
743 db.commit()