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