From 61118b45c8afb0d6789fc34c515db9b4fb21ee60 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 14:31:34 +0500 Subject: [PATCH 01/10] Fix: update validation output handling for encoding issues --- community/upwork-job-search/README.md | 87 ++++++++ community/upwork-job-search/__init__.py | 1 + community/upwork-job-search/main.py | 264 ++++++++++++++++++++++++ validate_ability.py | 3 +- 4 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 community/upwork-job-search/README.md create mode 100644 community/upwork-job-search/__init__.py create mode 100644 community/upwork-job-search/main.py diff --git a/community/upwork-job-search/README.md b/community/upwork-job-search/README.md new file mode 100644 index 00000000..9bcf1d22 --- /dev/null +++ b/community/upwork-job-search/README.md @@ -0,0 +1,87 @@ +# Upwork Job Search + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@khushi0433-lightgrey?style=flat-square) + +## What It Does +Search for freelance jobs on Upwork using voice. Simply say what type of job you're looking for (like "web development" or "Python programming"), and this Ability will find relevant Upwork jobs and read them out to you. + +## Suggested Trigger Words +- "find jobs" +- "search upwork" +- "find freelance work" +- "upwork jobs" +- "look for work" + +## Setup + +### Prerequisites +- An Upwork API application registered at [developers.upwork.com](https://developers.upwork.com) + +### API Credentials +1. Go to [developers.upwork.com](https://developers.upwork.com) and create a new app +2. Get your **Client Key** and **Client Secret** +3. Edit `main.py` and replace: + +``` +python + UPWORK_CLIENT_KEY = "YOUR_UPWORK_CLIENT_KEY" + UPWORK_CLIENT_SECRET = "YOUR_UPWORK_CLIENT_SECRET" + +``` + +### How to Get Upwork API Access +1. Register at [developers.upwork.com](https://developers.upwork.com) +2. Create a new application with: + - App Name: `OpenHome Upwork Jobs` + - Description: Voice AI ability to search Upwork jobs + - Callback URL: `https://localhost/callback` + - App URL: `https://app.openhome.com` +3. Once approved, you'll receive Client Key and Client Secret +4. For production use, you'll need to implement proper OAuth 2.0 flow + +## How It Works + +``` +User activates ability with trigger word + → Ability asks "What type of jobs are you looking for?" + → User responds with a category (e.g., "web development") + → Ability searches Upwork API + → Ability reads out top job results with: + - Job title + - Budget + - Duration + - Workload + - Client rating + → User can search again or exit +``` + +## Example Conversation + +> **User:** "find jobs" +> **AI:** "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? For example, web development, mobile app, data entry, or copy writing." +> **User:** "web development" +> **AI:** "Searching for web development jobs on Upwork... I found 5 jobs matching 'web development'. Here are the top results:" +> **AI:** "Job 1: Build a WordPress Website. Budget: 500 USD. Duration: 1-3 months. Workload: Not specified. Client rating: 4.5 out of 5." +> **AI:** "Job 2: React Frontend Developer Needed. Budget: 1000 USD. Duration: 1-4 weeks. Workload: More than 30 hrs/week. Client rating: 5 out of 5." +> **AI:** "Would you like me to search for a different category? Say stop to exit." +> **User:** "no thanks" +> **AI:** "Happy job hunting! Talk to you later." + +## Important Notes + +- This Ability uses the Upwork GraphQL API (`api.upwork.com/graphql/v2`) +- For full functionality, you need Upwork API credentials with appropriate permissions +- Jobs are limited to top 5 results to keep voice responses concise +- The ability includes error handling for API failures and provides user-friendly messages + +## Technical Details + +- **API Used**: Upwork GraphQL API v2 +- **Authentication**: OAuth 2.0 (client credentials flow) +- **Dependencies**: `requests` library +- **Pattern**: API Template (Speak → Input → API Call → Speak Result → Exit) + +## License + +MIT License - See LICENSE file for details. diff --git a/community/upwork-job-search/__init__.py b/community/upwork-job-search/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/upwork-job-search/__init__.py @@ -0,0 +1 @@ + diff --git a/community/upwork-job-search/main.py b/community/upwork-job-search/main.py new file mode 100644 index 00000000..ae7247e4 --- /dev/null +++ b/community/upwork-job-search/main.py @@ -0,0 +1,264 @@ +import json +import os +import requests +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +# ============================================================================= +# UPWORK JOB SEARCH ABILITY +# Search for freelance jobs on Upwork using the Upwork GraphQL API +# Pattern: Speak → Ask for search query → Call API → Speak results → Exit +# +# Requires OAuth 2.0 authentication with Upwork API +# ============================================================================= + +# --- CONFIGURATION --- +# Get these from https://developers.upwork.com/ +# Replace with your Upwork API credentials +UPWORK_CLIENT_KEY = "YOUR_UPWORK_CLIENT_KEY" +UPWORK_CLIENT_SECRET = "YOUR_UPWORK_CLIENT_SECRET" + +# Upwork API endpoints +UPWORK_AUTH_URL = "https://www.upwork.com/api/v3/oauth2/token" +UPWORK_GRAPHQL_URL = "https://api.upwork.com/graphql/v2" + + +class UpworkJobSearchCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + access_token: str = None + + @classmethod + def register_capability(cls) -> "MatchingCapability": + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def get_access_token(self) -> bool: + """ + Get OAuth access token. + In production, this would use proper OAuth flow. + For this ability, users need to provide their own credentials. + """ + try: + # Create Basic auth header from client key and secret + auth = (UPWORK_CLIENT_KEY, UPWORK_CLIENT_SECRET) + + # Request new token (in production, you'd cache this) + response = requests.post( + UPWORK_AUTH_URL, + data={"grant_type": "client_credentials"}, + auth=auth, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + self.access_token = data.get("access_token") + return True + else: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Auth failed: {response.status_code} - {response.text}" + ) + return False + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Auth error: {e}" + ) + return False + + async def search_jobs(self, query: str, category: str = None) -> list | None: + """ + Search for jobs using Upwork GraphQL API. + Returns list of jobs or None on failure. + """ + if not self.access_token: + success = await self.get_access_token() + if not success: + return None + + # GraphQL query for job search + graphql_query = { + "query": """ + query JobSearch($query: String!, $first: Int!) { + jobSearch(query: $query, first: $first) { + edges { + node { + title + description + skills + budget { + amount + currency + } + duration + workload + client { + feedback + reviewsCount + location { + country + } + } + postedAt + } + } + } + } + """, + "variables": { + "query": query, + "first": 5 # Return top 5 results for voice response + } + } + + try: + response = requests.post( + UPWORK_GRAPHQL_URL, + headers={ + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json" + }, + json=graphql_query, + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + + # Parse the GraphQL response + jobs = [] + edges = data.get("data", {}).get("jobSearch", {}).get("edges", []) + + for edge in edges: + job = edge.get("node", {}) + jobs.append({ + "title": job.get("title", "Untitled"), + "description": job.get("description", "")[:200] + "..." if job.get("description") else "", + "skills": job.get("skills", []), + "budget": job.get("budget", {}), + "duration": job.get("duration", "Not specified"), + "workload": job.get("workload", "Not specified"), + "client_rating": job.get("client", {}).get("feedback", "N/A"), + "client_reviews": job.get("client", {}).get("reviewsCount", 0), + "client_country": job.get("client", {}).get("location", {}).get("country", "Unknown"), + "posted_at": job.get("postedAt", "") + }) + + return jobs + + elif response.status_code == 401: + # Token expired, try to refresh + self.access_token = None + return await self.search_jobs(query, category) + else: + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] API error: {response.status_code} - {response.text}" + ) + return None + + except Exception as e: + self.worker.editor_logging_handler.error(f"[UpworkJobSearch] Search error: {e}") + return None + + def format_job_for_speech(self, job: dict, index: int) -> str: + """Format a job for voice response.""" + title = job.get("title", "Untitled") + budget = job.get("budget", {}) + budget_str = f"{budget.get('amount', 'N/A')} {budget.get('currency', 'USD')}" if budget else "Budget not specified" + duration = job.get("duration", "Not specified") + workload = job.get("workload", "Not specified") + rating = job.get("client_rating", "N/A") + + return f"Job {index + 1}: {title}. Budget: {budget_str}. Duration: {duration}. Workload: {workload}. Client rating: {rating} out of 5." + + async def run(self): + """Main conversation flow.""" + try: + # Step 1: Greet and explain + await self.capability_worker.speak( + "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? " + "For example, web development, mobile app, data entry, or copy writing." + ) + + # Step 2: Get search query from user + user_input = await self.capability_worker.user_response() + + if not user_input or not user_input.strip(): + await self.capability_worker.speak( + "I didn't catch that. Please try again with a job category or skill." + ) + self.capability_worker.resume_normal_flow() + return + + # Step 3: Search for jobs + await self.capability_worker.speak( + f"Searching for {user_input} jobs on Upwork..." + ) + + jobs = await self.search_jobs(user_input) + + # Step 4: Speak results + if jobs and len(jobs) > 0: + await self.capability_worker.speak( + f"I found {len(jobs)} jobs matching '{user_input}'. Here are the top results:" + ) + + # Speak each job (limit to 3 for voice) + for i, job in enumerate(jobs[:3]): + job_summary = self.format_job_for_speech(job, i) + await self.capability_worker.speak(job_summary) + + # Add closing message + await self.capability_worker.speak( + "Would you like me to search for a different category? Say stop to exit." + ) + + # Listen for follow-up + follow_up = await self.capability_worker.user_response() + + if follow_up and any(word in follow_up.lower() for word in ["yes", "sure", "another", "more", "search"]): + await self.capability_worker.speak("What would you like to search for?") + new_query = await self.capability_worker.user_response() + if new_query: + jobs = await self.search_jobs(new_query) + if jobs and len(jobs) > 0: + await self.capability_worker.speak( + f"I found {len(jobs)} jobs. Here are the results:" + ) + for i, job in enumerate(jobs[:3]): + job_summary = self.format_job_for_speech(job, i) + await self.capability_worker.speak(job_summary) + else: + await self.capability_worker.speak( + "I couldn't find any jobs matching that search." + ) + else: + await self.capability_worker.speak( + "Happy job hunting! Talk to you later." + ) + else: + await self.capability_worker.speak( + f"I couldn't find any jobs matching '{user_input}'. " + "Try a different category or skill. For example, Python programming, logo design, or content writing." + ) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[UpworkJobSearch] Error: {e}") + await self.capability_worker.speak( + "Something went wrong while searching for jobs. Please try again later." + ) + finally: + self.capability_worker.resume_normal_flow() diff --git a/validate_ability.py b/validate_ability.py index 7bc2e4aa..1c1b9cec 100644 --- a/validate_ability.py +++ b/validate_ability.py @@ -217,7 +217,8 @@ def main(): full_output = "\n".join(output_lines) - with open(output_file, "w") as f: + # using UTF-8 encoding to ensure emojis are written correctly + with open(output_file, "w", encoding="utf-8") as f: f.write(full_output) if summary_file: From f87bd98c7e40f959b9d5fe3a7d17b2438a565e35 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 14:32:55 +0500 Subject: [PATCH 02/10] Ignore validator output file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b09c1b2e..73b99119 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ htmlcov/ # Bandit reports bandit-report.json +validation_output.txt From 61cebc17b31c879b770f25949d54540181988ada Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 14:36:19 +0500 Subject: [PATCH 03/10] feat: update readme --- community/upwork-job-search/README.md | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/community/upwork-job-search/README.md b/community/upwork-job-search/README.md index 9bcf1d22..84bde91a 100644 --- a/community/upwork-job-search/README.md +++ b/community/upwork-job-search/README.md @@ -4,7 +4,7 @@ ![Author](https://img.shields.io/badge/Author-@khushi0433-lightgrey?style=flat-square) ## What It Does -Search for freelance jobs on Upwork using voice. Simply say what type of job you're looking for (like "web development" or "Python programming"), and this Ability will find relevant Upwork jobs and read them out to you. +Search for freelance jobs on Upwork using voice. Simply say what type of job you're looking for (like "web development" or "Python programming"), and this Ability will find the top Upwork jobs and read them out to you. ## Suggested Trigger Words - "find jobs" @@ -44,10 +44,10 @@ python ``` User activates ability with trigger word - → Ability asks "What type of jobs are you looking for?" - → User responds with a category (e.g., "web development") - → Ability searches Upwork API - → Ability reads out top job results with: + Ability asks "What type of jobs are you looking for?" + User responds with a category (e.g., "web development") + Ability searches Upwork API + Ability reads out top job results with: - Job title - Budget - Duration @@ -58,15 +58,15 @@ User activates ability with trigger word ## Example Conversation -> **User:** "find jobs" -> **AI:** "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? For example, web development, mobile app, data entry, or copy writing." -> **User:** "web development" -> **AI:** "Searching for web development jobs on Upwork... I found 5 jobs matching 'web development'. Here are the top results:" -> **AI:** "Job 1: Build a WordPress Website. Budget: 500 USD. Duration: 1-3 months. Workload: Not specified. Client rating: 4.5 out of 5." -> **AI:** "Job 2: React Frontend Developer Needed. Budget: 1000 USD. Duration: 1-4 weeks. Workload: More than 30 hrs/week. Client rating: 5 out of 5." -> **AI:** "Would you like me to search for a different category? Say stop to exit." -> **User:** "no thanks" -> **AI:** "Happy job hunting! Talk to you later." +> User: "find jobs" +> AI: "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? For example, web development, mobile app, data entry, or copy writing." +> User: "web development" +> AI: "Searching for web development jobs on Upwork... I found 5 jobs matching 'web development'. Here are the top results:" +> AI: "Job 1: Build a WordPress Website. Budget: 500 USD. Duration: 1-3 months. Workload: Not specified. Client rating: 4.5 out of 5." +> AI: "Job 2: React Frontend Developer Needed. Budget: 1000 USD. Duration: 1-4 weeks. Workload: More than 30 hrs/week. Client rating: 5 out of 5." +> AI: "Would you like me to search for a different category? Say stop to exit." +> User: "no thanks" +> AI: "Happy job hunting! Talk to you later." ## Important Notes @@ -77,10 +77,10 @@ User activates ability with trigger word ## Technical Details -- **API Used**: Upwork GraphQL API v2 -- **Authentication**: OAuth 2.0 (client credentials flow) -- **Dependencies**: `requests` library -- **Pattern**: API Template (Speak → Input → API Call → Speak Result → Exit) +- API Used: Upwork GraphQL API v2 +- Authentication: OAuth 2.0 (client credentials flow) +- Dependencies: `requests` library +- Pattern: API Template (Speak → Input → API Call → Speak Result → Exit) ## License From c8ffecdb9aaa3b355ddf75d16085d97bad632a8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 09:39:42 +0000 Subject: [PATCH 04/10] style: auto-format Python files with autoflake + autopep8 --- community/upwork-job-search/main.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/community/upwork-job-search/main.py b/community/upwork-job-search/main.py index ae7247e4..62450571 100644 --- a/community/upwork-job-search/main.py +++ b/community/upwork-job-search/main.py @@ -54,7 +54,7 @@ async def get_access_token(self) -> bool: try: # Create Basic auth header from client key and secret auth = (UPWORK_CLIENT_KEY, UPWORK_CLIENT_SECRET) - + # Request new token (in production, you'd cache this) response = requests.post( UPWORK_AUTH_URL, @@ -62,7 +62,7 @@ async def get_access_token(self) -> bool: auth=auth, timeout=30 ) - + if response.status_code == 200: data = response.json() self.access_token = data.get("access_token") @@ -72,7 +72,7 @@ async def get_access_token(self) -> bool: f"[UpworkJobSearch] Auth failed: {response.status_code} - {response.text}" ) return False - + except Exception as e: self.worker.editor_logging_handler.error( f"[UpworkJobSearch] Auth error: {e}" @@ -137,11 +137,11 @@ async def search_jobs(self, query: str, category: str = None) -> list | None: if response.status_code == 200: data = response.json() - + # Parse the GraphQL response jobs = [] edges = data.get("data", {}).get("jobSearch", {}).get("edges", []) - + for edge in edges: job = edge.get("node", {}) jobs.append({ @@ -156,9 +156,9 @@ async def search_jobs(self, query: str, category: str = None) -> list | None: "client_country": job.get("client", {}).get("location", {}).get("country", "Unknown"), "posted_at": job.get("postedAt", "") }) - + return jobs - + elif response.status_code == 401: # Token expired, try to refresh self.access_token = None @@ -181,7 +181,7 @@ def format_job_for_speech(self, job: dict, index: int) -> str: duration = job.get("duration", "Not specified") workload = job.get("workload", "Not specified") rating = job.get("client_rating", "N/A") - + return f"Job {index + 1}: {title}. Budget: {budget_str}. Duration: {duration}. Workload: {workload}. Client rating: {rating} out of 5." async def run(self): @@ -195,7 +195,7 @@ async def run(self): # Step 2: Get search query from user user_input = await self.capability_worker.user_response() - + if not user_input or not user_input.strip(): await self.capability_worker.speak( "I didn't catch that. Please try again with a job category or skill." @@ -207,7 +207,7 @@ async def run(self): await self.capability_worker.speak( f"Searching for {user_input} jobs on Upwork..." ) - + jobs = await self.search_jobs(user_input) # Step 4: Speak results @@ -215,20 +215,20 @@ async def run(self): await self.capability_worker.speak( f"I found {len(jobs)} jobs matching '{user_input}'. Here are the top results:" ) - + # Speak each job (limit to 3 for voice) for i, job in enumerate(jobs[:3]): job_summary = self.format_job_for_speech(job, i) await self.capability_worker.speak(job_summary) - + # Add closing message await self.capability_worker.speak( "Would you like me to search for a different category? Say stop to exit." ) - + # Listen for follow-up follow_up = await self.capability_worker.user_response() - + if follow_up and any(word in follow_up.lower() for word in ["yes", "sure", "another", "more", "search"]): await self.capability_worker.speak("What would you like to search for?") new_query = await self.capability_worker.user_response() From 14f4ee799d3acd0978168148ffc2b074e43d7e84 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 14:55:14 +0500 Subject: [PATCH 05/10] Add badges to README --- community/upwork-job-search/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/community/upwork-job-search/README.md b/community/upwork-job-search/README.md index 84bde91a..85df7728 100644 --- a/community/upwork-job-search/README.md +++ b/community/upwork-job-search/README.md @@ -1,5 +1,6 @@ # Upwork Job Search +![Static Badge](https://img.shields.io/badge/:badgeContent) ![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) ![Author](https://img.shields.io/badge/Author-@khushi0433-lightgrey?style=flat-square) From db66f9ddd7442e9097129c9979f3adcbf4dbd167 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 15:09:52 +0500 Subject: [PATCH 06/10] feat: add image badge --- community/upwork-job-search/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/upwork-job-search/README.md b/community/upwork-job-search/README.md index 85df7728..b95423bf 100644 --- a/community/upwork-job-search/README.md +++ b/community/upwork-job-search/README.md @@ -1,6 +1,6 @@ # Upwork Job Search -![Static Badge](https://img.shields.io/badge/:badgeContent) +![Abilities Badge](https://img.shields.io/badge/Abilities-Open-green.svg)io%2Fbadge%2FAbilities-Open-green) ![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) ![Author](https://img.shields.io/badge/Author-@khushi0433-lightgrey?style=flat-square) From 63c82d285cb6fafb8e6772de79fa7c2afd70ebd8 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Sat, 21 Feb 2026 16:36:02 +0500 Subject: [PATCH 07/10] fix: replace raw open() with read_file and add register capability tag --- community/upwork-job-search/main.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/community/upwork-job-search/main.py b/community/upwork-job-search/main.py index 62450571..d1e38829 100644 --- a/community/upwork-job-search/main.py +++ b/community/upwork-job-search/main.py @@ -25,18 +25,21 @@ class UpworkJobSearchCapability(MatchingCapability): + #{{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None access_token: str = None @classmethod def register_capability(cls) -> "MatchingCapability": - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - ) as file: - data = json.load(file) + from src.agent.capability_worker import CapabilityWorker + capability_worker = CapabilityWorker(None) # pass None if worker not available yet + config_str = capability_worker.read_file("config.json") + data = json.loads(config_str) + + #{{register capability}} return cls( - unique_name=data["unique_name"], + unique_name=data['unique_name'], matching_hotwords=data["matching_hotwords"], ) From bfac268fab12a673198a67179d9fd7e4c07d4ea2 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Mon, 23 Feb 2026 14:01:59 +0500 Subject: [PATCH 08/10] fix(upwork-job-search): replace OAuth/GraphQL with Remotive API, fix indentation --- community/upwork-job-search/main.py | 316 +++++++++++----------------- 1 file changed, 120 insertions(+), 196 deletions(-) diff --git a/community/upwork-job-search/main.py b/community/upwork-job-search/main.py index d1e38829..dcfe1f31 100644 --- a/community/upwork-job-search/main.py +++ b/community/upwork-job-search/main.py @@ -1,45 +1,34 @@ import json import os -import requests +import re +from typing import Optional, List +from urllib.parse import urlencode + +import httpx + from src.agent.capability import MatchingCapability from src.main import AgentWorker from src.agent.capability_worker import CapabilityWorker -# ============================================================================= -# UPWORK JOB SEARCH ABILITY -# Search for freelance jobs on Upwork using the Upwork GraphQL API -# Pattern: Speak → Ask for search query → Call API → Speak results → Exit -# -# Requires OAuth 2.0 authentication with Upwork API -# ============================================================================= - -# --- CONFIGURATION --- -# Get these from https://developers.upwork.com/ -# Replace with your Upwork API credentials -UPWORK_CLIENT_KEY = "YOUR_UPWORK_CLIENT_KEY" -UPWORK_CLIENT_SECRET = "YOUR_UPWORK_CLIENT_SECRET" - -# Upwork API endpoints -UPWORK_AUTH_URL = "https://www.upwork.com/api/v3/oauth2/token" -UPWORK_GRAPHQL_URL = "https://api.upwork.com/graphql/v2" +# Remotive public API — free, no authentication required +REMOTIVE_API_URL = "https://remotive.com/api/remote-jobs" class UpworkJobSearchCapability(MatchingCapability): - #{{register capability}} worker: AgentWorker = None capability_worker: CapabilityWorker = None - access_token: str = None + + # ---------------- REGISTER ---------------- @classmethod def register_capability(cls) -> "MatchingCapability": - from src.agent.capability_worker import CapabilityWorker - capability_worker = CapabilityWorker(None) # pass None if worker not available yet - config_str = capability_worker.read_file("config.json") - data = json.loads(config_str) + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) - #{{register capability}} return cls( - unique_name=data['unique_name'], + unique_name=data["unique_name"], matching_hotwords=data["matching_hotwords"], ) @@ -48,220 +37,155 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) - async def get_access_token(self) -> bool: - """ - Get OAuth access token. - In production, this would use proper OAuth flow. - For this ability, users need to provide their own credentials. - """ - try: - # Create Basic auth header from client key and secret - auth = (UPWORK_CLIENT_KEY, UPWORK_CLIENT_SECRET) - - # Request new token (in production, you'd cache this) - response = requests.post( - UPWORK_AUTH_URL, - data={"grant_type": "client_credentials"}, - auth=auth, - timeout=30 - ) - - if response.status_code == 200: - data = response.json() - self.access_token = data.get("access_token") - return True - else: - self.worker.editor_logging_handler.error( - f"[UpworkJobSearch] Auth failed: {response.status_code} - {response.text}" - ) - return False - - except Exception as e: + # ---------------- HELPERS ---------------- + + def _clean_html(self, text: str) -> str: + """Strip HTML tags and decode common HTML entities.""" + # Replace block-level tags with a space so adjacent words don't merge + text = re.sub(r"<(br|p|div|li|tr|td|th|h[1-6])[^>]*>", " ", text, flags=re.IGNORECASE) + # Strip remaining tags + text = re.sub(r"<[^>]+>", "", text) + # Decode common HTML entities + text = text.replace(" ", " ") + text = text.replace("&", "&") + text = text.replace("<", "<") + text = text.replace(">", ">") + text = text.replace(""", '"') + text = text.replace("'", "'") + # Collapse whitespace + text = re.sub(r"\s+", " ", text) + return text.strip() + + # ---------------- SEARCH ---------------- + + async def _fetch_jobs_for_query(self, client: httpx.AsyncClient, query: str) -> list: + """Single HTTP call to Remotive; returns raw job list (may be empty).""" + url = f"{REMOTIVE_API_URL}?{urlencode({'search': query, 'limit': 5})}" + response = await client.get( + url, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json", + }, + ) + if response.status_code != 200: self.worker.editor_logging_handler.error( - f"[UpworkJobSearch] Auth error: {e}" + f"[UpworkJobSearch] HTTP {response.status_code}: {response.text[:200]}" ) - return False + return [] + return response.json().get("jobs", []) - async def search_jobs(self, query: str, category: str = None) -> list | None: - """ - Search for jobs using Upwork GraphQL API. - Returns list of jobs or None on failure. + async def search_jobs(self, query: str) -> Optional[List[dict]]: + """Fetch the top 5 remote jobs matching the query from the Remotive API. + + Tries the full phrase first; if no results are returned, retries with + just the most significant keyword (first word) as a fallback. """ - if not self.access_token: - success = await self.get_access_token() - if not success: - return None + try: + async with httpx.AsyncClient(timeout=30.0) as client: + raw_jobs = await self._fetch_jobs_for_query(client, query) - # GraphQL query for job search - graphql_query = { - "query": """ - query JobSearch($query: String!, $first: Int!) { - jobSearch(query: $query, first: $first) { - edges { - node { - title - description - skills - budget { - amount - currency - } - duration - workload - client { - feedback - reviewsCount - location { - country - } - } - postedAt - } - } - } - } - """, - "variables": { - "query": query, - "first": 5 # Return top 5 results for voice response - } - } + # Fallback: retry with only the first keyword when the full phrase + # yields nothing (Remotive does phrase-level matching). + if not raw_jobs: + first_keyword = query.strip().split()[0] if query.strip() else "" + if first_keyword and first_keyword.lower() != query.strip().lower(): + raw_jobs = await self._fetch_jobs_for_query(client, first_keyword) - try: - response = requests.post( - UPWORK_GRAPHQL_URL, - headers={ - "Authorization": f"Bearer {self.access_token}", - "Content-Type": "application/json" - }, - json=graphql_query, - timeout=30 - ) + if not raw_jobs: + return None - if response.status_code == 200: - data = response.json() - - # Parse the GraphQL response - jobs = [] - edges = data.get("data", {}).get("jobSearch", {}).get("edges", []) - - for edge in edges: - job = edge.get("node", {}) - jobs.append({ - "title": job.get("title", "Untitled"), - "description": job.get("description", "")[:200] + "..." if job.get("description") else "", - "skills": job.get("skills", []), - "budget": job.get("budget", {}), - "duration": job.get("duration", "Not specified"), - "workload": job.get("workload", "Not specified"), - "client_rating": job.get("client", {}).get("feedback", "N/A"), - "client_reviews": job.get("client", {}).get("reviewsCount", 0), - "client_country": job.get("client", {}).get("location", {}).get("country", "Unknown"), - "posted_at": job.get("postedAt", "") - }) - - return jobs - - elif response.status_code == 401: - # Token expired, try to refresh - self.access_token = None - return await self.search_jobs(query, category) - else: - self.worker.editor_logging_handler.error( - f"[UpworkJobSearch] API error: {response.status_code} - {response.text}" + jobs = [] + for item in raw_jobs[:5]: + description = self._clean_html(item.get("description", "")) + if len(description) > 300: + description = description[:300] + "..." + + jobs.append( + { + "title": item.get("title", "Untitled"), + "company": item.get("company_name", "Unknown company"), + "description": description, + "link": item.get("url", ""), + "pub_date": item.get("publication_date", ""), + "salary": item.get("salary", "Not specified"), + "location": item.get("candidate_required_location", "Remote"), + "job_type": item.get("job_type", ""), + } ) - return None + + return jobs except Exception as e: - self.worker.editor_logging_handler.error(f"[UpworkJobSearch] Search error: {e}") + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Network error: {e}" + ) return None + # ---------------- FORMAT ---------------- + def format_job_for_speech(self, job: dict, index: int) -> str: - """Format a job for voice response.""" title = job.get("title", "Untitled") - budget = job.get("budget", {}) - budget_str = f"{budget.get('amount', 'N/A')} {budget.get('currency', 'USD')}" if budget else "Budget not specified" - duration = job.get("duration", "Not specified") - workload = job.get("workload", "Not specified") - rating = job.get("client_rating", "N/A") + company = job.get("company", "Unknown company") + location = job.get("location", "Remote") + salary = job.get("salary", "Not specified") + description = job.get("description", "No description available.") + short_desc = description[:150].rstrip() + + salary_part = f" Salary: {salary}." if salary and salary != "Not specified" else "" + return ( + f"Job {index + 1}: {title} at {company}. " + f"Location: {location}.{salary_part} " + f"{short_desc}." + ) - return f"Job {index + 1}: {title}. Budget: {budget_str}. Duration: {duration}. Workload: {workload}. Client rating: {rating} out of 5." + # ---------------- MAIN FLOW ---------------- async def run(self): - """Main conversation flow.""" try: - # Step 1: Greet and explain await self.capability_worker.speak( - "I can help you find freelance jobs on Upwork. What type of jobs are you looking for? " - "For example, web development, mobile app, data entry, or copy writing." + "I can help you find remote freelance jobs. " + "What type of jobs are you looking for?" ) - # Step 2: Get search query from user user_input = await self.capability_worker.user_response() if not user_input or not user_input.strip(): await self.capability_worker.speak( - "I didn't catch that. Please try again with a job category or skill." + "I didn't catch that. Please try again." ) - self.capability_worker.resume_normal_flow() return - # Step 3: Search for jobs await self.capability_worker.speak( - f"Searching for {user_input} jobs on Upwork..." + f"Searching for {user_input} jobs..." ) jobs = await self.search_jobs(user_input) - # Step 4: Speak results - if jobs and len(jobs) > 0: + if jobs: await self.capability_worker.speak( - f"I found {len(jobs)} jobs matching '{user_input}'. Here are the top results:" + f"I found {len(jobs)} jobs. Here are the top results." ) - - # Speak each job (limit to 3 for voice) for i, job in enumerate(jobs[:3]): - job_summary = self.format_job_for_speech(job, i) - await self.capability_worker.speak(job_summary) - - # Add closing message - await self.capability_worker.speak( - "Would you like me to search for a different category? Say stop to exit." - ) - - # Listen for follow-up - follow_up = await self.capability_worker.user_response() - - if follow_up and any(word in follow_up.lower() for word in ["yes", "sure", "another", "more", "search"]): - await self.capability_worker.speak("What would you like to search for?") - new_query = await self.capability_worker.user_response() - if new_query: - jobs = await self.search_jobs(new_query) - if jobs and len(jobs) > 0: - await self.capability_worker.speak( - f"I found {len(jobs)} jobs. Here are the results:" - ) - for i, job in enumerate(jobs[:3]): - job_summary = self.format_job_for_speech(job, i) - await self.capability_worker.speak(job_summary) - else: - await self.capability_worker.speak( - "I couldn't find any jobs matching that search." - ) - else: await self.capability_worker.speak( - "Happy job hunting! Talk to you later." + self.format_job_for_speech(job, i) ) else: await self.capability_worker.speak( - f"I couldn't find any jobs matching '{user_input}'. " - "Try a different category or skill. For example, Python programming, logo design, or content writing." + "I couldn't find any jobs matching that search." ) except Exception as e: - self.worker.editor_logging_handler.error(f"[UpworkJobSearch] Error: {e}") + self.worker.editor_logging_handler.error( + f"[UpworkJobSearch] Fatal error: {e}" + ) await self.capability_worker.speak( - "Something went wrong while searching for jobs. Please try again later." + "Something went wrong while searching." ) + finally: self.capability_worker.resume_normal_flow() From 434b7a4c098e2691b968f7f4cfeda2959ab2dc67 Mon Sep 17 00:00:00 2001 From: khushi0433 Date: Mon, 23 Feb 2026 14:02:23 +0500 Subject: [PATCH 09/10] fix(upwork-job-search): switch to Remotive API, remove OAuth and GraphQL --- community/upwork-job-search/config.json | 4 + community/upwork-job-search/images/badge.svg | 1 + community/upwork-job-search/run_local.py | 49 ++++ community/upwork-job-search/src/__init__.py | 0 .../upwork-job-search/src/agent/__init__.py | 0 .../upwork-job-search/src/agent/capability.py | 28 +++ .../src/agent/capability_worker.py | 45 ++++ community/upwork-job-search/src/main.py | 34 +++ community/upwork-job-search/test_ability.py | 222 ++++++++++++++++++ 9 files changed, 383 insertions(+) create mode 100644 community/upwork-job-search/config.json create mode 100644 community/upwork-job-search/images/badge.svg create mode 100644 community/upwork-job-search/run_local.py create mode 100644 community/upwork-job-search/src/__init__.py create mode 100644 community/upwork-job-search/src/agent/__init__.py create mode 100644 community/upwork-job-search/src/agent/capability.py create mode 100644 community/upwork-job-search/src/agent/capability_worker.py create mode 100644 community/upwork-job-search/src/main.py create mode 100644 community/upwork-job-search/test_ability.py diff --git a/community/upwork-job-search/config.json b/community/upwork-job-search/config.json new file mode 100644 index 00000000..39d15546 --- /dev/null +++ b/community/upwork-job-search/config.json @@ -0,0 +1,4 @@ +{ + "unique_name": "upwork-job-search", + "matching_hotwords": ["find jobs", "search upwork", "find freelance work", "upwork jobs", "look for work"] +} diff --git a/community/upwork-job-search/images/badge.svg b/community/upwork-job-search/images/badge.svg new file mode 100644 index 00000000..06c8460a --- /dev/null +++ b/community/upwork-job-search/images/badge.svg @@ -0,0 +1 @@ +Abilities: OpenAbilitiesOpen \ No newline at end of file diff --git a/community/upwork-job-search/run_local.py b/community/upwork-job-search/run_local.py new file mode 100644 index 00000000..3dd2adb6 --- /dev/null +++ b/community/upwork-job-search/run_local.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +""" +Local test runner for upwork-job-search ability. +This uses mock SDK classes to simulate the OpenHome environment. +""" +import asyncio +import sys +import os + +# Add the ability directory to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker +from main import UpworkJobSearchCapability + + +async def main(): + print("=" * 60) + print(" Upwork Job Search - Local Test Runner") + print("=" * 60) + print() + + # Create the worker (mock) + worker = AgentWorker() + + # Register and instantiate the capability + capability = UpworkJobSearchCapability.register_capability() + + # Set up the worker references + capability.worker = worker + capability.capability_worker = CapabilityWorker(worker) + + # Run the capability + print("Starting ability...\n") + try: + await capability.run() + except Exception as e: + print(f"\n Error: {e}") + import traceback + traceback.print_exc() + + print("\n" + "=" * 60) + print("Test runner finished") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/community/upwork-job-search/src/__init__.py b/community/upwork-job-search/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/upwork-job-search/src/agent/__init__.py b/community/upwork-job-search/src/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/upwork-job-search/src/agent/capability.py b/community/upwork-job-search/src/agent/capability.py new file mode 100644 index 00000000..a8f4353b --- /dev/null +++ b/community/upwork-job-search/src/agent/capability.py @@ -0,0 +1,28 @@ +# Mock SDK - Capability module +from typing import Optional + +class MatchingCapability: + """Mock base class for MatchingCapability""" + worker: 'AgentWorker' = None + capability_worker: 'CapabilityWorker' = None + unique_name: str = "" + matching_hotwords: list = [] + + def __init__(self, unique_name: str = "", matching_hotwords: list = None): + self.unique_name = unique_name + self.matching_hotwords = matching_hotwords or [] + self.worker = None + self.capability_worker = None + + @classmethod + def register_capability(cls) -> "MatchingCapability": + """Register this capability - must be implemented by subclass""" + raise NotImplementedError("Subclasses must implement register_capability") + + def call(self, worker: 'AgentWorker'): + """Called when the capability is triggered""" + raise NotImplementedError("Subclasses must implement call") + + async def run(self): + """Main execution logic - must be implemented by subclass""" + raise NotImplementedError("Subclasses must implement run") diff --git a/community/upwork-job-search/src/agent/capability_worker.py b/community/upwork-job-search/src/agent/capability_worker.py new file mode 100644 index 00000000..cd8b2062 --- /dev/null +++ b/community/upwork-job-search/src/agent/capability_worker.py @@ -0,0 +1,45 @@ +# Mock SDK - CapabilityWorker module +from typing import Optional, Any + +class CapabilityWorker: + """Mock CapabilityWorker class for local development""" + + def __init__(self, worker): + self.worker = worker + + async def speak(self, text: str): + """Speak text to the user""" + print(f"AI: {text}") + + async def user_response(self) -> str: + """Get user's voice response""" + return input("👤 User: ") + + async def wait_for_complete_transcription(self) -> str: + """Wait for complete transcription""" + return input("👤 User: ") + + def resume_normal_flow(self): + """Resume normal conversation flow""" + print("Resuming normal flow...") + + async def text_to_text_response(self, prompt: str) -> str: + """Get text-to-text response from LLM""" + print(f"LLM Prompt: {prompt}") + return input("LLM Response: ") + + # File operations (mock) + async def read_file(self, path: str) -> str: + """Read a file""" + with open(path, 'r') as f: + return f.read() + + async def write_file(self, path: str, content: str): + """Write to a file""" + with open(path, 'w') as f: + f.write(content) + + async def file_exists(self, path: str) -> bool: + """Check if file exists""" + import os + return os.path.exists(path) diff --git a/community/upwork-job-search/src/main.py b/community/upwork-job-search/src/main.py new file mode 100644 index 00000000..c9500330 --- /dev/null +++ b/community/upwork-job-search/src/main.py @@ -0,0 +1,34 @@ +# Mock SDK - Main module +from typing import Any + +class AgentWorker: + """Mock AgentWorker class for local development""" + session_tasks: 'SessionTasks' = None + editor_logging_handler: 'LoggingHandler' = None + + def __init__(self): + self.session_tasks = SessionTasks() + self.editor_logging_handler = LoggingHandler() + +class SessionTasks: + """Mock SessionTasks class""" + def create(self, coroutine): + """Create a task from coroutine""" + import asyncio + asyncio.create_task(coroutine) + + def sleep(self, seconds: float): + """Async sleep""" + import asyncio + return asyncio.sleep(seconds) + +class LoggingHandler: + """Mock LoggingHandler class""" + def error(self, msg: str): + print(f"ERROR: {msg}") + + def info(self, msg: str): + print(f"INFO: {msg}") + + def warning(self, msg: str): + print(f"WARNING: {msg}") diff --git a/community/upwork-job-search/test_ability.py b/community/upwork-job-search/test_ability.py new file mode 100644 index 00000000..6aecff0b --- /dev/null +++ b/community/upwork-job-search/test_ability.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Automated tests for the Upwork Job Search ability (Remotive API backend). +Covers: imports, _clean_html, search_jobs (live API), format_job_for_speech. +""" +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker +from main import UpworkJobSearchCapability + +PASS = "\033[92mPASS\033[0m" +FAIL = "\033[91mFAIL\033[0m" + +results = [] + + +def report(name: str, passed: bool, detail: str = ""): + status = PASS if passed else FAIL + print(f" [{status}] {name}" + (f" — {detail}" if detail else "")) + results.append(passed) + + +def make_capability() -> UpworkJobSearchCapability: + worker = AgentWorker() + cap = UpworkJobSearchCapability.register_capability() + cap.worker = worker + cap.capability_worker = CapabilityWorker(worker) + return cap + + +# ───────────────────────────────────────────── +# TEST 1 — Module imports correctly +# ───────────────────────────────────────────── +def test_import(): + print("\n[1] Module import") + try: + cap = make_capability() + report("UpworkJobSearchCapability instantiated", cap is not None) + report("unique_name loaded from config.json", bool(cap.unique_name)) + report( + "matching_hotwords is a non-empty list", + isinstance(cap.matching_hotwords, list) and len(cap.matching_hotwords) > 0, + ) + except Exception as e: + report("import / instantiation", False, str(e)) + + +# ───────────────────────────────────────────── +# TEST 2 — _clean_html +# ───────────────────────────────────────────── +def test_clean_html(): + print("\n[2] _clean_html()") + cap = make_capability() + + cases = [ + ("Hello world", "Hello world"), + ("

