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.
Files changed (84) hide show
  1. app/__init__.py +0 -0
  2. app/agents/__init__.py +0 -0
  3. app/agents/graph.py +40 -0
  4. app/agents/nodes.py +245 -0
  5. app/agents/state.py +19 -0
  6. app/api/__init__.py +0 -0
  7. app/api/candidates.py +49 -0
  8. app/api/dashboard.py +7 -0
  9. app/api/outreach.py +7 -0
  10. app/api/pipelines.py +58 -0
  11. app/api/positions.py +76 -0
  12. app/api/router.py +14 -0
  13. app/api/scheduler.py +7 -0
  14. app/api/skills.py +7 -0
  15. app/api/system.py +7 -0
  16. app/core/__init__.py +0 -0
  17. app/core/config.py +18 -0
  18. app/core/exception_handler.py +91 -0
  19. app/core/exceptions.py +33 -0
  20. app/core/logging.py +19 -0
  21. app/database/__init__.py +0 -0
  22. app/database/base.py +4 -0
  23. app/database/session.py +20 -0
  24. app/main.py +72 -0
  25. app/models/__init__.py +0 -0
  26. app/models/agent_run.py +18 -0
  27. app/models/candidate.py +28 -0
  28. app/models/node_log.py +18 -0
  29. app/models/outreach_log.py +16 -0
  30. app/models/pipeline.py +21 -0
  31. app/models/position.py +22 -0
  32. app/models/scheduler_job.py +16 -0
  33. app/models/skill.py +13 -0
  34. app/models/system_config.py +12 -0
  35. app/repositories/__init__.py +0 -0
  36. app/repositories/agent_run.py +74 -0
  37. app/repositories/candidate.py +84 -0
  38. app/repositories/node_log.py +57 -0
  39. app/repositories/outreach_log.py +60 -0
  40. app/repositories/pipeline.py +80 -0
  41. app/repositories/position.py +67 -0
  42. app/repositories/scheduler_job.py +74 -0
  43. app/schemas/__init__.py +0 -0
  44. app/schemas/agent_run.py +32 -0
  45. app/schemas/candidate.py +58 -0
  46. app/schemas/node_log.py +31 -0
  47. app/schemas/outreach_log.py +28 -0
  48. app/schemas/pipeline.py +34 -0
  49. app/schemas/position.py +49 -0
  50. app/schemas/scheduler_job.py +29 -0
  51. app/services/__init__.py +0 -0
  52. app/services/candidate.py +58 -0
  53. app/services/dashboard.py +230 -0
  54. app/services/email.py +116 -0
  55. app/services/health.py +105 -0
  56. app/services/pipeline.py +75 -0
  57. app/services/position.py +36 -0
  58. app/services/runner.py +292 -0
  59. app/services/scheduler.py +174 -0
  60. app/services/score.py +155 -0
  61. app/services/search.py +92 -0
  62. app/skills/base.py +30 -0
  63. app/skills/github.py +106 -0
  64. app/skills/registry.py +51 -0
  65. app/tests/__init__.py +3 -0
  66. app/tests/conftest.py +96 -0
  67. app/tests/generate_report.py +144 -0
  68. app/tests/test_candidates.py +158 -0
  69. app/tests/test_dashboard.py +27 -0
  70. app/tests/test_outreach.py +15 -0
  71. app/tests/test_pipelines.py +249 -0
  72. app/tests/test_positions.py +183 -0
  73. app/tests/test_scheduler.py +15 -0
  74. app/tests/test_skills.py +15 -0
  75. app/tests/test_system.py +35 -0
  76. app/utils/__init__.py +0 -0
  77. loop_agent_cli/__init__.py +5 -0
  78. loop_agent_cli/cli.py +728 -0
  79. loop_agent_cli/container.py +191 -0
  80. loop_agent_cli-0.1.0.dist-info/METADATA +202 -0
  81. loop_agent_cli-0.1.0.dist-info/RECORD +84 -0
  82. loop_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  83. loop_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  84. 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