Coverage for mcpgateway/services/prompt_service.py: 63%

1# -*- coding: utf-8 -*-

2"""Prompt Service Implementation.

3

4 Copyright 2025

5 SPDX-License-Identifier: Apache-2.0

6 Authors: Mihai Criveti

7

8 This module implements prompt template management according to the MCP specification.

9 It 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"""

16

17# Standard

18 import asyncio

19 from datetime import datetime, timezone

20 import logging

21 from string import Formatter

22 from typing import Any, AsyncGenerator, Dict, List, Optional, Set

23

24# Third-Party

25 from jinja2 import Environment, meta, select_autoescape

26 from sqlalchemy import delete, func, not_, select

27 from sqlalchemy.exc import IntegrityError

28 from sqlalchemy.orm import Session

29

30# First-Party

31 from mcpgateway.db import Prompt as DbPrompt

32 from mcpgateway.db import PromptMetric, server_prompt_association

33 from mcpgateway.models import Message, PromptResult, Role, TextContent

34 from mcpgateway.schemas import PromptCreate, PromptRead, PromptUpdate

35

36 logger = logging.getLogger(__name__)

37

38

39 class PromptError(Exception):

40 """Base class for prompt-related errors."""

41

42

43 class PromptNotFoundError(PromptError):

44 """Raised when a requested prompt is not found."""

45

46

47 class PromptNameConflictError(PromptError):

48 """Raised when a prompt name conflicts with existing (active or inactive) prompt."""

49

50 def __init__(self, name: str, is_active: bool = True, prompt_id: Optional[int] = None):

51 """Initialize the error with prompt information.

52

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)

65

66

67 class PromptValidationError(PromptError):

68 """Raised when prompt validation fails."""

69

70

71 class PromptService:

72 """Service for managing prompt templates.

73

74 Handles:

75 - Template registration and retrieval

76 - Argument validation

77 - Template rendering

78 - Resource embedding

79 - Active/inactive status management

80 """

81

82 def __init__(self) -> None:

83 """

84 Initialize the prompt service.

85

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)

93

94 async def initialize(self) -> None:

95 """Initialize the service."""

96 logger.info("Initializing prompt service")

97

98 async def shutdown(self) -> None:

99 """Shutdown the service."""

100 self._event_subscribers.clear()

101 logger.info("Prompt service shutdown complete")

102

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.

107

108 Args:

109 db_prompt: Db prompt to convert

110

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

134

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 }

155

156 async def register_prompt(self, db: Session, prompt: PromptCreate) -> PromptRead:

157 """Register a new prompt template.

158

159 Args:

160 db: Database session

161 prompt: Prompt creation schema

162

163 Returns:

164 Created prompt information

165

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()

173

174 if existing_prompt:

175 raise PromptNameConflictError(

176 prompt.name,

177 is_active=existing_prompt.is_active,

178 prompt_id=existing_prompt.id,

179 )

180

181 # Validate template syntax

182 self._validate_template(prompt.template)

183

184 # Extract required arguments from template

185 required_args = self._get_required_arguments(prompt.template)

186

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

198

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 )

206

207 # Add to DB

208 db.add(db_prompt)

209 db.commit()

210 db.refresh(db_prompt)

211

212 # Notify subscribers

213 await self._notify_prompt_added(db_prompt)

214

215 logger.info(f"Registered prompt: {prompt.name}")

216 prompt_dict = self._convert_db_prompt(db_prompt)

217 return PromptRead.model_validate(prompt_dict)

218

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)}")

225

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.

229

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.

234

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.

241

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]

252

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.

256

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.

261

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.

269

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]

280

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.

283

284 Args:

285 db: Database session

286 name: Name of prompt to get

287 arguments: Optional arguments for rendering

288

289 Returns:

290 Prompt result with rendered messages

291

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()

298

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")

303

304 raise PromptNotFoundError(f"Prompt not found: {name}")

305

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 )

316

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)}")

324

325 async def update_prompt(self, db: Session, name: str, prompt_update: PromptUpdate) -> PromptRead:

326 """Update an existing prompt.

