loop-agent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/agents/__init__.py +0 -0
- app/agents/graph.py +40 -0
- app/agents/nodes.py +245 -0
- app/agents/state.py +19 -0
- app/api/__init__.py +0 -0
- app/api/candidates.py +49 -0
- app/api/dashboard.py +7 -0
- app/api/outreach.py +7 -0
- app/api/pipelines.py +58 -0
- app/api/positions.py +76 -0
- app/api/router.py +14 -0
- app/api/scheduler.py +7 -0
- app/api/skills.py +7 -0
- app/api/system.py +7 -0
- app/core/__init__.py +0 -0
- app/core/config.py +18 -0
- app/core/exception_handler.py +91 -0
- app/core/exceptions.py +33 -0
- app/core/logging.py +19 -0
- app/database/__init__.py +0 -0
- app/database/base.py +4 -0
- app/database/session.py +20 -0
- app/main.py +72 -0
- app/models/__init__.py +0 -0
- app/models/agent_run.py +18 -0
- app/models/candidate.py +28 -0
- app/models/node_log.py +18 -0
- app/models/outreach_log.py +16 -0
- app/models/pipeline.py +21 -0
- app/models/position.py +22 -0
- app/models/scheduler_job.py +16 -0
- app/models/skill.py +13 -0
- app/models/system_config.py +12 -0
- app/repositories/__init__.py +0 -0
- app/repositories/agent_run.py +74 -0
- app/repositories/candidate.py +84 -0
- app/repositories/node_log.py +57 -0
- app/repositories/outreach_log.py +60 -0
- app/repositories/pipeline.py +80 -0
- app/repositories/position.py +67 -0
- app/repositories/scheduler_job.py +74 -0
- app/schemas/__init__.py +0 -0
- app/schemas/agent_run.py +32 -0
- app/schemas/candidate.py +58 -0
- app/schemas/node_log.py +31 -0
- app/schemas/outreach_log.py +28 -0
- app/schemas/pipeline.py +34 -0
- app/schemas/position.py +49 -0
- app/schemas/scheduler_job.py +29 -0
- app/services/__init__.py +0 -0
- app/services/candidate.py +58 -0
- app/services/dashboard.py +230 -0
- app/services/email.py +116 -0
- app/services/health.py +105 -0
- app/services/pipeline.py +75 -0
- app/services/position.py +36 -0
- app/services/runner.py +292 -0
- app/services/scheduler.py +174 -0
- app/services/score.py +155 -0
- app/services/search.py +92 -0
- app/skills/base.py +30 -0
- app/skills/github.py +106 -0
- app/skills/registry.py +51 -0
- app/tests/__init__.py +3 -0
- app/tests/conftest.py +96 -0
- app/tests/generate_report.py +144 -0
- app/tests/test_candidates.py +158 -0
- app/tests/test_dashboard.py +27 -0
- app/tests/test_outreach.py +15 -0
- app/tests/test_pipelines.py +249 -0
- app/tests/test_positions.py +183 -0
- app/tests/test_scheduler.py +15 -0
- app/tests/test_skills.py +15 -0
- app/tests/test_system.py +35 -0
- app/utils/__init__.py +0 -0
- loop_agent_cli/__init__.py +5 -0
- loop_agent_cli/cli.py +728 -0
- loop_agent_cli/container.py +191 -0
- loop_agent_cli-0.1.0.dist-info/METADATA +202 -0
- loop_agent_cli-0.1.0.dist-info/RECORD +84 -0
- loop_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- loop_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- loop_agent_cli-0.1.0.dist-info/top_level.txt +2 -0
app/services/search.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import List, Dict, Any
|
|
2
|
+
from app.skills.registry import skill_registry
|
|
3
|
+
from app.services.candidate import CandidateService
|
|
4
|
+
from app.repositories.candidate import CandidateRepository
|
|
5
|
+
from app.models.candidate import Candidate
|
|
6
|
+
from app.core.exceptions import GitHubAPIException
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchService:
|
|
11
|
+
"""
|
|
12
|
+
Service for handling candidate searches across different platforms
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, candidate_service: CandidateService):
|
|
16
|
+
self.candidate_service = candidate_service
|
|
17
|
+
self.skill_registry = skill_registry
|
|
18
|
+
|
|
19
|
+
async def search_candidates(self, position_id: str, keywords: List[str], max_results: int = 100) -> List[Candidate]:
|
|
20
|
+
"""
|
|
21
|
+
Search for candidates using various skills
|
|
22
|
+
"""
|
|
23
|
+
all_candidates = []
|
|
24
|
+
|
|
25
|
+
# Use GitHub skill to search for candidates
|
|
26
|
+
try:
|
|
27
|
+
github_skill = self.skill_registry.get_skill("github_search")
|
|
28
|
+
github_results = await github_skill.execute(keywords, max_results)
|
|
29
|
+
|
|
30
|
+
# Convert results to candidate objects
|
|
31
|
+
for candidate_data in github_results:
|
|
32
|
+
# Create or update candidate in the database
|
|
33
|
+
candidate_create_data = {
|
|
34
|
+
"source": candidate_data["source"],
|
|
35
|
+
"source_id": candidate_data["source_id"],
|
|
36
|
+
"github_login": candidate_data["github_login"],
|
|
37
|
+
"name": candidate_data["name"],
|
|
38
|
+
"email": candidate_data["email"],
|
|
39
|
+
"location": candidate_data["location"],
|
|
40
|
+
"company": candidate_data["company"],
|
|
41
|
+
"title": candidate_data["title"],
|
|
42
|
+
"bio": candidate_data["bio"],
|
|
43
|
+
"followers": candidate_data["followers"],
|
|
44
|
+
"public_repos": candidate_data["public_repos"],
|
|
45
|
+
"profile_url": candidate_data["profile_url"],
|
|
46
|
+
"avatar_url": candidate_data["avatar_url"],
|
|
47
|
+
"skills": candidate_data["skills"],
|
|
48
|
+
"search_keywords": candidate_data["search_keywords"]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Create candidate (this will handle deduplication)
|
|
52
|
+
candidate = await self.candidate_service.create_candidate(candidate_create_data)
|
|
53
|
+
all_candidates.append(candidate)
|
|
54
|
+
|
|
55
|
+
except GitHubAPIException as e:
|
|
56
|
+
# Log the error but continue with other skills if available
|
|
57
|
+
print(f"GitHub search failed: {str(e)}")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"Unexpected error during search: {str(e)}")
|
|
60
|
+
|
|
61
|
+
return all_candidates
|
|
62
|
+
|
|
63
|
+
async def generate_search_keywords(self, position_title: str, required_skills: List[str], search_keywords: List[str]) -> List[str]:
|
|
64
|
+
"""
|
|
65
|
+
Generate comprehensive search keywords based on position requirements
|
|
66
|
+
"""
|
|
67
|
+
keywords = []
|
|
68
|
+
|
|
69
|
+
# Add position title keywords
|
|
70
|
+
if position_title:
|
|
71
|
+
keywords.extend([position_title.lower(), position_title.replace(" ", "+")])
|
|
72
|
+
|
|
73
|
+
# Add required skills
|
|
74
|
+
if required_skills:
|
|
75
|
+
keywords.extend(required_skills)
|
|
76
|
+
# Combine skills for more specific searches
|
|
77
|
+
for i in range(len(required_skills)):
|
|
78
|
+
for j in range(i+1, len(required_skills)):
|
|
79
|
+
combined = f"{required_skills[i]}+{required_skills[j]}"
|
|
80
|
+
keywords.append(combined)
|
|
81
|
+
|
|
82
|
+
# Add custom search keywords
|
|
83
|
+
if search_keywords:
|
|
84
|
+
keywords.extend(search_keywords)
|
|
85
|
+
|
|
86
|
+
# Remove duplicates while preserving order
|
|
87
|
+
unique_keywords = []
|
|
88
|
+
for kw in keywords:
|
|
89
|
+
if kw not in unique_keywords:
|
|
90
|
+
unique_keywords.append(kw)
|
|
91
|
+
|
|
92
|
+
return unique_keywords
|
app/skills/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from app.models.candidate import Candidate
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseSkill(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Base class for all skills in the Recruiting Loop Agent
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def execute(self, *args, **kwargs) -> List[Dict[str, Any]]:
|
|
13
|
+
"""
|
|
14
|
+
Execute the skill and return a list of candidate data
|
|
15
|
+
"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def name(self) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Return the name of the skill
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def description(self) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Return the description of the skill
|
|
29
|
+
"""
|
|
30
|
+
pass
|
app/skills/github.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from app.skills.base import BaseSkill
|
|
4
|
+
from app.core.config import settings
|
|
5
|
+
from app.core.exceptions import GitHubAPIException
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
class GitHubSkill(BaseSkill):
|
|
9
|
+
"""
|
|
10
|
+
GitHub search skill for the Recruiting Loop Agent
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.github_token = settings.github_token
|
|
15
|
+
self.headers = {
|
|
16
|
+
"Accept": "application/vnd.github.v3+json",
|
|
17
|
+
"User-Agent": "Recruiting-Loop-Agent"
|
|
18
|
+
}
|
|
19
|
+
if self.github_token:
|
|
20
|
+
self.headers["Authorization"] = f"token {self.github_token}"
|
|
21
|
+
|
|
22
|
+
async def execute(self, keywords: List[str], max_results: int = 100) -> List[Dict[str, Any]]:
|
|
23
|
+
"""
|
|
24
|
+
Execute GitHub search and return a list of candidate data
|
|
25
|
+
"""
|
|
26
|
+
all_candidates = []
|
|
27
|
+
|
|
28
|
+
for keyword in keywords:
|
|
29
|
+
try:
|
|
30
|
+
# Search users based on keyword
|
|
31
|
+
search_url = f"https://api.github.com/search/users?q={keyword}&per_page=30"
|
|
32
|
+
|
|
33
|
+
async with httpx.AsyncClient() as client:
|
|
34
|
+
response = await client.get(search_url, headers=self.headers)
|
|
35
|
+
|
|
36
|
+
if response.status_code == 200:
|
|
37
|
+
data = response.json()
|
|
38
|
+
users = data.get("items", [])
|
|
39
|
+
|
|
40
|
+
# Get detailed user information for each user
|
|
41
|
+
for user in users[:min(len(users), max_results//len(keywords))]:
|
|
42
|
+
user_details = await self._get_user_details(user["login"])
|
|
43
|
+
if user_details:
|
|
44
|
+
all_candidates.append(user_details)
|
|
45
|
+
|
|
46
|
+
# Respect rate limits
|
|
47
|
+
await asyncio.sleep(1)
|
|
48
|
+
elif response.status_code == 403:
|
|
49
|
+
# Handle rate limiting
|
|
50
|
+
reset_time = int(response.headers.get("X-RateLimit-Reset", 0))
|
|
51
|
+
current_time = int(asyncio.get_event_loop().time())
|
|
52
|
+
sleep_time = max(reset_time - current_time, 60)
|
|
53
|
+
await asyncio.sleep(sleep_time)
|
|
54
|
+
continue
|
|
55
|
+
else:
|
|
56
|
+
raise GitHubAPIException(f"GitHub API returned status code {response.status_code}")
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise GitHubAPIException(f"Error searching GitHub for keyword '{keyword}': {str(e)}")
|
|
60
|
+
|
|
61
|
+
return all_candidates
|
|
62
|
+
|
|
63
|
+
async def _get_user_details(self, username: str) -> Dict[str, Any]:
|
|
64
|
+
"""
|
|
65
|
+
Get detailed information about a GitHub user
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
user_url = f"https://api.github.com/users/{username}"
|
|
69
|
+
|
|
70
|
+
async with httpx.AsyncClient() as client:
|
|
71
|
+
response = await client.get(user_url, headers=self.headers)
|
|
72
|
+
|
|
73
|
+
if response.status_code == 200:
|
|
74
|
+
user_data = response.json()
|
|
75
|
+
|
|
76
|
+
# Format the user data to match our candidate model
|
|
77
|
+
candidate_data = {
|
|
78
|
+
"source": "github",
|
|
79
|
+
"source_id": str(user_data.get("id", "")),
|
|
80
|
+
"github_login": user_data.get("login", ""),
|
|
81
|
+
"name": user_data.get("name", ""),
|
|
82
|
+
"email": user_data.get("email", ""),
|
|
83
|
+
"location": user_data.get("location", ""),
|
|
84
|
+
"company": user_data.get("company", ""),
|
|
85
|
+
"bio": user_data.get("bio", ""),
|
|
86
|
+
"followers": user_data.get("followers", 0),
|
|
87
|
+
"public_repos": user_data.get("public_repos", 0),
|
|
88
|
+
"profile_url": user_data.get("html_url", ""),
|
|
89
|
+
"avatar_url": user_data.get("avatar_url", ""),
|
|
90
|
+
"title": "", # GitHub doesn't have a direct title field
|
|
91
|
+
"skills": [], # Skills would need to be inferred from repos
|
|
92
|
+
"search_keywords": [] # Will be populated later
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return candidate_data
|
|
96
|
+
else:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise GitHubAPIException(f"Error fetching details for user '{username}': {str(e)}")
|
|
101
|
+
|
|
102
|
+
def name(self) -> str:
|
|
103
|
+
return "github_search"
|
|
104
|
+
|
|
105
|
+
def description(self) -> str:
|
|
106
|
+
return "Searches GitHub for potential candidates based on keywords"
|
app/skills/registry.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Dict, Type, List
|
|
2
|
+
from app.skills.base import BaseSkill
|
|
3
|
+
from app.skills.github import GitHubSkill
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SkillRegistry:
|
|
7
|
+
"""
|
|
8
|
+
Registry for all available skills in the Recruiting Loop Agent
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._skills: Dict[str, BaseSkill] = {}
|
|
13
|
+
self._skill_classes: Dict[str, Type[BaseSkill]] = {
|
|
14
|
+
"github_search": GitHubSkill
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def register_skill(self, skill_name: str, skill_instance: BaseSkill):
|
|
18
|
+
"""
|
|
19
|
+
Register a new skill instance
|
|
20
|
+
"""
|
|
21
|
+
self._skills[skill_name] = skill_instance
|
|
22
|
+
|
|
23
|
+
def get_skill(self, skill_name: str) -> BaseSkill:
|
|
24
|
+
"""
|
|
25
|
+
Get a skill instance by name
|
|
26
|
+
"""
|
|
27
|
+
if skill_name not in self._skills:
|
|
28
|
+
if skill_name in self._skill_classes:
|
|
29
|
+
# Create and register the skill if it's not already registered
|
|
30
|
+
skill_instance = self._skill_classes[skill_name]()
|
|
31
|
+
self.register_skill(skill_name, skill_instance)
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(f"Skill '{skill_name}' not found in registry")
|
|
34
|
+
|
|
35
|
+
return self._skills[skill_name]
|
|
36
|
+
|
|
37
|
+
def get_all_skills(self) -> List[BaseSkill]:
|
|
38
|
+
"""
|
|
39
|
+
Get all available skills
|
|
40
|
+
"""
|
|
41
|
+
return list(self._skills.values())
|
|
42
|
+
|
|
43
|
+
def get_available_skill_names(self) -> List[str]:
|
|
44
|
+
"""
|
|
45
|
+
Get names of all available skills
|
|
46
|
+
"""
|
|
47
|
+
return list(self._skill_classes.keys())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Global skill registry instance
|
|
51
|
+
skill_registry = SkillRegistry()
|
app/tests/__init__.py
ADDED
app/tests/conftest.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test configuration and fixtures
|
|
3
|
+
"""
|
|
4
|
+
import pytest
|
|
5
|
+
import pytest_asyncio
|
|
6
|
+
from httpx import AsyncClient, ASGITransport
|
|
7
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
8
|
+
from sqlalchemy.orm import sessionmaker
|
|
9
|
+
from app.database.base import Base
|
|
10
|
+
from app.database.session import get_db
|
|
11
|
+
from app.main import app
|
|
12
|
+
|
|
13
|
+
# Use in-memory SQLite for tests
|
|
14
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
|
15
|
+
|
|
16
|
+
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
|
17
|
+
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest_asyncio.fixture
|
|
21
|
+
async def db_session():
|
|
22
|
+
"""Create a fresh database session for each test"""
|
|
23
|
+
async with engine.begin() as conn:
|
|
24
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
25
|
+
|
|
26
|
+
async with AsyncSessionLocal() as session:
|
|
27
|
+
yield session
|
|
28
|
+
|
|
29
|
+
async with engine.begin() as conn:
|
|
30
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest_asyncio.fixture
|
|
34
|
+
async def client(db_session):
|
|
35
|
+
"""Create a test client with overridden database dependency"""
|
|
36
|
+
async def override_get_db():
|
|
37
|
+
yield db_session
|
|
38
|
+
|
|
39
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
40
|
+
|
|
41
|
+
transport = ASGITransport(app=app)
|
|
42
|
+
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
43
|
+
yield ac
|
|
44
|
+
|
|
45
|
+
app.dependency_overrides.clear()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def sample_position_data():
|
|
50
|
+
"""Sample position data for testing"""
|
|
51
|
+
return {
|
|
52
|
+
"title": "Senior Software Engineer",
|
|
53
|
+
"company": "Tech Corp",
|
|
54
|
+
"description": "Looking for an experienced backend developer",
|
|
55
|
+
"location": "San Francisco, CA",
|
|
56
|
+
"required_skills": ["Python", "FastAPI", "PostgreSQL"],
|
|
57
|
+
"search_keywords": ["backend", "python", "developer"],
|
|
58
|
+
"loop_interval": 60
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def sample_candidate_data():
|
|
64
|
+
"""Sample candidate data for testing"""
|
|
65
|
+
return {
|
|
66
|
+
"source": "github",
|
|
67
|
+
"source_id": "12345",
|
|
68
|
+
"github_login": "johndoe",
|
|
69
|
+
"name": "John Doe",
|
|
70
|
+
"email": "john@example.com",
|
|
71
|
+
"location": "San Francisco, CA",
|
|
72
|
+
"company": "Current Corp",
|
|
73
|
+
"title": "Software Engineer",
|
|
74
|
+
"bio": "Experienced Python developer",
|
|
75
|
+
"followers": 100,
|
|
76
|
+
"public_repos": 25,
|
|
77
|
+
"skills": ["Python", "Django", "PostgreSQL"],
|
|
78
|
+
"profile_url": "https://github.com/johndoe",
|
|
79
|
+
"avatar_url": "https://github.com/johndoe.png",
|
|
80
|
+
"search_keywords": ["python", "backend"]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def sample_pipeline_data():
|
|
86
|
+
"""Sample pipeline data for testing"""
|
|
87
|
+
import uuid
|
|
88
|
+
return {
|
|
89
|
+
"position_id": str(uuid.uuid4()),
|
|
90
|
+
"candidate_id": str(uuid.uuid4()),
|
|
91
|
+
"status": "discovered",
|
|
92
|
+
"score": 85.5,
|
|
93
|
+
"contact_count": 0,
|
|
94
|
+
"candidate_interest": "high",
|
|
95
|
+
"notes": "Strong candidate"
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test report generator
|
|
3
|
+
Generates a Markdown report from pytest results
|
|
4
|
+
"""
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_tests_and_generate_report():
|
|
12
|
+
"""Run pytest and generate a Markdown test report"""
|
|
13
|
+
|
|
14
|
+
# Run pytest with verbose output
|
|
15
|
+
project_root = Path(__file__).parent.parent.parent
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["pytest", "app/tests/", "-v", "--tb=short"],
|
|
18
|
+
capture_output=True,
|
|
19
|
+
text=True,
|
|
20
|
+
cwd=str(project_root)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Parse test results
|
|
24
|
+
test_results = []
|
|
25
|
+
|
|
26
|
+
for line in result.stdout.split('\n'):
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if '::test_' in line and ('PASSED' in line or 'FAILED' in line or 'SKIPPED' in line):
|
|
29
|
+
# Parse test line like: app/tests/test_positions.py::test_create_position PASSED [ 59%]
|
|
30
|
+
parts = line.split('::')
|
|
31
|
+
if len(parts) >= 2:
|
|
32
|
+
file_part = parts[0]
|
|
33
|
+
module = file_part.replace('app/tests/test_', '').replace('.py', '')
|
|
34
|
+
|
|
35
|
+
# The last part contains test name and status
|
|
36
|
+
last_part = parts[-1]
|
|
37
|
+
test_name = last_part.split(' ')[0]
|
|
38
|
+
|
|
39
|
+
if 'PASSED' in line:
|
|
40
|
+
status = 'PASSED'
|
|
41
|
+
elif 'FAILED' in line:
|
|
42
|
+
status = 'FAILED'
|
|
43
|
+
else:
|
|
44
|
+
status = 'SKIPPED'
|
|
45
|
+
|
|
46
|
+
test_results.append({
|
|
47
|
+
'module': module,
|
|
48
|
+
'test_name': test_name,
|
|
49
|
+
'status': status,
|
|
50
|
+
'line': line
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# Generate Markdown report
|
|
54
|
+
report = []
|
|
55
|
+
report.append("# Recruit Loop Agent API 测试报告")
|
|
56
|
+
report.append("")
|
|
57
|
+
report.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
58
|
+
report.append("")
|
|
59
|
+
|
|
60
|
+
# Test environment
|
|
61
|
+
report.append("## 测试环境说明")
|
|
62
|
+
report.append("")
|
|
63
|
+
report.append("- **测试框架**: pytest + pytest-asyncio")
|
|
64
|
+
report.append("- **HTTP 客户端**: httpx.AsyncClient")
|
|
65
|
+
report.append("- **数据库**: SQLite (内存模式)")
|
|
66
|
+
report.append("- **测试目标**: FastAPI 应用所有 API 接口")
|
|
67
|
+
report.append("")
|
|
68
|
+
|
|
69
|
+
# Summary
|
|
70
|
+
total = len(test_results)
|
|
71
|
+
passed = sum(1 for t in test_results if t['status'] == 'PASSED')
|
|
72
|
+
failed = sum(1 for t in test_results if t['status'] == 'FAILED')
|
|
73
|
+
skipped = sum(1 for t in test_results if t['status'] == 'SKIPPED')
|
|
74
|
+
|
|
75
|
+
report.append("## 测试执行摘要")
|
|
76
|
+
report.append("")
|
|
77
|
+
report.append(f"- **总测试用例数**: {total}")
|
|
78
|
+
report.append(f"- **通过**: {passed}")
|
|
79
|
+
report.append(f"- **失败**: {failed}")
|
|
80
|
+
report.append(f"- **跳过**: {skipped}")
|
|
81
|
+
report.append(f"- **通过率**: {(passed/total*100) if total > 0 else 0:.2f}%")
|
|
82
|
+
report.append("")
|
|
83
|
+
|
|
84
|
+
# Module breakdown
|
|
85
|
+
report.append("## 各模块测试详情")
|
|
86
|
+
report.append("")
|
|
87
|
+
|
|
88
|
+
modules = {}
|
|
89
|
+
for test in test_results:
|
|
90
|
+
module = test['module']
|
|
91
|
+
if module not in modules:
|
|
92
|
+
modules[module] = []
|
|
93
|
+
modules[module].append(test)
|
|
94
|
+
|
|
95
|
+
for module, tests in sorted(modules.items()):
|
|
96
|
+
report.append(f"### {module.upper()} 模块")
|
|
97
|
+
report.append("")
|
|
98
|
+
report.append("| 测试用例 | 状态 |")
|
|
99
|
+
report.append("|---------|------|")
|
|
100
|
+
|
|
101
|
+
for test in tests:
|
|
102
|
+
status_icon = "✅" if test['status'] == 'PASSED' else "❌" if test['status'] == 'FAILED' else "⏭️"
|
|
103
|
+
report.append(f"| {test['test_name']} | {status_icon} {test['status']} |")
|
|
104
|
+
|
|
105
|
+
report.append("")
|
|
106
|
+
|
|
107
|
+
# Failed tests details
|
|
108
|
+
if failed > 0:
|
|
109
|
+
report.append("## 失败用例详情")
|
|
110
|
+
report.append("")
|
|
111
|
+
|
|
112
|
+
for test in test_results:
|
|
113
|
+
if test['status'] == 'FAILED':
|
|
114
|
+
report.append(f"### {test['module']}::{test['test_name']}")
|
|
115
|
+
report.append("")
|
|
116
|
+
report.append("```")
|
|
117
|
+
# Extract error from pytest output
|
|
118
|
+
report.append(test['line'])
|
|
119
|
+
report.append("```")
|
|
120
|
+
report.append("")
|
|
121
|
+
|
|
122
|
+
# Full output
|
|
123
|
+
report.append("## 完整测试输出")
|
|
124
|
+
report.append("")
|
|
125
|
+
report.append("```")
|
|
126
|
+
report.append(result.stdout)
|
|
127
|
+
report.append("```")
|
|
128
|
+
report.append("")
|
|
129
|
+
|
|
130
|
+
# Write report to file
|
|
131
|
+
project_root = Path(__file__).parent.parent.parent
|
|
132
|
+
report_path = project_root / "docs" / "api_test_report.md"
|
|
133
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
with open(report_path, 'w', encoding='utf-8') as f:
|
|
136
|
+
f.write('\n'.join(report))
|
|
137
|
+
|
|
138
|
+
print(f"测试报告已生成: {report_path}")
|
|
139
|
+
|
|
140
|
+
return result.returncode
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(run_tests_and_generate_report())
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Candidates API tests
|
|
3
|
+
"""
|
|
4
|
+
import pytest
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.asyncio
|
|
9
|
+
async def test_create_candidate(client, sample_candidate_data):
|
|
10
|
+
"""Test creating a new candidate"""
|
|
11
|
+
response = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
12
|
+
|
|
13
|
+
assert response.status_code == 200
|
|
14
|
+
data = response.json()
|
|
15
|
+
|
|
16
|
+
assert data["source"] == sample_candidate_data["source"]
|
|
17
|
+
assert data["source_id"] == sample_candidate_data["source_id"]
|
|
18
|
+
assert data["github_login"] == sample_candidate_data["github_login"]
|
|
19
|
+
assert data["name"] == sample_candidate_data["name"]
|
|
20
|
+
assert "id" in data
|
|
21
|
+
assert "created_at" in data
|
|
22
|
+
assert "updated_at" in data
|
|
23
|
+
assert data["appearance_count"] == 1
|
|
24
|
+
assert data["source_weight"] == 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_create_duplicate_candidate(client, sample_candidate_data):
|
|
29
|
+
"""Test creating a duplicate candidate increments appearance count"""
|
|
30
|
+
# Create first time
|
|
31
|
+
response1 = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
32
|
+
assert response1.status_code == 200
|
|
33
|
+
|
|
34
|
+
# Create duplicate
|
|
35
|
+
response2 = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
36
|
+
assert response2.status_code == 200
|
|
37
|
+
|
|
38
|
+
data = response2.json()
|
|
39
|
+
assert data["appearance_count"] == 2
|
|
40
|
+
assert data["source_weight"] == 2.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_get_candidates(client, sample_candidate_data):
|
|
45
|
+
"""Test getting all candidates"""
|
|
46
|
+
await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
47
|
+
|
|
48
|
+
response = await client.get("/api/v1/candidates")
|
|
49
|
+
|
|
50
|
+
assert response.status_code == 200
|
|
51
|
+
data = response.json()
|
|
52
|
+
|
|
53
|
+
assert isinstance(data, list)
|
|
54
|
+
assert len(data) >= 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_get_candidates_with_pagination(client, sample_candidate_data):
|
|
59
|
+
"""Test getting candidates with pagination"""
|
|
60
|
+
# Create multiple candidates
|
|
61
|
+
for i in range(3):
|
|
62
|
+
data = sample_candidate_data.copy()
|
|
63
|
+
data["source_id"] = str(10000 + i)
|
|
64
|
+
data["github_login"] = f"user{i}"
|
|
65
|
+
await client.post("/api/v1/candidates", json=data)
|
|
66
|
+
|
|
67
|
+
response = await client.get("/api/v1/candidates?skip=0&limit=2")
|
|
68
|
+
|
|
69
|
+
assert response.status_code == 200
|
|
70
|
+
data = response.json()
|
|
71
|
+
assert len(data) == 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_get_candidates_with_keyword_filter(client, sample_candidate_data):
|
|
76
|
+
"""Test getting candidates with keyword filter"""
|
|
77
|
+
await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
78
|
+
|
|
79
|
+
response = await client.get("/api/v1/candidates?keyword=John")
|
|
80
|
+
|
|
81
|
+
assert response.status_code == 200
|
|
82
|
+
data = response.json()
|
|
83
|
+
assert len(data) >= 1
|
|
84
|
+
assert any("John" in c.get("name", "") for c in data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_get_candidate_by_id(client, sample_candidate_data):
|
|
89
|
+
"""Test getting a specific candidate by ID"""
|
|
90
|
+
create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
91
|
+
candidate_id = create_response.json()["id"]
|
|
92
|
+
|
|
93
|
+
response = await client.get(f"/api/v1/candidates/{candidate_id}")
|
|
94
|
+
|
|
95
|
+
assert response.status_code == 200
|
|
96
|
+
data = response.json()
|
|
97
|
+
assert data["id"] == candidate_id
|
|
98
|
+
assert data["name"] == sample_candidate_data["name"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_get_candidate_not_found(client):
|
|
103
|
+
"""Test getting a non-existent candidate"""
|
|
104
|
+
fake_id = str(uuid.uuid4())
|
|
105
|
+
response = await client.get(f"/api/v1/candidates/{fake_id}")
|
|
106
|
+
|
|
107
|
+
assert response.status_code == 404
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_update_candidate(client, sample_candidate_data):
|
|
112
|
+
"""Test updating a candidate"""
|
|
113
|
+
create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
114
|
+
candidate_id = create_response.json()["id"]
|
|
115
|
+
|
|
116
|
+
update_data = {"name": "Jane Doe", "title": "Senior Developer"}
|
|
117
|
+
response = await client.put(f"/api/v1/candidates/{candidate_id}", json=update_data)
|
|
118
|
+
|
|
119
|
+
assert response.status_code == 200
|
|
120
|
+
data = response.json()
|
|
121
|
+
assert data["name"] == "Jane Doe"
|
|
122
|
+
assert data["title"] == "Senior Developer"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_update_candidate_not_found(client):
|
|
127
|
+
"""Test updating a non-existent candidate"""
|
|
128
|
+
fake_id = str(uuid.uuid4())
|
|
129
|
+
update_data = {"name": "Updated Name"}
|
|
130
|
+
response = await client.put(f"/api/v1/candidates/{fake_id}", json=update_data)
|
|
131
|
+
|
|
132
|
+
assert response.status_code == 404
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio
|
|
136
|
+
async def test_delete_candidate(client, sample_candidate_data):
|
|
137
|
+
"""Test deleting a candidate"""
|
|
138
|
+
create_response = await client.post("/api/v1/candidates", json=sample_candidate_data)
|
|
139
|
+
candidate_id = create_response.json()["id"]
|
|
140
|
+
|
|
141
|
+
response = await client.delete(f"/api/v1/candidates/{candidate_id}")
|
|
142
|
+
|
|
143
|
+
assert response.status_code == 200
|
|
144
|
+
data = response.json()
|
|
145
|
+
assert data["message"] == "Candidate deleted successfully"
|
|
146
|
+
|
|
147
|
+
# Verify it's deleted
|
|
148
|
+
get_response = await client.get(f"/api/v1/candidates/{candidate_id}")
|
|
149
|
+
assert get_response.status_code == 404
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_delete_candidate_not_found(client):
|
|
154
|
+
"""Test deleting a non-existent candidate"""
|
|
155
|
+
fake_id = str(uuid.uuid4())
|
|
156
|
+
response = await client.delete(f"/api/v1/candidates/{fake_id}")
|
|
157
|
+
|
|
158
|
+
assert response.status_code == 404
|