Line 1

Line 2

", "Line 1 Line 2"), + ("
New line", "New line"), + ("No tags here", "No tags here"), + ("A & B <3>", "A & B <3>"), + (" spaced ", "spaced"), + ('link', "link"), + ("", ""), + ("
  • item 1
  • item 2
", "item 1 item 2"), + ] + + for raw, expected in cases: + result = cap._clean_html(raw) + passed = result == expected + report(f"_clean_html({raw!r})", passed, f"got {result!r}" if not passed else "") + + +# ───────────────────────────────────────────── +# TEST 3 — format_job_for_speech +# ───────────────────────────────────────────── +def test_format_job_for_speech(): + print("\n[3] format_job_for_speech()") + cap = make_capability() + + job = { + "title": "Python Developer", + "company": "Acme Corp", + "description": "Build REST APIs using FastAPI and PostgreSQL.", + "link": "https://remotive.com/job/123", + "pub_date": "2024-01-01T00:00:00", + "salary": "$80k - $120k", + "location": "Worldwide", + "job_type": "full_time", + } + + speech = cap.format_job_for_speech(job, 0) + report("returns a string", isinstance(speech, str)) + report("contains job index (Job 1)", "Job 1" in speech) + report("contains title", "Python Developer" in speech) + report("contains company name", "Acme Corp" in speech) + report("contains location", "Worldwide" in speech) + report("contains salary", "$80k" in speech) + report("contains description snippet", "FastAPI" in speech) + report("not empty", bool(speech.strip())) + + # No salary case + job_no_salary = {**job, "salary": "Not specified"} + speech2 = cap.format_job_for_speech(job_no_salary, 1) + report("omits salary line when not specified", "Salary:" not in speech2) + report("still contains title when no salary", "Python Developer" in speech2) + + +# ───────────────────────────────────────────── +# TEST 4 — search_jobs live API call +# ───────────────────────────────────────────── +async def test_search_jobs(): + print("\n[4] search_jobs() — live Remotive API call") + cap = make_capability() + + jobs = await cap.search_jobs("software engineer") + + report("returns a list (not None)", jobs is not None, "" if jobs else "got None — check network") + + if jobs is None: + report("skipping further job checks (no results)", False) + return + + report("returns at most 5 jobs", len(jobs) <= 5, f"got {len(jobs)}") + report("returns at least 1 job", len(jobs) >= 1, f"got {len(jobs)}") + + job = jobs[0] + required_keys = ["title", "company", "description", "link", "pub_date", "salary", "location"] + for key in required_keys: + report(f"first job has '{key}' key", key in job) + + report("title is a non-empty string", isinstance(job["title"], str) and bool(job["title"])) + report("description has no raw HTML tags", "<" not in job["description"]) + report( + "description truncated to <=303 chars", + len(job["description"]) <= 303, + f"len={len(job['description'])}", + ) + report( + "link starts with https://", + job["link"].startswith("https://"), + f"got {job['link'][:60]}", + ) + + print("\n Sample job fetched:") + print(f" Title : {job['title']}") + print(f" Company : {job['company']}") + print(f" Location : {job['location']}") + print(f" Salary : {job['salary']}") + print(f" Published: {job['pub_date']}") + print(f" Desc : {job['description'][:120]}...") + print(f" Link : {job['link']}") + + +# ───────────────────────────────────────────── +# TEST 5 — search_jobs edge cases +# ───────────────────────────────────────────── +async def test_edge_cases(): + print("\n[5] search_jobs() — edge cases") + cap = make_capability() + + # A nonsense query — should return None or an empty-list-derived None, not crash + jobs = await cap.search_jobs("zzzzzzzzzzzzz_no_results_expected_xyzxyz") + report( + "nonsense query returns None or list (no crash)", + jobs is None or isinstance(jobs, list), + ) + + # Empty string — should not crash + jobs_empty = await cap.search_jobs("") + report( + "empty query returns None or list (no crash)", + jobs_empty is None or isinstance(jobs_empty, list), + ) + + # Multi-word phrase that triggers the keyword fallback + jobs_fallback = await cap.search_jobs("python developer") + report( + "multi-word fallback returns None or list (no crash)", + jobs_fallback is None or isinstance(jobs_fallback, list), + ) + if jobs_fallback: + report( + "fallback result has 'title' key", + "title" in jobs_fallback[0], + ) + + +# ───────────────────────────────────────────── +# RUNNER +# ───────────────────────────────────────────── +async def run_all(): + print("=" * 60) + print(" Upwork Job Search — Ability Test Suite") + print("=" * 60) + + test_import() + test_clean_html() + test_format_job_for_speech() + await test_search_jobs() + await test_edge_cases() + + total = len(results) + passed = sum(results) + failed = total - passed + + print("\n" + "=" * 60) + if failed == 0: + print(f" Results: {passed}/{total} passed — ALL TESTS PASSED") + else: + print(f" Results: {passed}/{total} passed ({failed} FAILED)") + print("=" * 60) + + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + asyncio.run(run_all()) From 66e001010241c559434069d87f38e421d7105ffe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 09:05:07 +0000 Subject: [PATCH 10/10] style: auto-format Python files with autoflake + autopep8 --- community/upwork-job-search/run_local.py | 17 ++++++++--------- .../upwork-job-search/src/agent/capability.py | 1 - .../src/agent/capability_worker.py | 3 +-- community/upwork-job-search/src/main.py | 11 +++++++---- community/upwork-job-search/test_ability.py | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/community/upwork-job-search/run_local.py b/community/upwork-job-search/run_local.py index 3dd2adb6..1591a0c6 100644 --- a/community/upwork-job-search/run_local.py +++ b/community/upwork-job-search/run_local.py @@ -3,6 +3,9 @@ Local test runner for upwork-job-search ability. This uses mock SDK classes to simulate the OpenHome environment. """ +from main import UpworkJobSearchCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker import asyncio import sys import os @@ -10,27 +13,23 @@ # Add the ability directory to the path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from src.main import AgentWorker -from src.agent.capability_worker import CapabilityWorker -from main import UpworkJobSearchCapability - async def main(): print("=" * 60) print(" Upwork Job Search - Local Test Runner") print("=" * 60) print() - + # Create the worker (mock) worker = AgentWorker() - + # Register and instantiate the capability capability = UpworkJobSearchCapability.register_capability() - + # Set up the worker references capability.worker = worker capability.capability_worker = CapabilityWorker(worker) - + # Run the capability print("Starting ability...\n") try: @@ -39,7 +38,7 @@ async def main(): print(f"\n Error: {e}") import traceback traceback.print_exc() - + print("\n" + "=" * 60) print("Test runner finished") print("=" * 60) diff --git a/community/upwork-job-search/src/agent/capability.py b/community/upwork-job-search/src/agent/capability.py index a8f4353b..3df85248 100644 --- a/community/upwork-job-search/src/agent/capability.py +++ b/community/upwork-job-search/src/agent/capability.py @@ -1,5 +1,4 @@ # Mock SDK - Capability module -from typing import Optional class MatchingCapability: """Mock base class for MatchingCapability""" diff --git a/community/upwork-job-search/src/agent/capability_worker.py b/community/upwork-job-search/src/agent/capability_worker.py index cd8b2062..bf5f515e 100644 --- a/community/upwork-job-search/src/agent/capability_worker.py +++ b/community/upwork-job-search/src/agent/capability_worker.py @@ -1,9 +1,8 @@ # Mock SDK - CapabilityWorker module -from typing import Optional, Any class CapabilityWorker: """Mock CapabilityWorker class for local development""" - + def __init__(self, worker): self.worker = worker diff --git a/community/upwork-job-search/src/main.py b/community/upwork-job-search/src/main.py index c9500330..111e3017 100644 --- a/community/upwork-job-search/src/main.py +++ b/community/upwork-job-search/src/main.py @@ -1,5 +1,4 @@ # Mock SDK - Main module -from typing import Any class AgentWorker: """Mock AgentWorker class for local development""" @@ -10,25 +9,29 @@ def __init__(self): self.session_tasks = SessionTasks() self.editor_logging_handler = LoggingHandler() + class SessionTasks: """Mock SessionTasks class""" + def create(self, coroutine): """Create a task from coroutine""" import asyncio asyncio.create_task(coroutine) - + def sleep(self, seconds: float): """Async sleep""" import asyncio return asyncio.sleep(seconds) + class LoggingHandler: """Mock LoggingHandler class""" + def error(self, msg: str): print(f"ERROR: {msg}") - + def info(self, msg: str): print(f"INFO: {msg}") - + def warning(self, msg: str): print(f"WARNING: {msg}") diff --git a/community/upwork-job-search/test_ability.py b/community/upwork-job-search/test_ability.py index 6aecff0b..777fffb0 100644 --- a/community/upwork-job-search/test_ability.py +++ b/community/upwork-job-search/test_ability.py @@ -3,15 +3,15 @@ Automated tests for the Upwork Job Search ability (Remotive API backend). Covers: imports, _clean_html, search_jobs (live API), format_job_for_speech. """ +from main import UpworkJobSearchCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker import asyncio import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from src.main import AgentWorker -from src.agent.capability_worker import CapabilityWorker -from main import UpworkJobSearchCapability PASS = "\033[92mPASS\033[0m" FAIL = "\033[91mFAIL\033[0m"