327

328 Args:

329 db: Database session

330 name: Name of prompt to update

331 prompt_update: Updated prompt data

332

333 Returns:

334 Updated prompt information

335

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")

347

348 raise PromptNotFoundError(f"Prompt not found: {name}")

349

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 )

358

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

379

380 prompt.updated_at = datetime.now(timezone.utc)

381 db.commit()

382 db.refresh(prompt)

383

384 await self._notify_prompt_updated(prompt)

385 return PromptRead.model_validate(self._convert_db_prompt(prompt))

386

387 except Exception as e:

388 db.rollback()

389 raise PromptError(f"Failed to update prompt: {str(e)}")

390

391 async def toggle_prompt_status(self, db: Session, prompt_id: int, activate: bool) -> PromptRead:

392 """Toggle prompt active status.

393

394 Args:

395 db: Database session

396 prompt_id: Prompt ID to toggle

397 activate: True to activate, False to deactivate

398

399 Returns:

400 Updated prompt information

401

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)}")

424

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.

428

429 Args:

430 db: Database session

431 name: Name of prompt

432 include_inactive: Whether to include inactive prompts

433

434 Returns:

435 Prompt details

436

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)

452

453 async def delete_prompt(self, db: Session, name: str) -> None:

454 """Permanently delete a registered prompt.

455

456 Args:

457 db: Database session

458 name: Name of prompt to delete

459

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)}")

479

480 async def subscribe_events(self) -> AsyncGenerator[Dict[str, Any], None]:

481 """Subscribe to prompt events.

482

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)

494

495 def _validate_template(self, template: str) -> None:

496 """Validate template syntax.

497

498 Args:

499 template: Template to validate

500

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)}")

508

509 def _get_required_arguments(self, template: str) -> Set[str]:

510 """Extract required arguments from template.

511

512 Args:

513 template: Template to analyze

514

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)

523

524 def _render_template(self, template: str, arguments: Dict[str, str]) -> str:

525 """Render template with arguments.

526

527 Args:

528 template: Template to render

529 arguments: Arguments for rendering

530

531 Returns:

532 Rendered template text

533

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)}")

545

546 def _parse_messages(self, text: str) -> List[Message]:

547 """Parse rendered text into messages.

548

549 Args:

550 text: Text to parse

551

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

589

590 async def _notify_prompt_added(self, prompt: DbPrompt) -> None:

591 """

592 Notify subscribers of prompt addition.

593

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)

608

609 async def _notify_prompt_updated(self, prompt: DbPrompt) -> None:

610 """

611 Notify subscribers of prompt update.

612

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)

627

628 async def _notify_prompt_activated(self, prompt: DbPrompt) -> None:

629 """

630 Notify subscribers of prompt activation.

631

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)

641

642 async def _notify_prompt_deactivated(self, prompt: DbPrompt) -> None:

643 """

644 Notify subscribers of prompt deactivation.

645

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)

655

656 async def _notify_prompt_deleted(self, prompt_info: Dict[str, Any]) -> None:

657 """

658 Notify subscribers of prompt deletion.

659

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)

669

670 async def _notify_prompt_removed(self, prompt: DbPrompt) -> None:

671 """

672 Notify subscribers of prompt removal (deactivation).

673

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)

683

684 async def _publish_event(self, event: Dict[str, Any]) -> None:

685 """

686 Publish event to all subscribers.

687

688 Args:

689 event: Dictionary containing event info

690 """

691 for queue in self._event_subscribers:

692 await queue.put(event)

693

694 # --- Metrics ---

695 async def aggregate_metrics(self, db: Session) -> Dict[str, Any]:

696 """

697 Aggregate metrics for all prompt invocations.

698

699 Args:

700 db: Database Session

701

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 """

713

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()

722

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 }

733

734 async def reset_metrics(self, db: Session) -> None:

735 """

736 Reset all prompt metrics by deleting all records from the prompt metrics table.

737

738 Args:

739 db: Database Session

740 """

741

742 db.execute(delete(PromptMetric))

743 db.commit()