diff --git a/financial-agent-py/.env.example b/financial-agent-py/.env.example new file mode 100644 index 0000000..d4d5c3a --- /dev/null +++ b/financial-agent-py/.env.example @@ -0,0 +1,14 @@ +# OpenAI Configuration +OPENAI_API_KEY=your_openai_api_key_here + +# Nevermined Configuration (for protected agent) +BUILDER_NVM_API_KEY=your_builder_api_key_here +SUBSCRIBER_NVM_API_KEY=your_subscriber_api_key_here +NVM_ENVIRONMENT=staging_sandbox +NVM_AGENT_ID=your_agent_did_here +NVM_PLAN_ID=your_plan_did_here +NVM_AGENT_HOST=http://localhost:8000 + +# Server Configuration +PORT=8000 +AGENT_URL=http://localhost:8000 \ No newline at end of file diff --git a/financial-agent-py/.gitignore b/financial-agent-py/.gitignore new file mode 100644 index 0000000..536edc7 --- /dev/null +++ b/financial-agent-py/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# macOS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +node_modules/ \ No newline at end of file diff --git a/financial-agent-py/README.md b/financial-agent-py/README.md new file mode 100644 index 0000000..ecb3806 --- /dev/null +++ b/financial-agent-py/README.md @@ -0,0 +1,136 @@ +# Financial Agent (Python) + +A Python implementation of the FinGuide financial education agent using FastAPI, OpenAI, and Nevermined Payments. + +## Features + +- **Two Agent Versions**: + - `index_unprotected.py` - Free access financial education agent + - `index_nevermined.py` - Protected agent with Nevermined payment integration + +- **Dynamic Credit System** (Protected version only): + - Credits charged based on actual token usage + - Formula: `10 * (actual_tokens / max_tokens)`, minimum 1 credit + - Rewards concise responses with lower costs + +- **Session Management**: Maintains conversation context across multiple questions +- **Loading Animation**: Visual feedback during API calls +- **Educational Focus**: Provides financial education rather than investment advice + +## Setup + +1. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +2. **Environment Configuration**: + ```bash + cp .env.example .env + # Edit .env with your API keys and configuration + ``` + +3. **Required Environment Variables**: + ``` + OPENAI_API_KEY=your_openai_api_key_here + + # For protected agent only: + BUILDER_NVM_API_KEY=your_builder_api_key_here + SUBSCRIBER_NVM_API_KEY=your_subscriber_api_key_here + NVM_ENVIRONMENT=staging_sandbox + NVM_AGENT_ID=your_agent_did_here + NVM_PLAN_ID=your_plan_did_here + ``` + +## Running the Agent + +### Unprotected Agent (Free Access) +```bash +cd agent +python index_unprotected.py +# Server starts on http://localhost:8001 +``` + +### Protected Agent (Nevermined Integration) +```bash +cd agent +python index_nevermined.py +# Server starts on http://localhost:8000 +``` + +## Running the Client + +### Test Unprotected Agent +```bash +cd client +python index_unprotected.py +``` + +### Test Protected Agent +```bash +cd client +python index_nevermined.py +``` + +## API Endpoints + +### POST /ask +Send a financial question to FinGuide. + +**Request Body**: +```json +{ + "input_query": "What is diversification?", + "sessionId": "optional-session-id" +} +``` + +**Response** (Unprotected): +```json +{ + "output": "Diversification is like not putting all your eggs in one basket...", + "sessionId": "generated-or-provided-session-id" +} +``` + +**Response** (Protected): +```json +{ + "output": "Diversification is like not putting all your eggs in one basket...", + "sessionId": "generated-or-provided-session-id", + "redemptionResult": { + "creditsRedeemed": 3, + "error": null + } +} +``` + +### GET /health +Health check endpoint. + +**Response**: +```json +{ + "status": "ok" +} +``` + +## Architecture + +- **FastAPI**: Modern Python web framework with automatic OpenAPI docs +- **OpenAI**: GPT-4o-mini for natural language responses +- **Nevermined Payments**: Payment and observability integration (protected version) +- **Session Management**: In-memory conversation history +- **Async/Await**: Non-blocking request handling + +## Key Differences from TypeScript Version + +1. **FastAPI** instead of Express.js +2. **Pydantic models** for request/response validation +3. **Async/await** pattern throughout +4. **Python-style** naming conventions (snake_case) +5. **Local payments-py** library integration + +## Development + +The Python implementation maintains feature parity with the TypeScript version while following Python best practices and idioms. \ No newline at end of file diff --git a/financial-agent-py/agent/index_nevermined.py b/financial-agent-py/agent/index_nevermined.py new file mode 100644 index 0000000..88d3e25 --- /dev/null +++ b/financial-agent-py/agent/index_nevermined.py @@ -0,0 +1,228 @@ +""" +HTTP server for a financial-advice agent using OpenAI. +Exposes a `/ask` endpoint with per-session conversational memory and Nevermined protection. +""" +import os +import uuid +import math +from dataclasses import asdict +from typing import Dict, List, Any, Optional +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Header +from pydantic import BaseModel +import openai +from payments_py import Payments, PaymentOptions + +# Load environment variables +load_dotenv() + +# Configuration +PORT = int(os.getenv("PORT", 8000)) +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +NVM_API_KEY = os.getenv("BUILDER_NVM_API_KEY", "") +NVM_ENVIRONMENT = os.getenv("NVM_ENVIRONMENT", "staging_sandbox") +NVM_AGENT_ID = os.getenv("NVM_AGENT_ID", "") +NVM_AGENT_HOST = os.getenv("NVM_AGENT_HOST", f"http://localhost:{PORT}") + +if not OPENAI_API_KEY: + print("OPENAI_API_KEY is required to run the agent.") + exit(1) + +if not NVM_API_KEY or not NVM_AGENT_ID: + print("Nevermined environment is required: set NVM_API_KEY and NVM_AGENT_ID in .env") + exit(1) + +app = FastAPI(title="FinGuide Protected", version="1.0.0") + +# Initialize Nevermined Payments SDK for access control and observability +payments = Payments.get_instance(PaymentOptions( + nvm_api_key=NVM_API_KEY, + environment=NVM_ENVIRONMENT, +)) + +# Define the AI's role and behavior +def get_system_prompt(max_tokens: int) -> str: + return f"""You are FinGuide, a friendly financial education AI designed to help people learn about investing, personal finance, and market concepts. + +Your role is to provide: + +1. Financial education: Explain investing concepts, terminology, and strategies in simple, beginner-friendly language. +2. General market insights: Discuss historical trends, market principles, and how different asset classes typically behave. +3. Investment fundamentals: Teach about diversification, risk management, dollar-cost averaging, and long-term investing principles. +4. Personal finance guidance: Help with budgeting basics, emergency funds, debt management, and retirement planning concepts. + +Response style: +Write in a natural, conversational tone as if you're chatting with a friend over coffee. Be encouraging and educational rather than giving specific investment advice. Use analogies and everyday examples to explain complex concepts in a way that feels relatable. Always focus on teaching principles rather than recommending specific investments. Be honest about not having access to real-time market data, and naturally encourage users to do their own research and consult professionals for personalized advice. Avoid using bullet points or formal lists - instead, weave information into flowing, natural sentences that feel like genuine conversation. Adjust your response length based on the complexity of the question - for simple questions, keep responses concise (50-100 words), but for complex topics that need thorough explanation, feel free to use up to {max_tokens} tokens to provide comprehensive educational value. + +Important disclaimers: +Remember to naturally work into your conversations that you're an educational AI guide, not a licensed financial advisor. You don't have access to real-time market data or current prices. All the information you share is for educational purposes only, not personalized financial advice. Always encourage users to consult with qualified financial professionals for actual investment decisions. Naturally remind them that past performance never guarantees future results and all investments carry risk, including potential loss of principal. + +When discussing investments: +Focus on general principles and educational concepts while explaining both potential benefits and risks in a conversational way. Naturally emphasize the importance of diversification and long-term thinking. Gently remind users to only invest what they can afford to lose and suggest they research thoroughly while considering their personal financial situation. Make these important points feel like natural parts of the conversation rather than formal warnings.""" + +# Calculate dynamic credit amount based on token usage +def calculate_credit_amount(tokens_used: int, max_tokens: int) -> int: + """ + Formula: 10 * (actual_tokens / max_tokens) + This rewards shorter responses with lower costs + """ + token_utilization = min(tokens_used / max_tokens, 1.0) # Cap at 1 + base_credit_amount = 10 * token_utilization + credit_amount = max(math.ceil(base_credit_amount), 1) # Minimum 1 credit + + print(f"Token usage: {tokens_used}/{max_tokens} ({token_utilization * 100:.1f}%) - Credits: {credit_amount}") + + return credit_amount + +# Store conversation history for each session +sessions: Dict[str, List[Dict[str, str]]] = {} + +# Request/Response models +class AskRequest(BaseModel): + input_query: str + sessionId: Optional[str] = None + +class RedemptionResult(BaseModel): + creditsRedeemed: int + error: Optional[str] = None + +class AskResponse(BaseModel): + output: str + sessionId: str + redemptionResult: Optional[RedemptionResult] = None + +# Handle financial advice requests with Nevermined payment protection and observability +@app.post("/ask", response_model=AskResponse) +async def ask_financial_advice(request: AskRequest, authorization: Optional[str] = Header(default=None)): + try: + # Extract authorization details from request headers + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Authorization header must be in format: Bearer ") + auth_header = authorization + requested_url = f"{NVM_AGENT_HOST}/ask" + http_verb = "POST" + + # Check if user is authorized and has sufficient balance + agent_request = payments.requests.start_processing_request( + NVM_AGENT_ID, + auth_header, + requested_url, + http_verb + ) + + # Reject request if user doesn't have credits or subscription + if not agent_request.balance.is_subscriber or agent_request.balance.balance < 1: + raise HTTPException(status_code=402, detail="Payment Required") + + # Extract access token for credit redemption + request_access_token = auth_header.replace("Bearer ", "", 1) + + # Extract and validate the user's input + input_text = request.input_query.strip() + if not input_text: + raise HTTPException(status_code=400, detail="Missing input") + + # Get or create a session ID for conversation continuity + session_id = request.sessionId or str(uuid.uuid4()) + + # Define the maximum number of tokens for the completion response + max_tokens = 250 + + # Retrieve existing conversation history or start fresh + messages = sessions.get(session_id, []) + + # Add system prompt if this is a new conversation + if len(messages) == 0: + messages.append({ + "role": "system", + "content": get_system_prompt(max_tokens) + }) + + # Add the user's question to the conversation + messages.append({"role": "user", "content": input_text}) + + # Set up observability metadata for tracking this operation + custom_properties = { + "agentid": NVM_AGENT_ID, + "sessionid": session_id, + "operation": "financial_advice", + } + + # Create OpenAI client with Helicone observability integration + openai_config = payments.observability.with_openai( + OPENAI_API_KEY, + agent_request, + custom_properties + ) + + openai_client = openai.OpenAI(**asdict(openai_config)) + + # Call OpenAI API to generate response + completion = openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + temperature=0.3, + max_tokens=max_tokens, + ) + + # Extract the AI's response and token usage + response = completion.choices[0].message.content or "No response generated" + tokens_used = completion.usage.completion_tokens if completion.usage else 0 + + # Save the AI's response to conversation history + messages.append({"role": "assistant", "content": response}) + sessions[session_id] = messages + + # Calculate dynamic credit amount based on token usage + credit_amount = calculate_credit_amount(tokens_used, max_tokens) + + # Redeem credits after successful API call + redemption_result = None + try: + redemption_response = payments.requests.redeem_credits_from_request( + agent_request.agent_request_id, + request_access_token, + credit_amount + ) + + # Extract credits redeemed from the response + credits_redeemed = redemption_response.get("data", {}).get("amountOfCredits", 0) + + # Create redemption result + redemption_result = RedemptionResult(creditsRedeemed=credits_redeemed) + + except Exception as redeem_err: + print(f"Failed to redeem credits: {redeem_err}") + redemption_result = RedemptionResult( + creditsRedeemed=0, + error=str(redeem_err) + ) + + # Return response with session info and payment details + return AskResponse( + output=response, + sessionId=session_id, + redemptionResult=redemption_result + ) + + except HTTPException: + raise + except Exception as error: + print(f"Error handling /ask: {error}") + status_code = 402 if "Payment Required" in str(error) else 500 + detail = "Payment Required" if status_code == 402 else "Internal server error" + raise HTTPException(status_code=status_code, detail=detail) + +# Health check endpoint +@app.get("/health") +async def health_check(): + return {"status": "ok"} + +# Start the server +if __name__ == "__main__": + import uvicorn + print(f"Financial Agent listening on http://localhost:{PORT}") + print(f"NVM_API_KEY: {NVM_API_KEY}") + print(f"NVM_ENVIRONMENT: {NVM_ENVIRONMENT}") + print(f"NVM_AGENT_ID: {NVM_AGENT_ID}") + uvicorn.run(app, host="0.0.0.0", port=PORT) \ No newline at end of file diff --git a/financial-agent-py/agent/index_unprotected.py b/financial-agent-py/agent/index_unprotected.py new file mode 100644 index 0000000..15d92a7 --- /dev/null +++ b/financial-agent-py/agent/index_unprotected.py @@ -0,0 +1,120 @@ +""" +Free-access HTTP server for the financial-advice agent (no Nevermined protection). +Provides a `/ask` endpoint with per-session conversational memory. +""" +import os +import uuid +from typing import Dict, List, Any, Optional +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +import openai + +# Load environment variables +load_dotenv() + +# Configuration +PORT = int(os.getenv("PORT", 8001)) +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") + +if not OPENAI_API_KEY: + print("OPENAI_API_KEY is required to run the agent.") + exit(1) + +# Initialize OpenAI client with API key +client = openai.OpenAI(api_key=OPENAI_API_KEY) + +app = FastAPI(title="FinGuide Unprotected", version="1.0.0") + +# Define the AI's role and behavior +def get_system_prompt(max_tokens: int) -> str: + return f"""You are FinGuide, a friendly financial education AI designed to help people learn about investing, personal finance, and market concepts. + +Your role is to provide: + +1. Financial education: Explain investing concepts, terminology, and strategies in simple, beginner-friendly language. +2. General market insights: Discuss historical trends, market principles, and how different asset classes typically behave. +3. Investment fundamentals: Teach about diversification, risk management, dollar-cost averaging, and long-term investing principles. +4. Personal finance guidance: Help with budgeting basics, emergency funds, debt management, and retirement planning concepts. + +Response style: +Write in a natural, conversational tone as if you're chatting with a friend over coffee. Be encouraging and educational rather than giving specific investment advice. Use analogies and everyday examples to explain complex concepts in a way that feels relatable. Always focus on teaching principles rather than recommending specific investments. Be honest about not having access to real-time market data, and naturally encourage users to do their own research and consult professionals for personalized advice. Avoid using bullet points or formal lists - instead, weave information into flowing, natural sentences that feel like genuine conversation. Adjust your response length based on the complexity of the question - for simple questions, keep responses concise (50-100 words), but for complex topics that need thorough explanation, feel free to use up to {max_tokens} tokens to provide comprehensive educational value. + +Important disclaimers: +Remember to naturally work into your conversations that you're an educational AI guide, not a licensed financial advisor. You don't have access to real-time market data or current prices. All the information you share is for educational purposes only, not personalized financial advice. Always encourage users to consult with qualified financial professionals for actual investment decisions. Naturally remind them that past performance never guarantees future results and all investments carry risk, including potential loss of principal. + +When discussing investments: +Focus on general principles and educational concepts while explaining both potential benefits and risks in a conversational way. Naturally emphasize the importance of diversification and long-term thinking. Gently remind users to only invest what they can afford to lose and suggest they research thoroughly while considering their personal financial situation. Make these important points feel like natural parts of the conversation rather than formal warnings.""" + +# Store conversation history for each session +sessions: Dict[str, List[Dict[str, str]]] = {} + +# Request/Response models +class AskRequest(BaseModel): + input_query: str + sessionId: Optional[str] = None + +class AskResponse(BaseModel): + output: str + sessionId: str + +# Handle financial advice requests with session-based conversation memory +@app.post("/ask", response_model=AskResponse) +async def ask_financial_advice(request: AskRequest): + # Extract and validate the user's input + input_text = request.input_query.strip() + if not input_text: + raise HTTPException(status_code=400, detail="Missing input") + + # Get or create a session ID for conversation continuity + session_id = request.sessionId or str(uuid.uuid4()) + + # Define the maximum number of tokens for the completion response + max_tokens = 250 + + # Retrieve existing conversation history or start fresh + messages = sessions.get(session_id, []) + + # Add system prompt if this is a new conversation + if len(messages) == 0: + messages.append({ + "role": "system", + "content": get_system_prompt(max_tokens) + }) + + # Add the user's question to the conversation + messages.append({"role": "user", "content": input_text}) + + try: + # Call OpenAI API to generate response + completion = client.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + temperature=0.3, + max_tokens=max_tokens, + ) + + # Extract the AI's response + response = completion.choices[0].message.content or "No response generated" + + # Save the AI's response to conversation history + messages.append({"role": "assistant", "content": response}) + sessions[session_id] = messages + + # Return response to the client + return AskResponse(output=response, sessionId=session_id) + + except Exception as error: + print(f"Agent /ask error: {error}") + raise HTTPException(status_code=500, detail="Internal server error") + +# Health check endpoint +@app.get("/health") +async def health_check(): + return {"status": "ok"} + +# Start the server +if __name__ == "__main__": + import uvicorn + print(f"Financial Agent listening on http://localhost:{PORT}") + uvicorn.run(app, host="0.0.0.0", port=PORT) \ No newline at end of file diff --git a/financial-agent-py/client/index_nevermined.py b/financial-agent-py/client/index_nevermined.py new file mode 100644 index 0000000..016f77e --- /dev/null +++ b/financial-agent-py/client/index_nevermined.py @@ -0,0 +1,191 @@ +""" +Minimal client that sends three questions to the agent, +storing and reusing the returned sessionId to maintain conversation context. +""" +import os +import sys +import asyncio +import httpx +from typing import Dict, Any, Optional +from dotenv import load_dotenv +from payments_py import Payments, PaymentOptions + +# Load environment variables +load_dotenv() + +# Configuration: Load environment variables with defaults +AGENT_URL = os.getenv("AGENT_URL", "http://localhost:8000") +PLAN_ID = os.getenv("NVM_PLAN_ID", "") +AGENT_ID = os.getenv("NVM_AGENT_ID", "") +SUBSCRIBER_API_KEY = os.getenv("SUBSCRIBER_NVM_API_KEY", "") +NVM_ENVIRONMENT = os.getenv("NVM_ENVIRONMENT", "sandbox") + +# Define demo conversation to show chatbot-style interaction +DEMO_CONVERSATION_QUESTIONS = [ + "Hi there! I'm new to investing and keep hearing about diversification. Can you explain what that means in simple terms?", + "That makes sense! So if I want to start investing but only have $100 a month, what should I focus on first?", + "I've been thinking about cryptocurrency. What should a beginner like me know before investing in crypto?", +] + +# Simple loading animation frames +LOADING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +class LoadingAnimation: + def __init__(self, message: str = "FinGuide is thinking..."): + self.message = message + self.running = False + self.frame_index = 0 + + async def start(self): + self.running = True + while self.running: + frame = LOADING_FRAMES[self.frame_index % len(LOADING_FRAMES)] + print(f"\r{frame} {self.message}", end="", flush=True) + self.frame_index += 1 + await asyncio.sleep(0.1) + + def stop(self): + self.running = False + print("\r", end="", flush=True) # Clear the line + +def validate_environment() -> None: + """Validate required environment variables""" + if not PLAN_ID or not AGENT_ID: + raise Exception("NVM_PLAN_ID and NVM_AGENT_ID are required in environment") + if not SUBSCRIBER_API_KEY: + raise Exception("SUBSCRIBER_NVM_API_KEY is required in environment") + +async def get_access_token() -> str: + """Get or purchase access token for protected agent""" + print("🔐 Setting up Nevermined access...") + + # Initialize Nevermined Payments SDK + payments = Payments.get_instance(PaymentOptions( + nvm_api_key=SUBSCRIBER_API_KEY, + environment=NVM_ENVIRONMENT, + )) + + # Check current plan balance and subscription status + # NOTE: get_plan_balance raises PaymentsError if the plan doesn't exist or if there's an API error + # The returned balance_info is a validated Pydantic object with proper types + balance_info = payments.plans.get_plan_balance(PLAN_ID) + + # Access balance and subscription status directly from the Pydantic object + has_credits = balance_info.balance > 0 + is_subscriber = balance_info.is_subscriber + + # Purchase plan if not subscribed and no credits + if not is_subscriber and not has_credits: + print("💳 No subscription or credits found. Purchasing plan...") + payments.plans.order_plan(PLAN_ID) + + # Get access token for the agent + credentials = payments.agents.get_agent_access_token(PLAN_ID, AGENT_ID) + + if not credentials or not credentials.get("accessToken"): + raise Exception("Failed to obtain access token") + + print("✅ Access token obtained successfully") + return credentials["accessToken"] + +async def ask_agent(input_text: str, access_token: str, session_id: Optional[str] = None) -> Dict[str, Any]: + """Send a question to the protected financial agent""" + + # Start loading animation + loading = LoadingAnimation() + loading_task = asyncio.create_task(loading.start()) + + try: + # Prepare request payload + request_body = { + "input_query": input_text, + "sessionId": session_id + } + + # Prepare headers with authorization + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}" + } + + async with httpx.AsyncClient() as client: + # Make HTTP request to protected agent + response = await client.post( + f"{AGENT_URL}/ask", + json=request_body, + headers=headers, + timeout=30.0 + ) + + # Handle HTTP errors (including payment required) + if response.status_code == 402: + raise Exception("Payment Required - insufficient credits or subscription") + elif response.status_code != 200: + error_text = response.text + raise Exception(f"Agent request failed: {response.status_code} {response.reason_phrase} {error_text}") + + # Parse and return JSON response + return response.json() + + finally: + # Stop loading animation + loading.stop() + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass + +async def run_demo() -> None: + """ + Run the protected demo client. + Sends predefined financial questions to the agent with Authorization and reuses sessionId to preserve context. + """ + print("🚀 Starting Financial Agent Demo (Protected with Nevermined)\n") + + # Validate environment setup + validate_environment() + + # Obtain access token for protected agent + access_token = await get_access_token() + + # Track session across multiple questions + session_id: Optional[str] = None + + # Send each demo question and maintain conversation context + for i, question in enumerate(DEMO_CONVERSATION_QUESTIONS): + print(f"📝 Question {i + 1}: {question}") + + try: + # Send question to protected agent (reusing sessionId for context) + result = await ask_agent(question, access_token, session_id) + + # Update sessionId for next question + session_id = result["sessionId"] + + # Display agent response and payment info + print(f"🤖 FinGuide (Session: {session_id}):") + print(result["output"]) + + if result.get("redemptionResult"): + credits_redeemed = result["redemptionResult"].get("creditsRedeemed", 0) + print(f"💰 Credits redeemed: {credits_redeemed}") + + print("\n" + "=" * 80 + "\n") + + except Exception as error: + print(f"❌ Error processing question {i + 1}: {error}") + break + + print("✅ Demo completed!") + +# Run the demo and handle any errors +async def main(): + try: + await run_demo() + except Exception as error: + print(f"💥 Demo failed: {error}") + sys.exit(1) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/financial-agent-py/client/index_unprotected.py b/financial-agent-py/client/index_unprotected.py new file mode 100644 index 0000000..877cae6 --- /dev/null +++ b/financial-agent-py/client/index_unprotected.py @@ -0,0 +1,128 @@ +""" +Minimal client (free) that sends three questions to the free agent +without Nevermined protection, storing and reusing sessionId. +""" +import os +import sys +import time +import asyncio +import httpx +from typing import Dict, Any, Optional +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Configuration: Agent URL from environment or default +AGENT_URL = os.getenv("AGENT_URL", "http://localhost:8001") + +# Define demo conversation to show chatbot-style interaction +DEMO_CONVERSATION_QUESTIONS = [ + "Hi there! I'm new to investing and keep hearing about diversification. Can you explain what that means in simple terms?", + "That makes sense! So if I want to start investing but only have $100 a month, what should I focus on first?", + "I've been thinking about cryptocurrency. What should a beginner like me know before investing in crypto?", +] + +# Simple loading animation frames +LOADING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +class LoadingAnimation: + def __init__(self, message: str = "FinGuide is thinking..."): + self.message = message + self.running = False + self.frame_index = 0 + + async def start(self): + self.running = True + while self.running: + frame = LOADING_FRAMES[self.frame_index % len(LOADING_FRAMES)] + print(f"\r{frame} {self.message}", end="", flush=True) + self.frame_index += 1 + await asyncio.sleep(0.1) + + def stop(self): + self.running = False + print("\r", end="", flush=True) # Clear the line + +async def ask_agent(input_text: str, session_id: Optional[str] = None) -> Dict[str, Any]: + """Send a question to the financial agent""" + + # Start loading animation + loading = LoadingAnimation() + loading_task = asyncio.create_task(loading.start()) + + try: + # Prepare request payload + request_body = { + "input_query": input_text, + "sessionId": session_id + } + + async with httpx.AsyncClient() as client: + # Make HTTP request to agent + response = await client.post( + f"{AGENT_URL}/ask", + json=request_body, + headers={"Content-Type": "application/json"}, + timeout=30.0 + ) + + # Handle HTTP errors + if response.status_code != 200: + error_text = response.text + raise Exception(f"Agent request failed: {response.status_code} {response.reason_phrase} {error_text}") + + # Parse and return JSON response + return response.json() + + finally: + # Stop loading animation + loading.stop() + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass + +async def run_demo() -> None: + """ + Run the unprotected demo client. + Sends predefined financial questions to the agent and reuses sessionId to preserve context. + """ + print("🚀 Starting Financial Agent Demo (Unprotected)\n") + + # Track session across multiple questions + session_id: Optional[str] = None + + # Send each demo question and maintain conversation context + for i, question in enumerate(DEMO_CONVERSATION_QUESTIONS): + print(f"📝 Question {i + 1}: {question}") + + try: + # Send question to agent (reusing sessionId for context) + result = await ask_agent(question, session_id) + + # Update sessionId for next question + session_id = result["sessionId"] + + # Display agent response + print(f"🤖 FinGuide (Session: {session_id}):") + print(result["output"]) + print("\n" + "=" * 80 + "\n") + + except Exception as error: + print(f"❌ Error processing question {i + 1}: {error}") + break + + print("✅ Demo completed!") + +# Run the demo and handle any errors +async def main(): + try: + await run_demo() + except Exception as error: + print(f"💥 Demo failed: {error}") + sys.exit(1) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/financial-agent-py/requirements.txt b/financial-agent-py/requirements.txt new file mode 100644 index 0000000..6de4275 --- /dev/null +++ b/financial-agent-py/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115.2 +uvicorn>=0.30.0 +openai>=1.3.7 +python-dotenv>=1.0.0 +pydantic>=2.8.2 +httpx>=0.28.1 +payments-py>=0.8.0 \ No newline at end of file diff --git a/financial-agent/agent/index_nevermined.ts b/financial-agent/agent/index_nevermined.ts index 30538a7..d462087 100644 --- a/financial-agent/agent/index_nevermined.ts +++ b/financial-agent/agent/index_nevermined.ts @@ -91,7 +91,7 @@ Make these important points feel like natural parts of the conversation rather t } // Calculate dynamic credit amount based on token usage -function calculateCreditAmount(tokensUsed: number, maxTokens: number): number { +function calculateCreditAmount(tokensUsed: number, maxTokens: number): bigint { // Formula: 10 * (actual_tokens / max_tokens) // This rewards shorter responses with lower costs const tokenUtilization = Math.min(tokensUsed / maxTokens, 1); // Cap at 1 @@ -104,7 +104,7 @@ function calculateCreditAmount(tokensUsed: number, maxTokens: number): number { ).toFixed(1)}%) - Credits: ${creditAmount}` ); - return creditAmount; + return BigInt(creditAmount); } // Store conversation history for each session @@ -206,9 +206,9 @@ app.post("/ask", async (req: Request, res: Response) => { redemptionResult = await payments.requests.redeemCreditsFromRequest( agentRequest.agentRequestId, requestAccessToken, - BigInt(creditAmount) + creditAmount ); - redemptionResult.creditsRedeemed = creditAmount; + redemptionResult.creditsRedeemed = redemptionResult.amountOfCredits; } catch (redeemErr) { console.error("Failed to redeem credits:", redeemErr); redemptionResult = { diff --git a/financial-agent/client/index_nevermined.ts b/financial-agent/client/index_nevermined.ts index 67016c6..7166b04 100644 --- a/financial-agent/client/index_nevermined.ts +++ b/financial-agent/client/index_nevermined.ts @@ -10,7 +10,8 @@ const AGENT_URL = process.env.AGENT_URL || "http://localhost:3000"; const PLAN_ID = process.env.NVM_PLAN_ID as string; const AGENT_ID = process.env.NVM_AGENT_ID as string; const SUBSCRIBER_API_KEY = process.env.SUBSCRIBER_NVM_API_KEY as string; -const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || "staging_sandbox") as EnvironmentName; +const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || + "staging_sandbox") as EnvironmentName; // Define demo conversation to show chatbot-style interaction const DEMO_CONVERSATION_QUESTIONS = [ @@ -19,6 +20,12 @@ const DEMO_CONVERSATION_QUESTIONS = [ "I've been thinking about cryptocurrency. What should a beginner like me know before investing in crypto?", ]; +// Initialize Nevermined Payments SDK +const payments = Payments.getInstance({ + nvmApiKey: SUBSCRIBER_API_KEY, + environment: NVM_ENVIRONMENT, +}); + // Validate required environment variables function validateEnvironment(): void { if (!PLAN_ID || !AGENT_ID) { @@ -33,12 +40,6 @@ function validateEnvironment(): void { async function getorPurchaseAccessToken(): Promise { console.log("🔐 Setting up Nevermined access..."); - // Initialize Nevermined Payments SDK - const payments = Payments.getInstance({ - nvmApiKey: SUBSCRIBER_API_KEY, - environment: NVM_ENVIRONMENT, - }); - // Check current plan balance and subscription status const balanceInfo: any = await payments.plans.getPlanBalance(PLAN_ID); const hasCredits = Number(balanceInfo?.balance ?? 0) > 0; @@ -51,7 +52,10 @@ async function getorPurchaseAccessToken(): Promise { } // Get access token for the agent - const credentials = await payments.agents.getAgentAccessToken(PLAN_ID, AGENT_ID); + const credentials = await payments.agents.getAgentAccessToken( + PLAN_ID, + AGENT_ID + ); if (!credentials?.accessToken) { throw new Error("Failed to obtain access token"); @@ -63,7 +67,7 @@ async function getorPurchaseAccessToken(): Promise { // Simple loading animation for terminal function startLoadingAnimation(): () => void { - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let i = 0; const interval = setInterval(() => { process.stdout.write(`\r${frames[i]} FinGuide is thinking...`); @@ -72,12 +76,16 @@ function startLoadingAnimation(): () => void { return () => { clearInterval(interval); - process.stdout.write('\r'); + process.stdout.write("\r"); }; } // Send a question to the protected financial agent -async function askAgent(input: string, accessToken: string, sessionId?: string): Promise<{ output: string; sessionId: string; redemptionResult?: any }> { +async function askAgent( + input: string, + accessToken: string, + sessionId?: string +): Promise<{ output: string; sessionId: string; redemptionResult?: any }> { // Start loading animation const stopLoading = startLoadingAnimation(); @@ -85,13 +93,13 @@ async function askAgent(input: string, accessToken: string, sessionId?: string): // Prepare request payload const requestBody = { input_query: input, - sessionId: sessionId + sessionId: sessionId, }; // Prepare headers with authorization const headers = { "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, }; // Make HTTP request to protected agent @@ -105,13 +113,21 @@ async function askAgent(input: string, accessToken: string, sessionId?: string): if (!response.ok) { const errorText = await response.text().catch(() => ""); if (response.status === 402) { - throw new Error("Payment Required - insufficient credits or subscription"); + throw new Error( + "Payment Required - insufficient credits or subscription" + ); } - throw new Error(`Agent request failed: ${response.status} ${response.statusText} ${errorText}`); + throw new Error( + `Agent request failed: ${response.status} ${response.statusText} ${errorText}` + ); } // Parse and return JSON response - return await response.json() as { output: string; sessionId: string; redemptionResult?: any }; + return (await response.json()) as { + output: string; + sessionId: string; + redemptionResult?: any; + }; } finally { // Stop loading animation stopLoading(); @@ -153,11 +169,15 @@ async function runDemo(): Promise { console.log(result.output); if (result.redemptionResult) { - console.log(`💰 Credits redeemed: ${result.redemptionResult.creditsRedeemed || 0}`); + console.log( + `💰 Credits redeemed: ${result.redemptionResult.creditsRedeemed || 0}` + ); } - console.log("\n" + "=".repeat(80) + "\n"); + const balance = await payments.plans.getPlanBalance(PLAN_ID); + console.log("Balance:", balance.balance); + console.log("\n" + "=".repeat(80) + "\n"); } catch (error) { console.error(`❌ Error processing question ${i + 1}:`, error); break;