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

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

2"""Prompt Service Implementation. 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

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

16 

17# Standard 

18import asyncio 

19from datetime import datetime, timezone 

20import logging 

21from string import Formatter 

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

23 

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 

29 

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 

35 

36logger = logging.getLogger(__name__) 

37 

38 

39class PromptError(Exception): 

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

41 

42 

43class PromptNotFoundError(PromptError): 

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

45 

46 

47class 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 

67class PromptValidationError(PromptError): 

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

69 

70 

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