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/runner.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
from app.services.search import SearchService
|
|
3
|
+
from app.services.score import ScoreService
|
|
4
|
+
from app.services.pipeline import PipelineService
|
|
5
|
+
from app.services.email import EmailService
|
|
6
|
+
from app.repositories.candidate import CandidateRepository
|
|
7
|
+
from app.repositories.position import PositionRepository
|
|
8
|
+
from app.repositories.pipeline import PipelineRepository
|
|
9
|
+
from app.repositories.outreach_log import OutreachLogRepository
|
|
10
|
+
from app.repositories.agent_run import AgentRunRepository
|
|
11
|
+
from app.repositories.node_log import NodeLogRepository
|
|
12
|
+
from app.models.candidate import Candidate
|
|
13
|
+
from app.models.position import Position
|
|
14
|
+
from app.models.pipeline import Pipeline
|
|
15
|
+
from app.models.agent_run import AgentRun
|
|
16
|
+
from app.models.node_log import NodeLog
|
|
17
|
+
from app.schemas.agent_run import AgentRunCreate
|
|
18
|
+
from app.schemas.node_log import NodeLogCreate
|
|
19
|
+
from app.core.exceptions import DatabaseException
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
import uuid
|
|
22
|
+
import asyncio
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunnerService:
|
|
27
|
+
"""
|
|
28
|
+
Service to coordinate the entire recruiting loop: Search -> Dedup -> Score -> Pipeline -> Outreach -> Evaluate
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
search_service: SearchService,
|
|
34
|
+
score_service: ScoreService,
|
|
35
|
+
pipeline_service: PipelineService,
|
|
36
|
+
email_service: EmailService,
|
|
37
|
+
candidate_repo: CandidateRepository,
|
|
38
|
+
position_repo: PositionRepository,
|
|
39
|
+
pipeline_repo: PipelineRepository,
|
|
40
|
+
outreach_log_repo: OutreachLogRepository,
|
|
41
|
+
agent_run_repo: AgentRunRepository,
|
|
42
|
+
node_log_repo: NodeLogRepository
|
|
43
|
+
):
|
|
44
|
+
self.search_service = search_service
|
|
45
|
+
self.score_service = score_service
|
|
46
|
+
self.pipeline_service = pipeline_service
|
|
47
|
+
self.email_service = email_service
|
|
48
|
+
self.candidate_repo = candidate_repo
|
|
49
|
+
self.position_repo = position_repo
|
|
50
|
+
self.pipeline_repo = pipeline_repo
|
|
51
|
+
self.outreach_log_repo = outreach_log_repo
|
|
52
|
+
self.agent_run_repo = agent_run_repo
|
|
53
|
+
self.node_log_repo = node_log_repo
|
|
54
|
+
|
|
55
|
+
async def run_recruiting_loop(self, position_id: uuid.UUID) -> Dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Execute the full recruiting loop for a position
|
|
58
|
+
"""
|
|
59
|
+
start_time = time.time()
|
|
60
|
+
|
|
61
|
+
# Create an agent run record
|
|
62
|
+
agent_run_data = AgentRunCreate(
|
|
63
|
+
position_id=position_id,
|
|
64
|
+
started_at=datetime.utcnow(),
|
|
65
|
+
status="running"
|
|
66
|
+
)
|
|
67
|
+
agent_run = await self.agent_run_repo.create(agent_run_data)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Get position details
|
|
71
|
+
position = await self.position_repo.get_by_id(position_id)
|
|
72
|
+
if not position:
|
|
73
|
+
raise ValueError(f"Position {position_id} not found")
|
|
74
|
+
|
|
75
|
+
# Prepare results tracking
|
|
76
|
+
results = {
|
|
77
|
+
"candidates_found": 0,
|
|
78
|
+
"candidates_added": 0,
|
|
79
|
+
"emails_sent": 0,
|
|
80
|
+
"errors": []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Node 1: Search
|
|
84
|
+
search_start = time.time()
|
|
85
|
+
try:
|
|
86
|
+
# Generate search keywords based on position requirements
|
|
87
|
+
keywords = await self.search_service.generate_search_keywords(
|
|
88
|
+
position.title,
|
|
89
|
+
position.required_skills or [],
|
|
90
|
+
position.search_keywords or []
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Search for candidates
|
|
94
|
+
candidates = await self.search_service.search_candidates(
|
|
95
|
+
str(position_id),
|
|
96
|
+
keywords,
|
|
97
|
+
max_results=100
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
results["candidates_found"] = len(candidates)
|
|
101
|
+
results["candidates"] = candidates
|
|
102
|
+
|
|
103
|
+
# Log successful search
|
|
104
|
+
search_duration = time.time() - search_start
|
|
105
|
+
await self._log_node_result(agent_run.id, "search", "success", search_duration, {
|
|
106
|
+
"keywords_used": keywords,
|
|
107
|
+
"candidates_found": len(candidates)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
error_msg = f"Search failed: {str(e)}"
|
|
112
|
+
results["errors"].append(error_msg)
|
|
113
|
+
search_duration = time.time() - search_start
|
|
114
|
+
await self._log_node_result(agent_run.id, "search", "failed", search_duration, {
|
|
115
|
+
"error": error_msg
|
|
116
|
+
})
|
|
117
|
+
# Continue with the rest of the loop despite search error
|
|
118
|
+
|
|
119
|
+
# Node 2: Score (if candidates were found)
|
|
120
|
+
score_start = time.time()
|
|
121
|
+
try:
|
|
122
|
+
scored_candidates = []
|
|
123
|
+
if "candidates" in results:
|
|
124
|
+
for candidate in results["candidates"]:
|
|
125
|
+
# Calculate score for candidate
|
|
126
|
+
score_result = await self.score_service.calculate_score(
|
|
127
|
+
candidate,
|
|
128
|
+
position.required_skills or []
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Check if candidate should be added to pipeline
|
|
132
|
+
should_add = await self.score_service.should_add_to_pipeline(
|
|
133
|
+
score_result["total_score"]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if should_add:
|
|
137
|
+
# Create pipeline entry for candidate
|
|
138
|
+
pipeline_data = {
|
|
139
|
+
"position_id": position_id,
|
|
140
|
+
"candidate_id": candidate.id,
|
|
141
|
+
"score": score_result["total_score"],
|
|
142
|
+
"score_detail": str(score_result["detail"]),
|
|
143
|
+
"status": "discovered"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pipeline = await self.pipeline_service.create_pipeline(pipeline_data)
|
|
147
|
+
scored_candidates.append({
|
|
148
|
+
"candidate": candidate,
|
|
149
|
+
"pipeline": pipeline,
|
|
150
|
+
"score": score_result["total_score"]
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
results["scored_candidates"] = scored_candidates
|
|
154
|
+
|
|
155
|
+
# Log successful scoring
|
|
156
|
+
score_duration = time.time() - score_start
|
|
157
|
+
await self._log_node_result(agent_run.id, "score", "success", score_duration, {
|
|
158
|
+
"candidates_scored": len(results["scored_candidates"])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error_msg = f"Scoring failed: {str(e)}"
|
|
163
|
+
results["errors"].append(error_msg)
|
|
164
|
+
score_duration = time.time() - score_start
|
|
165
|
+
await self._log_node_result(agent_run.id, "score", "failed", score_duration, {
|
|
166
|
+
"error": error_msg
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
# Node 3: Outreach (send emails to top candidates)
|
|
170
|
+
outreach_start = time.time()
|
|
171
|
+
try:
|
|
172
|
+
emails_sent = 0
|
|
173
|
+
if "scored_candidates" in results:
|
|
174
|
+
# Sort candidates by score and send emails to top ones
|
|
175
|
+
sorted_candidates = sorted(
|
|
176
|
+
results["scored_candidates"],
|
|
177
|
+
key=lambda x: x["score"],
|
|
178
|
+
reverse=True
|
|
179
|
+
)[:10] # Top 10 candidates
|
|
180
|
+
|
|
181
|
+
for item in sorted_candidates:
|
|
182
|
+
candidate = item["candidate"]
|
|
183
|
+
pipeline = item["pipeline"]
|
|
184
|
+
|
|
185
|
+
# Generate and send email
|
|
186
|
+
email_body = self.email_service.generate_email_template(
|
|
187
|
+
candidate.name or candidate.github_login or "Candidate",
|
|
188
|
+
position.title,
|
|
189
|
+
position.company
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if candidate.email: # Only send if we have an email
|
|
193
|
+
try:
|
|
194
|
+
success = await self.email_service.send_email(
|
|
195
|
+
candidate.email,
|
|
196
|
+
f"Opportunity at {position.company}: {position.title}",
|
|
197
|
+
email_body,
|
|
198
|
+
pipeline.id
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if success:
|
|
202
|
+
emails_sent += 1
|
|
203
|
+
|
|
204
|
+
# Update pipeline status to contacted
|
|
205
|
+
await self.pipeline_service.update_pipeline_status(
|
|
206
|
+
pipeline.id,
|
|
207
|
+
"contacted"
|
|
208
|
+
)
|
|
209
|
+
except Exception as email_error:
|
|
210
|
+
error_msg = f"Email failed for candidate {candidate.id}: {str(email_error)}"
|
|
211
|
+
results["errors"].append(error_msg)
|
|
212
|
+
|
|
213
|
+
results["emails_sent"] = emails_sent
|
|
214
|
+
|
|
215
|
+
# Log successful outreach
|
|
216
|
+
outreach_duration = time.time() - outreach_start
|
|
217
|
+
await self._log_node_result(agent_run.id, "outreach", "success", outreach_duration, {
|
|
218
|
+
"emails_sent": emails_sent
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
error_msg = f"Outreach failed: {str(e)}"
|
|
223
|
+
results["errors"].append(error_msg)
|
|
224
|
+
outreach_duration = time.time() - outreach_start
|
|
225
|
+
await self._log_node_result(agent_run.id, "outreach", "failed", outreach_duration, {
|
|
226
|
+
"error": error_msg
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
# Update results
|
|
230
|
+
results["candidates_added"] = len(results.get("scored_candidates", []))
|
|
231
|
+
results["emails_sent"] = results.get("emails_sent", 0)
|
|
232
|
+
|
|
233
|
+
# Update agent run with final results
|
|
234
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
235
|
+
await self.agent_run_repo.update_completion(
|
|
236
|
+
agent_run.id,
|
|
237
|
+
"success",
|
|
238
|
+
duration_ms,
|
|
239
|
+
results["candidates_found"],
|
|
240
|
+
results["candidates_added"],
|
|
241
|
+
results["emails_sent"]
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"status": "completed",
|
|
246
|
+
"results": results,
|
|
247
|
+
"duration_ms": duration_ms
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
# Handle critical error in the loop
|
|
252
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
253
|
+
await self.agent_run_repo.update_completion(
|
|
254
|
+
agent_run.id,
|
|
255
|
+
"failed",
|
|
256
|
+
duration_ms,
|
|
257
|
+
0,
|
|
258
|
+
0,
|
|
259
|
+
0,
|
|
260
|
+
str(e)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
"status": "failed",
|
|
265
|
+
"error": str(e),
|
|
266
|
+
"duration_ms": duration_ms
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async def _log_node_result(
|
|
270
|
+
self,
|
|
271
|
+
run_id: uuid.UUID,
|
|
272
|
+
node_name: str,
|
|
273
|
+
status: str,
|
|
274
|
+
duration: float,
|
|
275
|
+
data: Dict[str, Any]
|
|
276
|
+
):
|
|
277
|
+
"""
|
|
278
|
+
Log the result of a specific node in the recruiting loop
|
|
279
|
+
"""
|
|
280
|
+
node_log_data = NodeLogCreate(
|
|
281
|
+
run_id=run_id,
|
|
282
|
+
node_name=node_name,
|
|
283
|
+
started_at=datetime.utcnow(),
|
|
284
|
+
finished_at=datetime.utcnow(),
|
|
285
|
+
duration_ms=int(duration * 1000), # Convert to milliseconds
|
|
286
|
+
status=status,
|
|
287
|
+
input=str(data.get("input", {})),
|
|
288
|
+
output=str(data.get("output", {})),
|
|
289
|
+
error=data.get("error", "")
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
await self.node_log_repo.create(node_log_data)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
2
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from app.repositories.position import PositionRepository
|
|
8
|
+
from app.repositories.scheduler_job import SchedulerJobRepository
|
|
9
|
+
from app.models.scheduler_job import SchedulerJob
|
|
10
|
+
from app.services.runner import RunnerService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchedulerService:
|
|
14
|
+
"""
|
|
15
|
+
Service for managing the scheduling of recruiting loops
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, position_repo: PositionRepository, job_repo: SchedulerJobRepository, runner_service: RunnerService):
|
|
19
|
+
self.position_repo = position_repo
|
|
20
|
+
self.job_repo = job_repo
|
|
21
|
+
self.runner_service = runner_service
|
|
22
|
+
self.scheduler = AsyncIOScheduler()
|
|
23
|
+
|
|
24
|
+
async def start(self):
|
|
25
|
+
"""
|
|
26
|
+
Start the scheduler
|
|
27
|
+
"""
|
|
28
|
+
if not self.scheduler.running:
|
|
29
|
+
self.scheduler.start()
|
|
30
|
+
|
|
31
|
+
# Schedule periodic check for active positions
|
|
32
|
+
self.scheduler.add_job(
|
|
33
|
+
self._check_and_schedule_positions,
|
|
34
|
+
trigger=IntervalTrigger(seconds=60), # Check every minute
|
|
35
|
+
id='position_checker',
|
|
36
|
+
name='Check positions for scheduling',
|
|
37
|
+
replace_existing=True
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def stop(self):
|
|
41
|
+
"""
|
|
42
|
+
Stop the scheduler
|
|
43
|
+
"""
|
|
44
|
+
if self.scheduler.running:
|
|
45
|
+
self.scheduler.shutdown()
|
|
46
|
+
|
|
47
|
+
async def schedule_position(self, position_id: uuid.UUID, interval_minutes: int = 60):
|
|
48
|
+
"""
|
|
49
|
+
Schedule a position for recruiting loops
|
|
50
|
+
"""
|
|
51
|
+
# Create or update scheduler job record
|
|
52
|
+
job_data = {
|
|
53
|
+
"position_id": position_id,
|
|
54
|
+
"enabled": True,
|
|
55
|
+
"interval_minutes": interval_minutes,
|
|
56
|
+
"next_run": datetime.utcnow() + timedelta(minutes=interval_minutes),
|
|
57
|
+
"status": "waiting"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Check if job already exists
|
|
61
|
+
existing_job = await self.job_repo.get_by_position_id(position_id)
|
|
62
|
+
if existing_job:
|
|
63
|
+
# Update existing job
|
|
64
|
+
await self.job_repo.update(existing_job.id, job_data)
|
|
65
|
+
else:
|
|
66
|
+
# Create new job
|
|
67
|
+
await self.job_repo.create(job_data)
|
|
68
|
+
|
|
69
|
+
# Add job to scheduler
|
|
70
|
+
self.scheduler.add_job(
|
|
71
|
+
self._run_position_loop,
|
|
72
|
+
trigger=IntervalTrigger(minutes=interval_minutes),
|
|
73
|
+
id=f'position_{position_id}',
|
|
74
|
+
name=f'Run recruiting loop for position {position_id}',
|
|
75
|
+
args=[position_id],
|
|
76
|
+
replace_existing=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def unschedule_position(self, position_id: uuid.UUID):
|
|
80
|
+
"""
|
|
81
|
+
Remove a position from the scheduler
|
|
82
|
+
"""
|
|
83
|
+
# Remove from scheduler
|
|
84
|
+
job_id = f'position_{position_id}'
|
|
85
|
+
if self.scheduler.get_job(job_id):
|
|
86
|
+
self.scheduler.remove_job(job_id)
|
|
87
|
+
|
|
88
|
+
# Update job record
|
|
89
|
+
existing_job = await self.job_repo.get_by_position_id(position_id)
|
|
90
|
+
if existing_job:
|
|
91
|
+
await self.job_repo.update_status(position_id, "paused")
|
|
92
|
+
|
|
93
|
+
async def _run_position_loop(self, position_id: uuid.UUID):
|
|
94
|
+
"""
|
|
95
|
+
Internal method to run the recruiting loop for a position
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
# Update job status to running
|
|
99
|
+
await self.job_repo.update_status(position_id, "running")
|
|
100
|
+
|
|
101
|
+
# Run the recruiting loop
|
|
102
|
+
await self.runner_service.run_recruiting_loop(position_id)
|
|
103
|
+
|
|
104
|
+
# Update job status to waiting
|
|
105
|
+
await self.job_repo.update_status(position_id, "waiting")
|
|
106
|
+
|
|
107
|
+
# Update last run time
|
|
108
|
+
await self.job_repo.update_last_run(position_id, datetime.utcnow())
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
# Update job status to error
|
|
112
|
+
await self.job_repo.update_status(position_id, "error")
|
|
113
|
+
print(f"Error running recruiting loop for position {position_id}: {str(e)}")
|
|
114
|
+
|
|
115
|
+
async def _check_and_schedule_positions(self):
|
|
116
|
+
"""
|
|
117
|
+
Internal method to periodically check for active positions and schedule them
|
|
118
|
+
"""
|
|
119
|
+
# Get all active positions
|
|
120
|
+
positions = await self.position_repo.get_all(status="active")
|
|
121
|
+
|
|
122
|
+
for position in positions:
|
|
123
|
+
# Check if position should be scheduled
|
|
124
|
+
if position.loop_enabled:
|
|
125
|
+
# Get existing job
|
|
126
|
+
existing_job = await self.job_repo.get_by_position_id(position.id)
|
|
127
|
+
|
|
128
|
+
if not existing_job or existing_job.enabled:
|
|
129
|
+
# Schedule the position if not already scheduled
|
|
130
|
+
await self.schedule_position(position.id, position.loop_interval)
|
|
131
|
+
|
|
132
|
+
async def get_job_status(self, position_id: uuid.UUID) -> Optional[Dict[str, Any]]:
|
|
133
|
+
"""
|
|
134
|
+
Get the status of a scheduled job
|
|
135
|
+
"""
|
|
136
|
+
job = await self.job_repo.get_by_position_id(position_id)
|
|
137
|
+
if not job:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
scheduler_job = self.scheduler.get_job(f'position_{position_id}')
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"position_id": job.position_id,
|
|
144
|
+
"enabled": job.enabled,
|
|
145
|
+
"interval_minutes": job.interval_minutes,
|
|
146
|
+
"next_run": job.next_run,
|
|
147
|
+
"last_run": job.last_run,
|
|
148
|
+
"total_runs": job.total_runs,
|
|
149
|
+
"status": job.status,
|
|
150
|
+
"scheduler_active": scheduler_job is not None
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async def pause_job(self, position_id: uuid.UUID):
|
|
154
|
+
"""
|
|
155
|
+
Pause a scheduled job
|
|
156
|
+
"""
|
|
157
|
+
await self.unschedule_position(position_id)
|
|
158
|
+
await self.job_repo.update_status(position_id, "paused")
|
|
159
|
+
|
|
160
|
+
async def resume_job(self, position_id: uuid.UUID):
|
|
161
|
+
"""
|
|
162
|
+
Resume a scheduled job
|
|
163
|
+
"""
|
|
164
|
+
position = await self.position_repo.get_by_id(position_id)
|
|
165
|
+
if position:
|
|
166
|
+
await self.schedule_position(position_id, position.loop_interval)
|
|
167
|
+
await self.job_repo.update_status(position_id, "waiting")
|
|
168
|
+
|
|
169
|
+
async def run_position_now(self, position_id: uuid.UUID):
|
|
170
|
+
"""
|
|
171
|
+
Run a position's recruiting loop immediately
|
|
172
|
+
"""
|
|
173
|
+
# Run the loop in the background
|
|
174
|
+
asyncio.create_task(self._run_position_loop(position_id))
|
app/services/score.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from typing import List, Dict, Any
|
|
2
|
+
from app.models.candidate import Candidate
|
|
3
|
+
from app.models.pipeline import Pipeline
|
|
4
|
+
from app.schemas.pipeline import PipelineUpdate
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
class ScoreService:
|
|
8
|
+
"""
|
|
9
|
+
Service for calculating scores for candidates based on various factors
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
# Weights for different scoring criteria
|
|
14
|
+
self.skill_weight = 0.5 # 50% of total score
|
|
15
|
+
self.activity_weight = 0.3 # 30% of total score
|
|
16
|
+
self.profile_weight = 0.2 # 20% of total score
|
|
17
|
+
|
|
18
|
+
async def calculate_score(self, candidate: Candidate, required_skills: List[str]) -> Dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Calculate a score for a candidate based on required skills, activity, and profile completeness
|
|
21
|
+
"""
|
|
22
|
+
# Calculate skill score (0-100)
|
|
23
|
+
skill_score = self._calculate_skill_score(candidate, required_skills)
|
|
24
|
+
|
|
25
|
+
# Calculate activity score (0-100)
|
|
26
|
+
activity_score = self._calculate_activity_score(candidate)
|
|
27
|
+
|
|
28
|
+
# Calculate profile score (0-100)
|
|
29
|
+
profile_score = self._calculate_profile_score(candidate)
|
|
30
|
+
|
|
31
|
+
# Calculate weighted total score
|
|
32
|
+
total_score = (
|
|
33
|
+
skill_score * self.skill_weight +
|
|
34
|
+
activity_score * self.activity_weight +
|
|
35
|
+
profile_score * self.profile_weight
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
score_detail = {
|
|
39
|
+
"skill_score": skill_score,
|
|
40
|
+
"activity_score": activity_score,
|
|
41
|
+
"profile_score": profile_score,
|
|
42
|
+
"weights": {
|
|
43
|
+
"skill": self.skill_weight,
|
|
44
|
+
"activity": self.activity_weight,
|
|
45
|
+
"profile": self.profile_weight
|
|
46
|
+
},
|
|
47
|
+
"breakdown": {
|
|
48
|
+
"skills_match": self._get_skills_match(candidate, required_skills)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"total_score": round(total_score, 2),
|
|
54
|
+
"detail": score_detail
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def _calculate_skill_score(self, candidate: Candidate, required_skills: List[str]) -> float:
|
|
58
|
+
"""
|
|
59
|
+
Calculate skill-based score
|
|
60
|
+
"""
|
|
61
|
+
if not required_skills or not candidate.skills:
|
|
62
|
+
return 0.0
|
|
63
|
+
|
|
64
|
+
# Parse skills from JSON string if needed
|
|
65
|
+
candidate_skills_list = candidate.skills if isinstance(candidate.skills, list) else []
|
|
66
|
+
if isinstance(candidate.skills, str):
|
|
67
|
+
try:
|
|
68
|
+
candidate_skills_list = json.loads(candidate.skills)
|
|
69
|
+
except:
|
|
70
|
+
candidate_skills_list = []
|
|
71
|
+
|
|
72
|
+
matched_skills = [skill for skill in required_skills if skill.lower() in [s.lower() for s in candidate_skills_list]]
|
|
73
|
+
score = (len(matched_skills) / len(required_skills)) * 100
|
|
74
|
+
|
|
75
|
+
return min(score, 100.0) # Cap at 100
|
|
76
|
+
|
|
77
|
+
def _calculate_activity_score(self, candidate: Candidate) -> float:
|
|
78
|
+
"""
|
|
79
|
+
Calculate activity-based score based on GitHub metrics
|
|
80
|
+
"""
|
|
81
|
+
# Followers contribute up to 40 points
|
|
82
|
+
followers_score = min((candidate.followers / 100) * 40, 40)
|
|
83
|
+
|
|
84
|
+
# Public repos contribute up to 30 points
|
|
85
|
+
repos_score = min((candidate.public_repos / 20) * 30, 30)
|
|
86
|
+
|
|
87
|
+
# Combined activity score (max 70)
|
|
88
|
+
activity_score = followers_score + repos_score
|
|
89
|
+
|
|
90
|
+
# Normalize to 0-100 scale
|
|
91
|
+
normalized_score = (activity_score / 70) * 100 if activity_score > 0 else 0
|
|
92
|
+
|
|
93
|
+
return min(normalized_score, 100.0)
|
|
94
|
+
|
|
95
|
+
def _calculate_profile_score(self, candidate: Candidate) -> float:
|
|
96
|
+
"""
|
|
97
|
+
Calculate profile completeness score
|
|
98
|
+
"""
|
|
99
|
+
score = 0
|
|
100
|
+
|
|
101
|
+
# Name present
|
|
102
|
+
if candidate.name:
|
|
103
|
+
score += 15
|
|
104
|
+
|
|
105
|
+
# Location present
|
|
106
|
+
if candidate.location:
|
|
107
|
+
score += 10
|
|
108
|
+
|
|
109
|
+
# Company present
|
|
110
|
+
if candidate.company:
|
|
111
|
+
score += 10
|
|
112
|
+
|
|
113
|
+
# Bio present
|
|
114
|
+
if candidate.bio:
|
|
115
|
+
score += 20
|
|
116
|
+
|
|
117
|
+
# Profile URL present
|
|
118
|
+
if candidate.profile_url:
|
|
119
|
+
score += 15
|
|
120
|
+
|
|
121
|
+
# Avatar URL present
|
|
122
|
+
if candidate.avatar_url:
|
|
123
|
+
score += 10
|
|
124
|
+
|
|
125
|
+
# Email present
|
|
126
|
+
if candidate.email:
|
|
127
|
+
score += 20
|
|
128
|
+
|
|
129
|
+
return min(score, 100.0)
|
|
130
|
+
|
|
131
|
+
def _get_skills_match(self, candidate: Candidate, required_skills: List[str]) -> Dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Get details about skills match for reporting
|
|
134
|
+
"""
|
|
135
|
+
candidate_skills_list = candidate.skills if isinstance(candidate.skills, list) else []
|
|
136
|
+
if isinstance(candidate.skills, str):
|
|
137
|
+
try:
|
|
138
|
+
candidate_skills_list = json.loads(candidate.skills)
|
|
139
|
+
except:
|
|
140
|
+
candidate_skills_list = []
|
|
141
|
+
|
|
142
|
+
matched_skills = [skill for skill in required_skills if skill.lower() in [s.lower() for s in candidate_skills_list]]
|
|
143
|
+
unmatched_skills = [skill for skill in required_skills if skill.lower() not in [s.lower() for s in candidate_skills_list]]
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"matched": matched_skills,
|
|
147
|
+
"unmatched": unmatched_skills,
|
|
148
|
+
"match_percentage": len(matched_skills) / len(required_skills) * 100 if required_skills else 0
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async def should_add_to_pipeline(self, score: float, threshold: float = 60.0) -> bool:
|
|
152
|
+
"""
|
|
153
|
+
Determine if a candidate should be added to the pipeline based on score
|
|
154
|
+
"""
|
|
155
|
+
return score >= threshold
|