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

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 

17import asyncio 

18import logging 

19from datetime import datetime 

20from string import Formatter 

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

22 

23from jinja2 import Environment, meta, select_autoescape 

24from sqlalchemy import delete, func, not_, select 

25from sqlalchemy.exc import IntegrityError 

26from sqlalchemy.orm import Session 

27 

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 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36class PromptError(Exception): 

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

38 

39 

40class PromptNotFoundError(PromptError): 

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

42 

43 

44class PromptNameConflictError(PromptError): 

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

46 

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

48 """Initialize the error with prompt information. 

49 

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) 

62 

63 

64class PromptValidationError(PromptError): 

65 """Raised when prompt validation fails.""" 

66 

67 

68class PromptService: 

69 """Service for managing prompt templates. 

70 

71 Handles: 

72 - Template registration and retrieval 

73 - Argument validation 

74 - Template rendering 

75 - Resource embedding 

76 - Active/inactive status management 

77 """ 

78 

79 def __init__(self) -> None: 

80 """ 

81 Initialize the prompt service. 

82 

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) 

90 

91 async def initialize(self) -> None: 

92 """Initialize the service.""" 

93 logger.info("Initializing prompt service") 

94 

95 async def shutdown(self) -> None: 

96 """Shutdown the service.""" 

97 self._event_subscribers.clear() 

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

99 

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. 

104 

105 Args: 

106 db_prompt: Db prompt to convert 

107 

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 

131 

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 } 

152 

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

154 """Register a new prompt template. 

155 

156 Args: 

157 db: Database session 

158 prompt: Prompt creation schema 

159 

160 Returns: 

161 Created prompt information 

162 

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

170 

171 if existing_prompt: 

172 raise PromptNameConflictError( 

173 prompt.name, 

174 is_active=existing_prompt.is_active, 

175 prompt_id=existing_prompt.id, 

176 ) 

177 

178 # Validate template syntax 

179 self._validate_template(prompt.template) 

180 

181 # Extract required arguments from template 

182 required_args = self._get_required_arguments(prompt.template) 

183 

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 

195 

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 ) 

203 

204 # Add to DB 

205 db.add(db_prompt) 

206 db.commit() 

207 db.refresh(db_prompt) 

208 

209 # Notify subscribers 

210 await self._notify_prompt_added(db_prompt) 

211 

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

213 prompt_dict = self._convert_db_prompt(db_prompt) 

214 return PromptRead.model_validate(prompt_dict) 

215 

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

222 

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. 

226 

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. 

231 

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. 

238 

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] 

249 

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. 

253 

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. 

258 

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. 

266 

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] 

277 

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. 

280 

281 Args: 

282 db: Database session 

283 name: Name of prompt to get 

284 arguments: Optional arguments for rendering 

285 

286 Returns: 

287 Prompt result with rendered messages 

288 

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

295 

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

300 

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

302 

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 ) 

313 

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

321 

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

323 """Update an existing prompt. 

324 

325 Args: 

326 db: Database session 

327 name: Name of prompt to update 

328 prompt_update: Updated prompt data 

329 

330 Returns: 

331 Updated prompt information 

332 

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

344 

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

346 

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 ) 

355 

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 

376 

377 prompt.updated_at = datetime.utcnow() 

378 db.commit() 

379 db.refresh(prompt) 

380 

381 await self._notify_prompt_updated(prompt) 

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

383 

384 except Exception as e: 

385 db.rollback() 

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

387 

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

389 """Toggle prompt active status. 

390 

391 Args: 

392 db: Database session 

393 prompt_id: Prompt ID to toggle 

394 activate: True to activate, False to deactivate 

395 

396 Returns: 

397 Updated prompt information 

398 

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

421 

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. 

425 

426 Args: 

427 db: Database session 

428 name: Name of prompt 

429 include_inactive: Whether to include inactive prompts 

430 

431 Returns: 

432 Prompt details 

433 

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) 

449 

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

451 """Permanently delete a registered prompt. 

452 

453 Args: 

454 db: Database session 

455 name: Name of prompt to delete 

456 

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

476 

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

478 """Subscribe to prompt events. 

479 

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) 

491 

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

493 """Validate template syntax. 

494 

495 Args: 

496 template: Template to validate 

497 

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

505 

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

507 """Extract required arguments from template. 

508 

509 Args: 

510 template: Template to analyze 

511 

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) 

520 

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

522 """Render template with arguments. 

523 

524 Args: 

525 template: Template to render 

526 arguments: Arguments for rendering 

527 

528 Returns: 

529 Rendered template text 

530 

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

542 

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

544 """Parse rendered text into messages. 

545 

546 Args: 

547 text: Text to parse 

548 

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 

586 

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

588 """ 

589 Notify subscribers of prompt addition. 

590 

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) 

605 

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

607 """ 

608 Notify subscribers of prompt update. 

609 

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) 

624 

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

626 """ 

627 Notify subscribers of prompt activation. 

628 

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) 

638 

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

640 """ 

641 Notify subscribers of prompt deactivation. 

642 

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) 

652 

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

654 """ 

655 Notify subscribers of prompt deletion. 

656 

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) 

666 

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

668 """ 

669 Notify subscribers of prompt removal (deactivation). 

670 

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) 

680 

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

682 """ 

683 Publish event to all subscribers. 

684 

685 Args: 

686 event: Dictionary containing event info 

687 """ 

688 for queue in self._event_subscribers: 

689 await queue.put(event) 

690 

691 # --- Metrics --- 

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

693 """ 

694 Aggregate metrics for all prompt invocations. 

695 

696 Args: 

697 db: Database Session 

698 

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

710 

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

719 

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 } 

730 

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

732 """ 

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

734 

735 Args: 

736 db: Database Session 

737 """ 

738 

739 db.execute(delete(PromptMetric)) 

740 db.commit()