From e80e12eb438dda0badce73b600b56a14684fa0af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 05:59:34 +0000 Subject: [PATCH 1/2] Add library API for programmatic prompt refinement This commit adds a clean Python library API to Promptheus, allowing developers to use it programmatically in their own applications. Key changes: - Created src/promptheus/api.py with public API functions - Updated __init__.py to export library interface - Added comprehensive library usage documentation to README - Created tests for the library API - Updated pyproject.toml with improved dependency documentation - Bumped version to 0.3.1 API functions: - refine_prompt(): Main refinement function (light or question-based) - generate_questions(): Generate clarifying questions only - refine_with_answers(): Refine using pre-provided answers - tweak_prompt(): Apply specific modifications to a prompt - list_available_providers(): List configured AI providers - list_available_models(): List available models per provider Exported classes: - Config: Configuration class for providers and models - get_provider(): Get a provider instance directly - Exceptions: ProviderAPIError, InvalidProviderError, PromptCancelled The library maintains full backward compatibility with the CLI while enabling new use cases like batch processing, web application integration, and custom refinement workflows. --- README.md | 232 ++++++++++++++++- pyproject.toml | 23 +- src/promptheus/__init__.py | 69 ++++- src/promptheus/api.py | 505 +++++++++++++++++++++++++++++++++++++ tests/test_library_api.py | 375 +++++++++++++++++++++++++++ 5 files changed, 1195 insertions(+), 9 deletions(-) create mode 100644 src/promptheus/api.py create mode 100644 tests/test_library_api.py diff --git a/README.md b/README.md index 5ea9eba..21d27d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ -[![Python Version](https://img.shields.io/badge/python-3.10+-blue)](https://www.python.org/downloads/) [![PyPI Version](https://img.shields.io/pypi/v/promptheus)](https://pypi.org/project/promptheus/) [![Release Version](https://img.shields.io/badge/release-v0.2.4-brightgreen)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/abhichandra21/Promptheus?style=social)](https://github.com/abhichandra21/Promptheus) +[![Python Version](https://img.shields.io/badge/python-3.10+-blue)](https://www.python.org/downloads/) [![PyPI Version](https://img.shields.io/pypi/v/promptheus)](https://pypi.org/project/promptheus/) [![Release Version](https://img.shields.io/badge/release-v0.3.1-brightgreen)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/abhichandra21/Promptheus?style=social)](https://github.com/abhichandra21/Promptheus) [![Deploy GitHub Pages](https://github.com/abhichandra21/Promptheus/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/deploy-pages.yml) [![Docker Build & Test](https://github.com/abhichandra21/Promptheus/actions/workflows/docker-test.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/docker-test.yml) [![Publish Python Package](https://github.com/abhichandra21/Promptheus/actions/workflows/publish.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/publish.yml) @@ -132,6 +132,236 @@ export PROMPTHEUS_TELEMETRY_ENABLED=0 export PROMPTHEUS_HISTORY_DIR=~/.custom_promptheus ``` +## Library Usage + +Promptheus can be used as a **Python library** in your own applications for programmatic prompt refinement. + +### Installation + +```bash +pip install promptheus # Includes CLI and library +pip install promptheus[all] # With all optional features (MCP, Web UI) +``` + +### Quick Start + +```python +from promptheus import refine_prompt + +# Simple refinement +result = refine_prompt("Write a blog post about AI") +print(result['refined_prompt']) +print(f"Task type: {result['task_type']}") +``` + +### Basic Examples + +**Light refinement (no questions)** +```python +from promptheus import refine_prompt + +result = refine_prompt( + "Explain Docker containers", + skip_questions=True # Fast, direct refinement +) +print(result['refined_prompt']) +``` + +**Using specific provider and model** +```python +from promptheus import refine_prompt, Config + +config = Config(provider="openai", model="gpt-4o") +result = refine_prompt( + "Create a REST API schema", + config=config, + skip_questions=True +) +print(result['refined_prompt']) +``` + +**Question-based refinement** +```python +from promptheus import generate_questions, refine_with_answers + +# Step 1: Generate clarifying questions +questions_result = generate_questions("Write a blog post about AI") + +print(f"Task type: {questions_result['task_type']}") +for q in questions_result['questions']: + print(f"- {q['question']} ({q['type']})") + +# Step 2: Provide answers (from user input, database, etc.) +answers = { + 'q0': 'Technical developers', + 'q1': '1500 words', + 'q2': ['Code examples', 'Best practices'] +} + +# Step 3: Refine with answers +final_result = refine_with_answers( + "Write a blog post about AI", + answers, + questions_result['question_mapping'] +) +print(final_result['refined_prompt']) +``` + +**Iterative tweaking** +```python +from promptheus import refine_prompt, tweak_prompt + +# Initial refinement +result = refine_prompt("Write a technical guide", skip_questions=True) +prompt = result['refined_prompt'] + +# Apply specific tweaks +tweaked = tweak_prompt(prompt, "make it more concise") +prompt = tweaked['tweaked_prompt'] + +tweaked = tweak_prompt(prompt, "add more code examples") +prompt = tweaked['tweaked_prompt'] + +print(prompt) +``` + +### Advanced Usage + +**Provider discovery** +```python +from promptheus import list_available_providers, list_available_models + +# List configured providers +providers = list_available_providers() +print(f"Available: {', '.join(providers)}") + +# List models for a provider +models = list_available_models(provider='openai') +print(f"OpenAI models: {models['openai']}") + +# List all models +all_models = list_available_models() +for provider, model_list in all_models.items(): + print(f"{provider}: {len(model_list)} models") +``` + +**Error handling** +```python +from promptheus import refine_prompt, ProviderAPIError, InvalidProviderError + +try: + result = refine_prompt("Write a story", provider="openai") + print(result['refined_prompt']) +except ProviderAPIError as e: + print(f"Provider error: {e}") +except InvalidProviderError as e: + print(f"Invalid provider: {e}") +except ValueError as e: + print(f"Configuration error: {e}") +``` + +**Custom configuration** +```python +from promptheus import Config, get_provider, refine_prompt +import os + +# Set up environment +os.environ['ANTHROPIC_API_KEY'] = 'your-key-here' + +# Create custom configuration +config = Config() +config.set_provider('anthropic') +config.set_model('claude-3-5-sonnet-20241022') + +# Use in refinement +result = refine_prompt( + "Explain quantum computing", + config=config, + skip_questions=True +) +``` + +### API Reference + +**Main Functions:** +- `refine_prompt()` - Main refinement function (light or question-based) +- `generate_questions()` - Generate clarifying questions only +- `refine_with_answers()` - Refine using pre-provided answers +- `tweak_prompt()` - Apply specific modifications to a prompt +- `list_available_providers()` - List configured AI providers +- `list_available_models()` - List available models per provider + +**Configuration:** +- `Config` - Configuration class for providers and models +- `get_provider()` - Get a provider instance directly + +**Exceptions:** +- `ProviderAPIError` - AI provider API failures +- `InvalidProviderError` - Unknown/invalid provider specified +- `PromptCancelled` - User cancellation (CLI context) + +### Use Cases + +**Batch Processing** +```python +from promptheus import refine_prompt + +prompts = [ + "Explain machine learning", + "Create a FastAPI tutorial", + "Review security best practices" +] + +for original in prompts: + result = refine_prompt(original, skip_questions=True) + print(f"Original: {original}") + print(f"Refined: {result['refined_prompt']}\n") +``` + +**Web Application Integration** +```python +from fastapi import FastAPI +from promptheus import refine_prompt, ProviderAPIError + +app = FastAPI() + +@app.post("/refine") +async def refine_endpoint(prompt: str, provider: str = "google"): + try: + result = refine_prompt(prompt, provider=provider, skip_questions=True) + return { + "success": True, + "refined_prompt": result['refined_prompt'], + "task_type": result['task_type'] + } + except ProviderAPIError as e: + return {"success": False, "error": str(e)} +``` + +**Custom Question Flow** +```python +from promptheus import generate_questions, refine_with_answers + +def interactive_refinement(prompt: str): + # Generate questions + q_result = generate_questions(prompt) + + # Present questions to user (web form, CLI, etc.) + answers = {} + for idx, q in enumerate(q_result['questions']): + user_answer = input(f"{q['question']}: ") + answers[f"q{idx}"] = user_answer + + # Refine with answers + final = refine_with_answers( + prompt, + answers, + q_result['question_mapping'] + ) + + return final['refined_prompt'] +``` + ## MCP Server Promptheus includes a **Model Context Protocol (MCP) server** that exposes prompt refinement capabilities as standardized tools for integration with MCP-compatible clients. diff --git a/pyproject.toml b/pyproject.toml index 3e822aa..6792f2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,8 @@ [tool.poetry] name = "promptheus" version = "0.3.1" -description = "AI-powered prompt engineering CLI tool" +<<<<<<< HEAD +description = "AI-powered prompt engineering CLI tool and Python library" authors = ["Promptheus Contributors"] license = "MIT" readme = "README.md" @@ -24,19 +25,29 @@ packages = [{include = "promptheus", from = "src"}] [tool.poetry.dependencies] python = "^3.10" +# Core library dependencies +python-dotenv = "^1.2.0" aiohttp = "^3.9.0" +# AI Provider SDKs (core) anthropic = "^0.70.0" google-genai = "^1.49.0" openai = "^1.51.0" +# CLI interface dependencies (installed by default for backward compatibility) prompt_toolkit = "^3.0.52" pyperclip = "^1.11.0" -python-dotenv = "^1.2.0" questionary = "^2.1.0" rich = "^14.0.0" -mcp = "^1.0.0" -filelock = "^3.20.0" -fastapi = "^0.115.0" -uvicorn = {version = "^0.24.0", extras = ["standard"]} +# Optional features (MCP server, Web UI) +mcp = {version = "^1.0.0", optional = true} +filelock = {version = "^3.20.0", optional = true} +fastapi = {version = "^0.115.0", optional = true} +uvicorn = {version = "^0.24.0", extras = ["standard"], optional = true} + +[tool.poetry.extras] +# Optional feature sets +mcp = ["mcp"] +web = ["fastapi", "uvicorn", "filelock"] +all = ["mcp", "fastapi", "uvicorn", "filelock"] [tool.poetry.group.dev.dependencies] pytest = "~=8.4.0" diff --git a/src/promptheus/__init__.py b/src/promptheus/__init__.py index 3dd72d6..f5dae92 100644 --- a/src/promptheus/__init__.py +++ b/src/promptheus/__init__.py @@ -1,3 +1,68 @@ -"""Promptheus - AI-powered prompt engineering CLI tool.""" +""" +Promptheus - AI-powered prompt engineering tool and library. -__version__ = "0.2.4" +Promptheus can be used as both a CLI tool and a Python library for +programmatic prompt refinement. + +CLI Usage: + $ promptheus "Write a blog post" + $ promptheus --skip-questions "Explain Docker" + +Library Usage: + >>> from promptheus import refine_prompt, Config + >>> + >>> result = refine_prompt("Write a blog post about AI") + >>> print(result['refined_prompt']) + >>> + >>> # With specific configuration + >>> config = Config(provider="openai", model="gpt-4o") + >>> result = refine_prompt( + ... "Explain quantum computing", + ... config=config, + ... skip_questions=True + ... ) +""" + +__version__ = "0.3.1" + +# Library API exports +from promptheus.api import ( + refine_prompt, + tweak_prompt, + generate_questions, + refine_with_answers, + list_available_providers, + list_available_models, +) + +# Core classes and functions +from promptheus.config import Config +from promptheus.providers import get_provider, LLMProvider + +# Exceptions +from promptheus.exceptions import ( + PromptCancelled, + ProviderAPIError, + InvalidProviderError, +) + +__all__ = [ + # Version + "__version__", + # Main API functions + "refine_prompt", + "tweak_prompt", + "generate_questions", + "refine_with_answers", + "list_available_providers", + "list_available_models", + # Configuration + "Config", + # Provider management + "get_provider", + "LLMProvider", + # Exceptions + "PromptCancelled", + "ProviderAPIError", + "InvalidProviderError", +] diff --git a/src/promptheus/api.py b/src/promptheus/api.py new file mode 100644 index 0000000..0b55539 --- /dev/null +++ b/src/promptheus/api.py @@ -0,0 +1,505 @@ +""" +Promptheus Library API + +This module provides a clean programmatic interface for using Promptheus +as a library in your Python applications. + +Example usage: + >>> from promptheus import refine_prompt, Config + >>> + >>> # Simple refinement + >>> result = refine_prompt("Write a blog post about AI") + >>> print(result['refined_prompt']) + >>> + >>> # With specific provider and model + >>> config = Config(provider="openai", model="gpt-4o") + >>> result = refine_prompt( + ... "Explain quantum computing", + ... config=config, + ... skip_questions=True + ... ) + >>> print(result['refined_prompt']) +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Tuple +from argparse import Namespace + +from promptheus.config import Config +from promptheus.providers import LLMProvider, get_provider +from promptheus.prompts import ( + ANALYSIS_REFINEMENT_SYSTEM_INSTRUCTION, + CLARIFICATION_SYSTEM_INSTRUCTION, + GENERATION_SYSTEM_INSTRUCTION, + TWEAK_SYSTEM_INSTRUCTION, +) +from promptheus.exceptions import ProviderAPIError, InvalidProviderError +from promptheus.utils import sanitize_error_message + +logger = logging.getLogger(__name__) + +__all__ = [ + "refine_prompt", + "tweak_prompt", + "generate_questions", + "refine_with_answers", + "list_available_providers", + "list_available_models", +] + + +def refine_prompt( + prompt: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, + skip_questions: bool = True, + answers: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Refine a prompt using AI. + + This is the main library function for prompt refinement. It can operate in + several modes: + + 1. Light refinement (skip_questions=True, default): + Quick improvement without asking clarifying questions + + 2. Question-based refinement (skip_questions=False): + Generates clarifying questions that you can answer programmatically + + 3. Answer-based refinement (answers provided): + Refines the prompt based on provided answers + + Args: + prompt: The original prompt to refine + provider: AI provider to use (google, openai, anthropic, etc.) + Overrides config if provided + model: Specific model to use. Overrides config if provided + config: Configuration object. If None, creates default config + skip_questions: If True, performs light refinement without questions + answers: Pre-provided answers to questions (dict of question_id -> answer) + + Returns: + Dictionary containing: + - refined_prompt (str): The refined prompt + - task_type (str): Detected task type ('analysis' or 'generation') + - was_refined (bool): Whether refinement was applied + - provider (str): Provider used + - model (str): Model used + - questions (List[Dict], optional): Generated questions if skip_questions=False + - question_mapping (Dict, optional): Mapping of question IDs to text + + Raises: + ProviderAPIError: If the AI provider fails + InvalidProviderError: If the specified provider is invalid + ValueError: If invalid arguments are provided + + Examples: + >>> # Simple light refinement + >>> result = refine_prompt("Write a blog post") + >>> print(result['refined_prompt']) + + >>> # Get questions for refinement + >>> result = refine_prompt("Write a blog post", skip_questions=False) + >>> if 'questions' in result: + ... # Present questions to user, collect answers + ... answers = {'q0': 'Technical audience', 'q1': '1000 words'} + ... final = refine_prompt("Write a blog post", answers=answers) + ... print(final['refined_prompt']) + + >>> # Use specific provider and model + >>> config = Config(provider="anthropic", model="claude-3-5-sonnet-20241022") + >>> result = refine_prompt("Explain Docker", config=config) + """ + # Initialize configuration + if config is None: + config = Config() + + # Override provider/model if specified + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + # Validate configuration + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + # Get provider instance + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + # If answers are provided, do answer-based refinement + if answers is not None: + return _refine_with_answers_impl( + provider_instance, prompt, answers, config + ) + + # If skip_questions=True, do light refinement + if skip_questions: + return _light_refine_impl(provider_instance, prompt, config) + + # Otherwise, generate questions for refinement + return _generate_questions_impl(provider_instance, prompt, config) + + +def tweak_prompt( + prompt: str, + tweak_instruction: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Make a specific tweak to a prompt. + + This function applies a targeted modification to an existing prompt + based on a natural language instruction. + + Args: + prompt: The current prompt to modify + tweak_instruction: Natural language description of the change + (e.g., "make it more concise", "add technical details") + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - tweaked_prompt (str): The modified prompt + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + ValueError: If arguments are invalid + + Examples: + >>> result = tweak_prompt( + ... "Write a blog post about AI", + ... "make it more technical and add specific examples" + ... ) + >>> print(result['tweaked_prompt']) + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + try: + tweaked = provider_instance.tweak_prompt( + prompt, tweak_instruction, TWEAK_SYSTEM_INSTRUCTION + ) + return { + "tweaked_prompt": tweaked, + "provider": provider_name, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Tweak failed") + raise ProviderAPIError(f"Failed to tweak prompt: {sanitized}") from exc + + +def generate_questions( + prompt: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Generate clarifying questions for a prompt. + + This function analyzes a prompt and generates relevant clarifying + questions that can help refine it. + + Args: + prompt: The prompt to analyze + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - task_type (str): Detected task type ('analysis' or 'generation') + - questions (List[Dict]): List of question objects with: + - question (str): The question text + - type (str): Question type (text, radio, checkbox, confirm) + - options (List[str], optional): Answer options for radio/checkbox + - required (bool): Whether the question is required + - default (Any, optional): Default answer + - question_mapping (Dict[str, str]): Maps question IDs (q0, q1...) to text + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + + Examples: + >>> result = generate_questions("Write a blog post about AI") + >>> for q in result['questions']: + ... print(f"{q['question']} (type: {q['type']})") + >>> + >>> # Use the question_mapping to build answers + >>> mapping = result['question_mapping'] + >>> answers = { + ... 'q0': 'Technical audience', + ... 'q1': '1500 words' + ... } + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + return _generate_questions_impl(provider_instance, prompt, config) + + +def refine_with_answers( + prompt: str, + answers: Dict[str, Any], + question_mapping: Optional[Dict[str, str]] = None, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Refine a prompt using provided answers to clarifying questions. + + This function takes a prompt and answers to previously generated + questions, and produces a refined prompt. + + Args: + prompt: The original prompt + answers: Dictionary mapping question IDs to answers + question_mapping: Optional mapping of question IDs to question text + (provides context for better refinement) + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - refined_prompt (str): The refined prompt + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + + Examples: + >>> # First, generate questions + >>> q_result = generate_questions("Write a blog post") + >>> + >>> # Collect answers (from user, database, etc.) + >>> answers = { + ... 'q0': 'Technical developers', + ... 'q1': '1200 words', + ... 'q2': ['SEO optimization', 'Code examples'] + ... } + >>> + >>> # Refine with answers + >>> result = refine_with_answers( + ... "Write a blog post", + ... answers, + ... q_result['question_mapping'] + ... ) + >>> print(result['refined_prompt']) + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + return _refine_with_answers_impl( + provider_instance, prompt, answers, config, question_mapping + ) + + +def list_available_providers(config: Optional[Config] = None) -> List[str]: + """ + List all available (configured) AI providers. + + Returns only providers that have valid API keys configured. + + Args: + config: Optional configuration object + + Returns: + List of provider names (e.g., ['google', 'openai', 'anthropic']) + + Examples: + >>> providers = list_available_providers() + >>> print(f"Available providers: {', '.join(providers)}") + """ + if config is None: + config = Config() + + return config.get_configured_providers() + + +def list_available_models( + provider: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, List[str]]: + """ + List available models for one or all providers. + + Args: + provider: Specific provider to list models for (None = all providers) + config: Optional configuration object + + Returns: + Dictionary mapping provider names to lists of model names + Example: {'google': ['gemini-2.0-flash', 'gemini-1.5-pro'], ...} + + Examples: + >>> # List all models + >>> all_models = list_available_models() + >>> for provider, models in all_models.items(): + ... print(f"{provider}: {', '.join(models)}") + >>> + >>> # List models for specific provider + >>> openai_models = list_available_models(provider='openai') + >>> print(openai_models['openai']) + """ + if config is None: + config = Config() + + from promptheus._provider_data import PROVIDER_DATA + + if provider: + if provider not in PROVIDER_DATA: + raise ValueError(f"Unknown provider: {provider}") + models = [m['name'] for m in PROVIDER_DATA[provider]['models']] + return {provider: models} + + # Return all providers + result = {} + for prov_name, prov_data in PROVIDER_DATA.items(): + result[prov_name] = [m['name'] for m in prov_data['models']] + + return result + + +# Internal implementation functions + +def _light_refine_impl( + provider: LLMProvider, + prompt: str, + config: Config, +) -> Dict[str, Any]: + """Internal: Perform light refinement without questions.""" + try: + refined = provider.light_refine( + prompt, ANALYSIS_REFINEMENT_SYSTEM_INSTRUCTION + ) + return { + "refined_prompt": refined, + "task_type": "analysis", + "was_refined": True, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Light refinement failed") + raise ProviderAPIError(f"Light refinement failed: {sanitized}") from exc + + +def _generate_questions_impl( + provider: LLMProvider, + prompt: str, + config: Config, +) -> Dict[str, Any]: + """Internal: Generate clarifying questions.""" + try: + result = provider.generate_questions(prompt, CLARIFICATION_SYSTEM_INSTRUCTION) + + if result is None: + raise ProviderAPIError("Provider returned no questions") + + task_type = result.get("task_type", "generation") + questions = result.get("questions", []) + + # Build question mapping + question_mapping = {} + for idx, q in enumerate(questions): + question_mapping[f"q{idx}"] = q.get("question", f"Question {idx}") + + return { + "task_type": task_type, + "questions": questions, + "question_mapping": question_mapping, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Question generation failed") + raise ProviderAPIError(f"Question generation failed: {sanitized}") from exc + + +def _refine_with_answers_impl( + provider: LLMProvider, + prompt: str, + answers: Dict[str, Any], + config: Config, + question_mapping: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + """Internal: Refine prompt with provided answers.""" + if question_mapping is None: + # Build a basic mapping from answer keys + question_mapping = {key: f"Question {key}" for key in answers.keys()} + + try: + refined = provider.refine_from_answers( + prompt, answers, question_mapping, GENERATION_SYSTEM_INSTRUCTION + ) + return { + "refined_prompt": refined, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Answer-based refinement failed") + raise ProviderAPIError(f"Answer-based refinement failed: {sanitized}") from exc diff --git a/tests/test_library_api.py b/tests/test_library_api.py new file mode 100644 index 0000000..c50df20 --- /dev/null +++ b/tests/test_library_api.py @@ -0,0 +1,375 @@ +""" +Tests for the library API (api.py). + +These tests verify that Promptheus can be used as a library for +programmatic prompt refinement. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from promptheus import ( + refine_prompt, + tweak_prompt, + generate_questions, + refine_with_answers, + list_available_providers, + list_available_models, + Config, + ProviderAPIError, + InvalidProviderError, +) + + +class TestRefinePrompt: + """Test the main refine_prompt() function.""" + + def test_refine_prompt_light_mode(self): + """Test light refinement (skip_questions=True).""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.light_refine.return_value = "Refined prompt here" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = refine_prompt( + "Test prompt", + skip_questions=True + ) + + assert result['refined_prompt'] == "Refined prompt here" + assert result['task_type'] == "analysis" + assert result['was_refined'] is True + assert result['provider'] == 'google' + assert result['model'] == 'gemini-2.0-flash' + + mock_provider.light_refine.assert_called_once() + + def test_refine_prompt_with_provider_override(self): + """Test refining with specific provider.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'openai' + mock_provider.light_refine.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'openai' + mock_config.get_model.return_value = 'gpt-4o' + mock_config_class.return_value = mock_config + + result = refine_prompt( + "Test", + provider="openai", + model="gpt-4o", + skip_questions=True + ) + + mock_config.set_provider.assert_called_once_with("openai") + mock_config.set_model.assert_called_once_with("gpt-4o") + assert result['provider'] == 'openai' + + def test_refine_prompt_with_config_object(self): + """Test using a Config object.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'anthropic' + mock_provider.light_refine.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'anthropic' + mock_config.get_model.return_value = 'claude-3-5-sonnet-20241022' + + result = refine_prompt( + "Test", + config=mock_config, + skip_questions=True + ) + + assert result['refined_prompt'] == "Refined" + + def test_refine_prompt_invalid_config(self): + """Test error handling for invalid configuration.""" + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = False + mock_config.consume_error_messages.return_value = ['Missing API key'] + mock_config_class.return_value = mock_config + + with pytest.raises(ValueError, match="Configuration invalid"): + refine_prompt("Test", skip_questions=True) + + def test_refine_prompt_provider_error(self): + """Test handling of provider API errors.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.light_refine.side_effect = Exception("API Error") + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + with pytest.raises(ProviderAPIError): + refine_prompt("Test", skip_questions=True) + + +class TestGenerateQuestions: + """Test the generate_questions() function.""" + + def test_generate_questions_success(self): + """Test successful question generation.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.generate_questions.return_value = { + 'task_type': 'generation', + 'questions': [ + {'question': 'What is the target audience?', 'type': 'text', 'required': True}, + {'question': 'What is the desired length?', 'type': 'text', 'required': True} + ] + } + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = generate_questions("Write a blog post") + + assert result['task_type'] == 'generation' + assert len(result['questions']) == 2 + assert 'question_mapping' in result + assert result['question_mapping']['q0'] == 'What is the target audience?' + assert result['question_mapping']['q1'] == 'What is the desired length?' + + def test_generate_questions_empty_result(self): + """Test handling of empty question result.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.generate_questions.return_value = None + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config_class.return_value = mock_config + + with pytest.raises(ProviderAPIError, match="Provider returned no questions"): + generate_questions("Test") + + +class TestRefineWithAnswers: + """Test the refine_with_answers() function.""" + + def test_refine_with_answers_success(self): + """Test refinement with provided answers.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.refine_from_answers.return_value = "Detailed refined prompt" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + answers = { + 'q0': 'Technical developers', + 'q1': '1500 words' + } + question_mapping = { + 'q0': 'What is the target audience?', + 'q1': 'What is the desired length?' + } + + result = refine_with_answers( + "Write a blog post", + answers, + question_mapping + ) + + assert result['refined_prompt'] == "Detailed refined prompt" + assert result['provider'] == 'google' + mock_provider.refine_from_answers.assert_called_once() + + def test_refine_with_answers_no_mapping(self): + """Test refinement without question mapping.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.refine_from_answers.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + answers = {'q0': 'Answer'} + + result = refine_with_answers("Test", answers) + + # Should create a basic mapping + call_args = mock_provider.refine_from_answers.call_args + assert call_args[0][2] == {'q0': 'Question q0'} + + +class TestTweakPrompt: + """Test the tweak_prompt() function.""" + + def test_tweak_prompt_success(self): + """Test successful prompt tweaking.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.tweak_prompt.return_value = "Tweaked prompt" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = tweak_prompt( + "Original prompt", + "make it more concise" + ) + + assert result['tweaked_prompt'] == "Tweaked prompt" + assert result['provider'] == 'google' + mock_provider.tweak_prompt.assert_called_once() + + +class TestProviderDiscovery: + """Test provider and model discovery functions.""" + + def test_list_available_providers(self): + """Test listing available providers.""" + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.get_configured_providers.return_value = ['google', 'openai', 'anthropic'] + mock_config_class.return_value = mock_config + + providers = list_available_providers() + + assert providers == ['google', 'openai', 'anthropic'] + + def test_list_available_models_all(self): + """Test listing all models.""" + with patch('promptheus.api.PROVIDER_DATA', { + 'google': { + 'models': [ + {'name': 'gemini-2.0-flash'}, + {'name': 'gemini-1.5-pro'} + ] + }, + 'openai': { + 'models': [ + {'name': 'gpt-4o'}, + {'name': 'gpt-4-turbo'} + ] + } + }): + models = list_available_models() + + assert 'google' in models + assert 'openai' in models + assert 'gemini-2.0-flash' in models['google'] + assert 'gpt-4o' in models['openai'] + + def test_list_available_models_specific_provider(self): + """Test listing models for a specific provider.""" + with patch('promptheus.api.PROVIDER_DATA', { + 'google': { + 'models': [ + {'name': 'gemini-2.0-flash'}, + {'name': 'gemini-1.5-pro'} + ] + } + }): + models = list_available_models(provider='google') + + assert 'google' in models + assert len(models) == 1 + assert 'gemini-2.0-flash' in models['google'] + + def test_list_available_models_invalid_provider(self): + """Test error for invalid provider.""" + with patch('promptheus.api.PROVIDER_DATA', {}): + with pytest.raises(ValueError, match="Unknown provider"): + list_available_models(provider='invalid') + + +class TestIntegrationScenarios: + """Test common integration scenarios.""" + + def test_question_answer_workflow(self): + """Test full question-answer workflow.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + + # Mock question generation + mock_provider.generate_questions.return_value = { + 'task_type': 'generation', + 'questions': [ + {'question': 'Target audience?', 'type': 'text', 'required': True} + ] + } + + # Mock answer refinement + mock_provider.refine_from_answers.return_value = "Final refined prompt" + + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + # Step 1: Generate questions + q_result = generate_questions("Write a blog post") + assert len(q_result['questions']) == 1 + + # Step 2: Provide answers + answers = {'q0': 'Developers'} + + # Step 3: Refine + final = refine_with_answers( + "Write a blog post", + answers, + q_result['question_mapping'] + ) + + assert final['refined_prompt'] == "Final refined prompt" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 31f4405aba7389f64ed964e341b260118078fe7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 05:59:34 +0000 Subject: [PATCH 2/2] Add library API for programmatic prompt refinement This commit adds a clean Python library API to Promptheus, allowing developers to use it programmatically in their own applications. Key changes: - Created src/promptheus/api.py with public API functions - Updated __init__.py to export library interface - Added comprehensive library usage documentation to README - Created tests for the library API - Updated pyproject.toml with improved dependency documentation - Bumped version to 0.3.1 API functions: - refine_prompt(): Main refinement function (light or question-based) - generate_questions(): Generate clarifying questions only - refine_with_answers(): Refine using pre-provided answers - tweak_prompt(): Apply specific modifications to a prompt - list_available_providers(): List configured AI providers - list_available_models(): List available models per provider Exported classes: - Config: Configuration class for providers and models - get_provider(): Get a provider instance directly - Exceptions: ProviderAPIError, InvalidProviderError, PromptCancelled The library maintains full backward compatibility with the CLI while enabling new use cases like batch processing, web application integration, and custom refinement workflows. --- README.md | 232 ++++++++++++++++- poetry.lock | 68 +++-- pyproject.toml | 22 +- src/promptheus/__init__.py | 69 ++++- src/promptheus/api.py | 505 +++++++++++++++++++++++++++++++++++++ tests/test_library_api.py | 375 +++++++++++++++++++++++++++ 6 files changed, 1239 insertions(+), 32 deletions(-) create mode 100644 src/promptheus/api.py create mode 100644 tests/test_library_api.py diff --git a/README.md b/README.md index 5ea9eba..21d27d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ -[![Python Version](https://img.shields.io/badge/python-3.10+-blue)](https://www.python.org/downloads/) [![PyPI Version](https://img.shields.io/pypi/v/promptheus)](https://pypi.org/project/promptheus/) [![Release Version](https://img.shields.io/badge/release-v0.2.4-brightgreen)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/abhichandra21/Promptheus?style=social)](https://github.com/abhichandra21/Promptheus) +[![Python Version](https://img.shields.io/badge/python-3.10+-blue)](https://www.python.org/downloads/) [![PyPI Version](https://img.shields.io/pypi/v/promptheus)](https://pypi.org/project/promptheus/) [![Release Version](https://img.shields.io/badge/release-v0.3.1-brightgreen)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/abhichandra21/Promptheus?style=social)](https://github.com/abhichandra21/Promptheus) [![Deploy GitHub Pages](https://github.com/abhichandra21/Promptheus/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/deploy-pages.yml) [![Docker Build & Test](https://github.com/abhichandra21/Promptheus/actions/workflows/docker-test.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/docker-test.yml) [![Publish Python Package](https://github.com/abhichandra21/Promptheus/actions/workflows/publish.yml/badge.svg)](https://github.com/abhichandra21/Promptheus/actions/workflows/publish.yml) @@ -132,6 +132,236 @@ export PROMPTHEUS_TELEMETRY_ENABLED=0 export PROMPTHEUS_HISTORY_DIR=~/.custom_promptheus ``` +## Library Usage + +Promptheus can be used as a **Python library** in your own applications for programmatic prompt refinement. + +### Installation + +```bash +pip install promptheus # Includes CLI and library +pip install promptheus[all] # With all optional features (MCP, Web UI) +``` + +### Quick Start + +```python +from promptheus import refine_prompt + +# Simple refinement +result = refine_prompt("Write a blog post about AI") +print(result['refined_prompt']) +print(f"Task type: {result['task_type']}") +``` + +### Basic Examples + +**Light refinement (no questions)** +```python +from promptheus import refine_prompt + +result = refine_prompt( + "Explain Docker containers", + skip_questions=True # Fast, direct refinement +) +print(result['refined_prompt']) +``` + +**Using specific provider and model** +```python +from promptheus import refine_prompt, Config + +config = Config(provider="openai", model="gpt-4o") +result = refine_prompt( + "Create a REST API schema", + config=config, + skip_questions=True +) +print(result['refined_prompt']) +``` + +**Question-based refinement** +```python +from promptheus import generate_questions, refine_with_answers + +# Step 1: Generate clarifying questions +questions_result = generate_questions("Write a blog post about AI") + +print(f"Task type: {questions_result['task_type']}") +for q in questions_result['questions']: + print(f"- {q['question']} ({q['type']})") + +# Step 2: Provide answers (from user input, database, etc.) +answers = { + 'q0': 'Technical developers', + 'q1': '1500 words', + 'q2': ['Code examples', 'Best practices'] +} + +# Step 3: Refine with answers +final_result = refine_with_answers( + "Write a blog post about AI", + answers, + questions_result['question_mapping'] +) +print(final_result['refined_prompt']) +``` + +**Iterative tweaking** +```python +from promptheus import refine_prompt, tweak_prompt + +# Initial refinement +result = refine_prompt("Write a technical guide", skip_questions=True) +prompt = result['refined_prompt'] + +# Apply specific tweaks +tweaked = tweak_prompt(prompt, "make it more concise") +prompt = tweaked['tweaked_prompt'] + +tweaked = tweak_prompt(prompt, "add more code examples") +prompt = tweaked['tweaked_prompt'] + +print(prompt) +``` + +### Advanced Usage + +**Provider discovery** +```python +from promptheus import list_available_providers, list_available_models + +# List configured providers +providers = list_available_providers() +print(f"Available: {', '.join(providers)}") + +# List models for a provider +models = list_available_models(provider='openai') +print(f"OpenAI models: {models['openai']}") + +# List all models +all_models = list_available_models() +for provider, model_list in all_models.items(): + print(f"{provider}: {len(model_list)} models") +``` + +**Error handling** +```python +from promptheus import refine_prompt, ProviderAPIError, InvalidProviderError + +try: + result = refine_prompt("Write a story", provider="openai") + print(result['refined_prompt']) +except ProviderAPIError as e: + print(f"Provider error: {e}") +except InvalidProviderError as e: + print(f"Invalid provider: {e}") +except ValueError as e: + print(f"Configuration error: {e}") +``` + +**Custom configuration** +```python +from promptheus import Config, get_provider, refine_prompt +import os + +# Set up environment +os.environ['ANTHROPIC_API_KEY'] = 'your-key-here' + +# Create custom configuration +config = Config() +config.set_provider('anthropic') +config.set_model('claude-3-5-sonnet-20241022') + +# Use in refinement +result = refine_prompt( + "Explain quantum computing", + config=config, + skip_questions=True +) +``` + +### API Reference + +**Main Functions:** +- `refine_prompt()` - Main refinement function (light or question-based) +- `generate_questions()` - Generate clarifying questions only +- `refine_with_answers()` - Refine using pre-provided answers +- `tweak_prompt()` - Apply specific modifications to a prompt +- `list_available_providers()` - List configured AI providers +- `list_available_models()` - List available models per provider + +**Configuration:** +- `Config` - Configuration class for providers and models +- `get_provider()` - Get a provider instance directly + +**Exceptions:** +- `ProviderAPIError` - AI provider API failures +- `InvalidProviderError` - Unknown/invalid provider specified +- `PromptCancelled` - User cancellation (CLI context) + +### Use Cases + +**Batch Processing** +```python +from promptheus import refine_prompt + +prompts = [ + "Explain machine learning", + "Create a FastAPI tutorial", + "Review security best practices" +] + +for original in prompts: + result = refine_prompt(original, skip_questions=True) + print(f"Original: {original}") + print(f"Refined: {result['refined_prompt']}\n") +``` + +**Web Application Integration** +```python +from fastapi import FastAPI +from promptheus import refine_prompt, ProviderAPIError + +app = FastAPI() + +@app.post("/refine") +async def refine_endpoint(prompt: str, provider: str = "google"): + try: + result = refine_prompt(prompt, provider=provider, skip_questions=True) + return { + "success": True, + "refined_prompt": result['refined_prompt'], + "task_type": result['task_type'] + } + except ProviderAPIError as e: + return {"success": False, "error": str(e)} +``` + +**Custom Question Flow** +```python +from promptheus import generate_questions, refine_with_answers + +def interactive_refinement(prompt: str): + # Generate questions + q_result = generate_questions(prompt) + + # Present questions to user (web form, CLI, etc.) + answers = {} + for idx, q in enumerate(q_result['questions']): + user_answer = input(f"{q['question']}: ") + answers[f"q{idx}"] = user_answer + + # Refine with answers + final = refine_with_answers( + prompt, + answers, + q_result['question_mapping'] + ) + + return final['refined_prompt'] +``` + ## MCP Server Promptheus includes a **Model Context Protocol (MCP) server** that exposes prompt refinement capabilities as standardized tools for integration with MCP-compatible clients. diff --git a/poetry.lock b/poetry.lock index 109dcb9..8dc8908 100644 --- a/poetry.lock +++ b/poetry.lock @@ -407,9 +407,10 @@ files = [ name = "click" version = "8.3.1" description = "Composable command line interface toolkit" -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "(extra == \"mcp\" or extra == \"all\") and sys_platform != \"emscripten\" or extra == \"web\" or extra == \"all\"" files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -429,7 +430,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} +markers = {main = "(extra == \"web\" or extra == \"all\") and sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "distro" @@ -483,9 +484,10 @@ test = ["pytest (>=6)"] name = "fastapi" version = "0.115.14" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"web\" or extra == \"all\"" files = [ {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, @@ -504,9 +506,10 @@ standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "htt name = "filelock" version = "3.20.0" description = "A platform independent file lock." -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"web\" or extra == \"all\"" files = [ {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, @@ -743,9 +746,10 @@ trio = ["trio (>=0.22.0,<1.0)"] name = "httptools" version = "0.7.1" description = "A collection of framework independent HTTP protocol utils." -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"web\" or extra == \"all\"" files = [ {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, @@ -821,9 +825,10 @@ zstd = ["zstandard (>=0.18.0)"] name = "httpx-sse" version = "0.4.3" description = "Consume Server-Sent Event (SSE) messages with HTTPX." -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, @@ -972,9 +977,10 @@ files = [ name = "jsonschema" version = "4.25.1" description = "An implementation of JSON Schema validation for Python" -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, @@ -994,9 +1000,10 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- name = "jsonschema-specifications" version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, @@ -1033,9 +1040,10 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] name = "mcp" version = "1.12.4" description = "Model Context Protocol SDK" -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789"}, {file = "mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5"}, @@ -1620,9 +1628,10 @@ typing-extensions = ">=4.14.1" name = "pydantic-settings" version = "2.12.0" description = "Settings management using Pydantic" -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809"}, {file = "pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0"}, @@ -1710,9 +1719,10 @@ cli = ["click (>=5.0)"] name = "python-multipart" version = "0.0.20" description = "A streaming multipart parser for Python" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, @@ -1722,10 +1732,10 @@ files = [ name = "pywin32" version = "311" description = "Python for Window Extensions" -optional = false +optional = true python-versions = "*" groups = ["main"] -markers = "sys_platform == \"win32\"" +markers = "(extra == \"mcp\" or extra == \"all\") and sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -1753,9 +1763,10 @@ files = [ name = "pyyaml" version = "6.0.3" description = "YAML parser and emitter for Python" -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"web\" or extra == \"all\"" files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1851,9 +1862,10 @@ prompt_toolkit = ">=2.0,<4.0" name = "referencing" version = "0.37.0" description = "JSON Referencing + Python" -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, @@ -1909,9 +1921,10 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "rpds-py" version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false +optional = true python-versions = ">=3.10" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -2060,9 +2073,10 @@ files = [ name = "sse-starlette" version = "3.0.3" description = "SSE plugin for Starlette" -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\"" files = [ {file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"}, {file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"}, @@ -2081,9 +2095,10 @@ uvicorn = ["uvicorn (>=0.34.0)"] name = "starlette" version = "0.46.2" description = "The little ASGI library that shines." -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"mcp\" or extra == \"all\" or extra == \"web\"" files = [ {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, @@ -2236,9 +2251,10 @@ zstd = ["zstandard (>=0.18.0)"] name = "uvicorn" version = "0.24.0.post1" description = "The lightning-fast ASGI server." -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "(extra == \"mcp\" or extra == \"all\") and sys_platform != \"emscripten\" or extra == \"web\" or extra == \"all\"" files = [ {file = "uvicorn-0.24.0.post1-py3-none-any.whl", hash = "sha256:7c84fea70c619d4a710153482c0d230929af7bcf76c7bfa6de151f0a3a80121e"}, {file = "uvicorn-0.24.0.post1.tar.gz", hash = "sha256:09c8e5a79dc466bdf28dead50093957db184de356fcdc48697bad3bde4c2588e"}, @@ -2263,10 +2279,10 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0) name = "uvloop" version = "0.22.1" description = "Fast implementation of asyncio event loop on top of libuv" -optional = false +optional = true python-versions = ">=3.8.1" groups = ["main"] -markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and (extra == \"web\" or extra == \"all\")" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -2328,9 +2344,10 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", name = "watchfiles" version = "1.1.1" description = "Simple, modern and high performance file watching and code reload in python." -optional = false +optional = true python-versions = ">=3.9" groups = ["main"] +markers = "extra == \"web\" or extra == \"all\"" files = [ {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, @@ -2682,7 +2699,12 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" +[extras] +all = ["fastapi", "filelock", "mcp", "uvicorn"] +mcp = ["mcp"] +web = ["fastapi", "filelock", "uvicorn"] + [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "7c98b27813e7b398f604691942d905d9ed76c55eaee73bfd13c7558dc8dc54eb" +content-hash = "b49ae1b8eb1f204562c9d717500d02748d3e1a56bcc4a4d7c24ba493aca5f69f" diff --git a/pyproject.toml b/pyproject.toml index 3e822aa..40b79be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "promptheus" version = "0.3.1" -description = "AI-powered prompt engineering CLI tool" +description = "AI-powered prompt engineering CLI tool and Python library" authors = ["Promptheus Contributors"] license = "MIT" readme = "README.md" @@ -24,19 +24,29 @@ packages = [{include = "promptheus", from = "src"}] [tool.poetry.dependencies] python = "^3.10" +# Core library dependencies +python-dotenv = "^1.2.0" aiohttp = "^3.9.0" +# AI Provider SDKs (core) anthropic = "^0.70.0" google-genai = "^1.49.0" openai = "^1.51.0" +# CLI interface dependencies (installed by default for backward compatibility) prompt_toolkit = "^3.0.52" pyperclip = "^1.11.0" -python-dotenv = "^1.2.0" questionary = "^2.1.0" rich = "^14.0.0" -mcp = "^1.0.0" -filelock = "^3.20.0" -fastapi = "^0.115.0" -uvicorn = {version = "^0.24.0", extras = ["standard"]} +# Optional features (MCP server, Web UI) +mcp = {version = "^1.0.0", optional = true} +filelock = {version = "^3.20.0", optional = true} +fastapi = {version = "^0.115.0", optional = true} +uvicorn = {version = "^0.24.0", extras = ["standard"], optional = true} + +[tool.poetry.extras] +# Optional feature sets +mcp = ["mcp"] +web = ["fastapi", "uvicorn", "filelock"] +all = ["mcp", "fastapi", "uvicorn", "filelock"] [tool.poetry.group.dev.dependencies] pytest = "~=8.4.0" diff --git a/src/promptheus/__init__.py b/src/promptheus/__init__.py index 3dd72d6..f5dae92 100644 --- a/src/promptheus/__init__.py +++ b/src/promptheus/__init__.py @@ -1,3 +1,68 @@ -"""Promptheus - AI-powered prompt engineering CLI tool.""" +""" +Promptheus - AI-powered prompt engineering tool and library. -__version__ = "0.2.4" +Promptheus can be used as both a CLI tool and a Python library for +programmatic prompt refinement. + +CLI Usage: + $ promptheus "Write a blog post" + $ promptheus --skip-questions "Explain Docker" + +Library Usage: + >>> from promptheus import refine_prompt, Config + >>> + >>> result = refine_prompt("Write a blog post about AI") + >>> print(result['refined_prompt']) + >>> + >>> # With specific configuration + >>> config = Config(provider="openai", model="gpt-4o") + >>> result = refine_prompt( + ... "Explain quantum computing", + ... config=config, + ... skip_questions=True + ... ) +""" + +__version__ = "0.3.1" + +# Library API exports +from promptheus.api import ( + refine_prompt, + tweak_prompt, + generate_questions, + refine_with_answers, + list_available_providers, + list_available_models, +) + +# Core classes and functions +from promptheus.config import Config +from promptheus.providers import get_provider, LLMProvider + +# Exceptions +from promptheus.exceptions import ( + PromptCancelled, + ProviderAPIError, + InvalidProviderError, +) + +__all__ = [ + # Version + "__version__", + # Main API functions + "refine_prompt", + "tweak_prompt", + "generate_questions", + "refine_with_answers", + "list_available_providers", + "list_available_models", + # Configuration + "Config", + # Provider management + "get_provider", + "LLMProvider", + # Exceptions + "PromptCancelled", + "ProviderAPIError", + "InvalidProviderError", +] diff --git a/src/promptheus/api.py b/src/promptheus/api.py new file mode 100644 index 0000000..0b55539 --- /dev/null +++ b/src/promptheus/api.py @@ -0,0 +1,505 @@ +""" +Promptheus Library API + +This module provides a clean programmatic interface for using Promptheus +as a library in your Python applications. + +Example usage: + >>> from promptheus import refine_prompt, Config + >>> + >>> # Simple refinement + >>> result = refine_prompt("Write a blog post about AI") + >>> print(result['refined_prompt']) + >>> + >>> # With specific provider and model + >>> config = Config(provider="openai", model="gpt-4o") + >>> result = refine_prompt( + ... "Explain quantum computing", + ... config=config, + ... skip_questions=True + ... ) + >>> print(result['refined_prompt']) +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Tuple +from argparse import Namespace + +from promptheus.config import Config +from promptheus.providers import LLMProvider, get_provider +from promptheus.prompts import ( + ANALYSIS_REFINEMENT_SYSTEM_INSTRUCTION, + CLARIFICATION_SYSTEM_INSTRUCTION, + GENERATION_SYSTEM_INSTRUCTION, + TWEAK_SYSTEM_INSTRUCTION, +) +from promptheus.exceptions import ProviderAPIError, InvalidProviderError +from promptheus.utils import sanitize_error_message + +logger = logging.getLogger(__name__) + +__all__ = [ + "refine_prompt", + "tweak_prompt", + "generate_questions", + "refine_with_answers", + "list_available_providers", + "list_available_models", +] + + +def refine_prompt( + prompt: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, + skip_questions: bool = True, + answers: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Refine a prompt using AI. + + This is the main library function for prompt refinement. It can operate in + several modes: + + 1. Light refinement (skip_questions=True, default): + Quick improvement without asking clarifying questions + + 2. Question-based refinement (skip_questions=False): + Generates clarifying questions that you can answer programmatically + + 3. Answer-based refinement (answers provided): + Refines the prompt based on provided answers + + Args: + prompt: The original prompt to refine + provider: AI provider to use (google, openai, anthropic, etc.) + Overrides config if provided + model: Specific model to use. Overrides config if provided + config: Configuration object. If None, creates default config + skip_questions: If True, performs light refinement without questions + answers: Pre-provided answers to questions (dict of question_id -> answer) + + Returns: + Dictionary containing: + - refined_prompt (str): The refined prompt + - task_type (str): Detected task type ('analysis' or 'generation') + - was_refined (bool): Whether refinement was applied + - provider (str): Provider used + - model (str): Model used + - questions (List[Dict], optional): Generated questions if skip_questions=False + - question_mapping (Dict, optional): Mapping of question IDs to text + + Raises: + ProviderAPIError: If the AI provider fails + InvalidProviderError: If the specified provider is invalid + ValueError: If invalid arguments are provided + + Examples: + >>> # Simple light refinement + >>> result = refine_prompt("Write a blog post") + >>> print(result['refined_prompt']) + + >>> # Get questions for refinement + >>> result = refine_prompt("Write a blog post", skip_questions=False) + >>> if 'questions' in result: + ... # Present questions to user, collect answers + ... answers = {'q0': 'Technical audience', 'q1': '1000 words'} + ... final = refine_prompt("Write a blog post", answers=answers) + ... print(final['refined_prompt']) + + >>> # Use specific provider and model + >>> config = Config(provider="anthropic", model="claude-3-5-sonnet-20241022") + >>> result = refine_prompt("Explain Docker", config=config) + """ + # Initialize configuration + if config is None: + config = Config() + + # Override provider/model if specified + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + # Validate configuration + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + # Get provider instance + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + # If answers are provided, do answer-based refinement + if answers is not None: + return _refine_with_answers_impl( + provider_instance, prompt, answers, config + ) + + # If skip_questions=True, do light refinement + if skip_questions: + return _light_refine_impl(provider_instance, prompt, config) + + # Otherwise, generate questions for refinement + return _generate_questions_impl(provider_instance, prompt, config) + + +def tweak_prompt( + prompt: str, + tweak_instruction: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Make a specific tweak to a prompt. + + This function applies a targeted modification to an existing prompt + based on a natural language instruction. + + Args: + prompt: The current prompt to modify + tweak_instruction: Natural language description of the change + (e.g., "make it more concise", "add technical details") + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - tweaked_prompt (str): The modified prompt + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + ValueError: If arguments are invalid + + Examples: + >>> result = tweak_prompt( + ... "Write a blog post about AI", + ... "make it more technical and add specific examples" + ... ) + >>> print(result['tweaked_prompt']) + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + try: + tweaked = provider_instance.tweak_prompt( + prompt, tweak_instruction, TWEAK_SYSTEM_INSTRUCTION + ) + return { + "tweaked_prompt": tweaked, + "provider": provider_name, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Tweak failed") + raise ProviderAPIError(f"Failed to tweak prompt: {sanitized}") from exc + + +def generate_questions( + prompt: str, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Generate clarifying questions for a prompt. + + This function analyzes a prompt and generates relevant clarifying + questions that can help refine it. + + Args: + prompt: The prompt to analyze + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - task_type (str): Detected task type ('analysis' or 'generation') + - questions (List[Dict]): List of question objects with: + - question (str): The question text + - type (str): Question type (text, radio, checkbox, confirm) + - options (List[str], optional): Answer options for radio/checkbox + - required (bool): Whether the question is required + - default (Any, optional): Default answer + - question_mapping (Dict[str, str]): Maps question IDs (q0, q1...) to text + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + + Examples: + >>> result = generate_questions("Write a blog post about AI") + >>> for q in result['questions']: + ... print(f"{q['question']} (type: {q['type']})") + >>> + >>> # Use the question_mapping to build answers + >>> mapping = result['question_mapping'] + >>> answers = { + ... 'q0': 'Technical audience', + ... 'q1': '1500 words' + ... } + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + return _generate_questions_impl(provider_instance, prompt, config) + + +def refine_with_answers( + prompt: str, + answers: Dict[str, Any], + question_mapping: Optional[Dict[str, str]] = None, + *, + provider: Optional[str] = None, + model: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """ + Refine a prompt using provided answers to clarifying questions. + + This function takes a prompt and answers to previously generated + questions, and produces a refined prompt. + + Args: + prompt: The original prompt + answers: Dictionary mapping question IDs to answers + question_mapping: Optional mapping of question IDs to question text + (provides context for better refinement) + provider: AI provider to use (overrides config) + model: Specific model to use (overrides config) + config: Configuration object + + Returns: + Dictionary containing: + - refined_prompt (str): The refined prompt + - provider (str): Provider used + - model (str): Model used + + Raises: + ProviderAPIError: If the AI provider fails + + Examples: + >>> # First, generate questions + >>> q_result = generate_questions("Write a blog post") + >>> + >>> # Collect answers (from user, database, etc.) + >>> answers = { + ... 'q0': 'Technical developers', + ... 'q1': '1200 words', + ... 'q2': ['SEO optimization', 'Code examples'] + ... } + >>> + >>> # Refine with answers + >>> result = refine_with_answers( + ... "Write a blog post", + ... answers, + ... q_result['question_mapping'] + ... ) + >>> print(result['refined_prompt']) + """ + if config is None: + config = Config() + + if provider: + config.set_provider(provider) + if model: + config.set_model(model) + + if not config.validate(): + error_messages = config.consume_error_messages() + raise ValueError(f"Configuration invalid: {'; '.join(error_messages)}") + + provider_name = config.provider or "google" + provider_instance = get_provider(provider_name, config, config.get_model()) + + return _refine_with_answers_impl( + provider_instance, prompt, answers, config, question_mapping + ) + + +def list_available_providers(config: Optional[Config] = None) -> List[str]: + """ + List all available (configured) AI providers. + + Returns only providers that have valid API keys configured. + + Args: + config: Optional configuration object + + Returns: + List of provider names (e.g., ['google', 'openai', 'anthropic']) + + Examples: + >>> providers = list_available_providers() + >>> print(f"Available providers: {', '.join(providers)}") + """ + if config is None: + config = Config() + + return config.get_configured_providers() + + +def list_available_models( + provider: Optional[str] = None, + config: Optional[Config] = None, +) -> Dict[str, List[str]]: + """ + List available models for one or all providers. + + Args: + provider: Specific provider to list models for (None = all providers) + config: Optional configuration object + + Returns: + Dictionary mapping provider names to lists of model names + Example: {'google': ['gemini-2.0-flash', 'gemini-1.5-pro'], ...} + + Examples: + >>> # List all models + >>> all_models = list_available_models() + >>> for provider, models in all_models.items(): + ... print(f"{provider}: {', '.join(models)}") + >>> + >>> # List models for specific provider + >>> openai_models = list_available_models(provider='openai') + >>> print(openai_models['openai']) + """ + if config is None: + config = Config() + + from promptheus._provider_data import PROVIDER_DATA + + if provider: + if provider not in PROVIDER_DATA: + raise ValueError(f"Unknown provider: {provider}") + models = [m['name'] for m in PROVIDER_DATA[provider]['models']] + return {provider: models} + + # Return all providers + result = {} + for prov_name, prov_data in PROVIDER_DATA.items(): + result[prov_name] = [m['name'] for m in prov_data['models']] + + return result + + +# Internal implementation functions + +def _light_refine_impl( + provider: LLMProvider, + prompt: str, + config: Config, +) -> Dict[str, Any]: + """Internal: Perform light refinement without questions.""" + try: + refined = provider.light_refine( + prompt, ANALYSIS_REFINEMENT_SYSTEM_INSTRUCTION + ) + return { + "refined_prompt": refined, + "task_type": "analysis", + "was_refined": True, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Light refinement failed") + raise ProviderAPIError(f"Light refinement failed: {sanitized}") from exc + + +def _generate_questions_impl( + provider: LLMProvider, + prompt: str, + config: Config, +) -> Dict[str, Any]: + """Internal: Generate clarifying questions.""" + try: + result = provider.generate_questions(prompt, CLARIFICATION_SYSTEM_INSTRUCTION) + + if result is None: + raise ProviderAPIError("Provider returned no questions") + + task_type = result.get("task_type", "generation") + questions = result.get("questions", []) + + # Build question mapping + question_mapping = {} + for idx, q in enumerate(questions): + question_mapping[f"q{idx}"] = q.get("question", f"Question {idx}") + + return { + "task_type": task_type, + "questions": questions, + "question_mapping": question_mapping, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Question generation failed") + raise ProviderAPIError(f"Question generation failed: {sanitized}") from exc + + +def _refine_with_answers_impl( + provider: LLMProvider, + prompt: str, + answers: Dict[str, Any], + config: Config, + question_mapping: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + """Internal: Refine prompt with provided answers.""" + if question_mapping is None: + # Build a basic mapping from answer keys + question_mapping = {key: f"Question {key}" for key in answers.keys()} + + try: + refined = provider.refine_from_answers( + prompt, answers, question_mapping, GENERATION_SYSTEM_INSTRUCTION + ) + return { + "refined_prompt": refined, + "provider": provider.name if hasattr(provider, 'name') else config.provider, + "model": config.get_model(), + } + except Exception as exc: + sanitized = sanitize_error_message(str(exc)) + logger.exception("Answer-based refinement failed") + raise ProviderAPIError(f"Answer-based refinement failed: {sanitized}") from exc diff --git a/tests/test_library_api.py b/tests/test_library_api.py new file mode 100644 index 0000000..c50df20 --- /dev/null +++ b/tests/test_library_api.py @@ -0,0 +1,375 @@ +""" +Tests for the library API (api.py). + +These tests verify that Promptheus can be used as a library for +programmatic prompt refinement. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from promptheus import ( + refine_prompt, + tweak_prompt, + generate_questions, + refine_with_answers, + list_available_providers, + list_available_models, + Config, + ProviderAPIError, + InvalidProviderError, +) + + +class TestRefinePrompt: + """Test the main refine_prompt() function.""" + + def test_refine_prompt_light_mode(self): + """Test light refinement (skip_questions=True).""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.light_refine.return_value = "Refined prompt here" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = refine_prompt( + "Test prompt", + skip_questions=True + ) + + assert result['refined_prompt'] == "Refined prompt here" + assert result['task_type'] == "analysis" + assert result['was_refined'] is True + assert result['provider'] == 'google' + assert result['model'] == 'gemini-2.0-flash' + + mock_provider.light_refine.assert_called_once() + + def test_refine_prompt_with_provider_override(self): + """Test refining with specific provider.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'openai' + mock_provider.light_refine.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'openai' + mock_config.get_model.return_value = 'gpt-4o' + mock_config_class.return_value = mock_config + + result = refine_prompt( + "Test", + provider="openai", + model="gpt-4o", + skip_questions=True + ) + + mock_config.set_provider.assert_called_once_with("openai") + mock_config.set_model.assert_called_once_with("gpt-4o") + assert result['provider'] == 'openai' + + def test_refine_prompt_with_config_object(self): + """Test using a Config object.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'anthropic' + mock_provider.light_refine.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'anthropic' + mock_config.get_model.return_value = 'claude-3-5-sonnet-20241022' + + result = refine_prompt( + "Test", + config=mock_config, + skip_questions=True + ) + + assert result['refined_prompt'] == "Refined" + + def test_refine_prompt_invalid_config(self): + """Test error handling for invalid configuration.""" + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = False + mock_config.consume_error_messages.return_value = ['Missing API key'] + mock_config_class.return_value = mock_config + + with pytest.raises(ValueError, match="Configuration invalid"): + refine_prompt("Test", skip_questions=True) + + def test_refine_prompt_provider_error(self): + """Test handling of provider API errors.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.light_refine.side_effect = Exception("API Error") + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + with pytest.raises(ProviderAPIError): + refine_prompt("Test", skip_questions=True) + + +class TestGenerateQuestions: + """Test the generate_questions() function.""" + + def test_generate_questions_success(self): + """Test successful question generation.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.generate_questions.return_value = { + 'task_type': 'generation', + 'questions': [ + {'question': 'What is the target audience?', 'type': 'text', 'required': True}, + {'question': 'What is the desired length?', 'type': 'text', 'required': True} + ] + } + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = generate_questions("Write a blog post") + + assert result['task_type'] == 'generation' + assert len(result['questions']) == 2 + assert 'question_mapping' in result + assert result['question_mapping']['q0'] == 'What is the target audience?' + assert result['question_mapping']['q1'] == 'What is the desired length?' + + def test_generate_questions_empty_result(self): + """Test handling of empty question result.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.generate_questions.return_value = None + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config_class.return_value = mock_config + + with pytest.raises(ProviderAPIError, match="Provider returned no questions"): + generate_questions("Test") + + +class TestRefineWithAnswers: + """Test the refine_with_answers() function.""" + + def test_refine_with_answers_success(self): + """Test refinement with provided answers.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.refine_from_answers.return_value = "Detailed refined prompt" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + answers = { + 'q0': 'Technical developers', + 'q1': '1500 words' + } + question_mapping = { + 'q0': 'What is the target audience?', + 'q1': 'What is the desired length?' + } + + result = refine_with_answers( + "Write a blog post", + answers, + question_mapping + ) + + assert result['refined_prompt'] == "Detailed refined prompt" + assert result['provider'] == 'google' + mock_provider.refine_from_answers.assert_called_once() + + def test_refine_with_answers_no_mapping(self): + """Test refinement without question mapping.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.refine_from_answers.return_value = "Refined" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + answers = {'q0': 'Answer'} + + result = refine_with_answers("Test", answers) + + # Should create a basic mapping + call_args = mock_provider.refine_from_answers.call_args + assert call_args[0][2] == {'q0': 'Question q0'} + + +class TestTweakPrompt: + """Test the tweak_prompt() function.""" + + def test_tweak_prompt_success(self): + """Test successful prompt tweaking.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + mock_provider.tweak_prompt.return_value = "Tweaked prompt" + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + result = tweak_prompt( + "Original prompt", + "make it more concise" + ) + + assert result['tweaked_prompt'] == "Tweaked prompt" + assert result['provider'] == 'google' + mock_provider.tweak_prompt.assert_called_once() + + +class TestProviderDiscovery: + """Test provider and model discovery functions.""" + + def test_list_available_providers(self): + """Test listing available providers.""" + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.get_configured_providers.return_value = ['google', 'openai', 'anthropic'] + mock_config_class.return_value = mock_config + + providers = list_available_providers() + + assert providers == ['google', 'openai', 'anthropic'] + + def test_list_available_models_all(self): + """Test listing all models.""" + with patch('promptheus.api.PROVIDER_DATA', { + 'google': { + 'models': [ + {'name': 'gemini-2.0-flash'}, + {'name': 'gemini-1.5-pro'} + ] + }, + 'openai': { + 'models': [ + {'name': 'gpt-4o'}, + {'name': 'gpt-4-turbo'} + ] + } + }): + models = list_available_models() + + assert 'google' in models + assert 'openai' in models + assert 'gemini-2.0-flash' in models['google'] + assert 'gpt-4o' in models['openai'] + + def test_list_available_models_specific_provider(self): + """Test listing models for a specific provider.""" + with patch('promptheus.api.PROVIDER_DATA', { + 'google': { + 'models': [ + {'name': 'gemini-2.0-flash'}, + {'name': 'gemini-1.5-pro'} + ] + } + }): + models = list_available_models(provider='google') + + assert 'google' in models + assert len(models) == 1 + assert 'gemini-2.0-flash' in models['google'] + + def test_list_available_models_invalid_provider(self): + """Test error for invalid provider.""" + with patch('promptheus.api.PROVIDER_DATA', {}): + with pytest.raises(ValueError, match="Unknown provider"): + list_available_models(provider='invalid') + + +class TestIntegrationScenarios: + """Test common integration scenarios.""" + + def test_question_answer_workflow(self): + """Test full question-answer workflow.""" + with patch('promptheus.api.get_provider') as mock_get_provider: + mock_provider = Mock() + mock_provider.name = 'google' + + # Mock question generation + mock_provider.generate_questions.return_value = { + 'task_type': 'generation', + 'questions': [ + {'question': 'Target audience?', 'type': 'text', 'required': True} + ] + } + + # Mock answer refinement + mock_provider.refine_from_answers.return_value = "Final refined prompt" + + mock_get_provider.return_value = mock_provider + + with patch('promptheus.api.Config') as mock_config_class: + mock_config = Mock() + mock_config.validate.return_value = True + mock_config.provider = 'google' + mock_config.get_model.return_value = 'gemini-2.0-flash' + mock_config_class.return_value = mock_config + + # Step 1: Generate questions + q_result = generate_questions("Write a blog post") + assert len(q_result['questions']) == 1 + + # Step 2: Provide answers + answers = {'q0': 'Developers'} + + # Step 3: Refine + final = refine_with_answers( + "Write a blog post", + answers, + q_result['question_mapping'] + ) + + assert final['refined_prompt'] == "Final refined prompt" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])