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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
2
|
+
from sqlalchemy.future import select
|
|
3
|
+
from app.models.pipeline import Pipeline
|
|
4
|
+
from app.schemas.pipeline import PipelineCreate, PipelineUpdate
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
def _serialize_pipeline_data(data: dict) -> dict:
|
|
9
|
+
"""Convert UUID fields to strings for database storage."""
|
|
10
|
+
for field in ("position_id", "candidate_id"):
|
|
11
|
+
if field in data and data[field] is not None:
|
|
12
|
+
data[field] = str(data[field])
|
|
13
|
+
return data
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PipelineRepository:
|
|
17
|
+
def __init__(self, db_session: AsyncSession):
|
|
18
|
+
self.db_session = db_session
|
|
19
|
+
|
|
20
|
+
async def create(self, pipeline_data: PipelineCreate) -> Pipeline:
|
|
21
|
+
data = _serialize_pipeline_data(pipeline_data.model_dump())
|
|
22
|
+
db_pipeline = Pipeline(**data)
|
|
23
|
+
self.db_session.add(db_pipeline)
|
|
24
|
+
await self.db_session.commit()
|
|
25
|
+
await self.db_session.refresh(db_pipeline)
|
|
26
|
+
return db_pipeline
|
|
27
|
+
|
|
28
|
+
async def get_by_id(self, pipeline_id: uuid.UUID) -> Optional[Pipeline]:
|
|
29
|
+
stmt = select(Pipeline).where(Pipeline.id == str(pipeline_id))
|
|
30
|
+
result = await self.db_session.execute(stmt)
|
|
31
|
+
return result.scalar_one_or_none()
|
|
32
|
+
|
|
33
|
+
async def get_by_position_and_candidate(self, position_id: uuid.UUID, candidate_id: uuid.UUID) -> Optional[Pipeline]:
|
|
34
|
+
stmt = select(Pipeline).where(
|
|
35
|
+
(Pipeline.position_id == str(position_id)) & (Pipeline.candidate_id == str(candidate_id))
|
|
36
|
+
)
|
|
37
|
+
result = await self.db_session.execute(stmt)
|
|
38
|
+
return result.scalar_one_or_none()
|
|
39
|
+
|
|
40
|
+
async def get_by_position(self, position_id: uuid.UUID, skip: int = 0, limit: int = 100) -> List[Pipeline]:
|
|
41
|
+
stmt = select(Pipeline).where(Pipeline.position_id == str(position_id)).offset(skip).limit(limit)
|
|
42
|
+
result = await self.db_session.execute(stmt)
|
|
43
|
+
return result.scalars().all()
|
|
44
|
+
|
|
45
|
+
async def get_by_status(self, position_id: uuid.UUID, status: str) -> List[Pipeline]:
|
|
46
|
+
stmt = select(Pipeline).where(
|
|
47
|
+
(Pipeline.position_id == str(position_id)) & (Pipeline.status == status)
|
|
48
|
+
)
|
|
49
|
+
result = await self.db_session.execute(stmt)
|
|
50
|
+
return result.scalars().all()
|
|
51
|
+
|
|
52
|
+
async def get_all(self, skip: int = 0, limit: int = 100) -> List[Pipeline]:
|
|
53
|
+
stmt = select(Pipeline).offset(skip).limit(limit)
|
|
54
|
+
result = await self.db_session.execute(stmt)
|
|
55
|
+
return result.scalars().all()
|
|
56
|
+
|
|
57
|
+
async def update(self, pipeline_id: uuid.UUID, pipeline_data: PipelineUpdate) -> Optional[Pipeline]:
|
|
58
|
+
db_pipeline = await self.get_by_id(pipeline_id)
|
|
59
|
+
if db_pipeline:
|
|
60
|
+
for field, value in pipeline_data.model_dump(exclude_unset=True).items():
|
|
61
|
+
setattr(db_pipeline, field, value)
|
|
62
|
+
await self.db_session.commit()
|
|
63
|
+
await self.db_session.refresh(db_pipeline)
|
|
64
|
+
return db_pipeline
|
|
65
|
+
|
|
66
|
+
async def update_status(self, pipeline_id: uuid.UUID, status: str) -> Optional[Pipeline]:
|
|
67
|
+
db_pipeline = await self.get_by_id(pipeline_id)
|
|
68
|
+
if db_pipeline:
|
|
69
|
+
db_pipeline.status = status
|
|
70
|
+
await self.db_session.commit()
|
|
71
|
+
await self.db_session.refresh(db_pipeline)
|
|
72
|
+
return db_pipeline
|
|
73
|
+
|
|
74
|
+
async def delete(self, pipeline_id: uuid.UUID) -> bool:
|
|
75
|
+
db_pipeline = await self.get_by_id(pipeline_id)
|
|
76
|
+
if db_pipeline:
|
|
77
|
+
await self.db_session.delete(db_pipeline)
|
|
78
|
+
await self.db_session.commit()
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
2
|
+
from sqlalchemy.future import select
|
|
3
|
+
from app.models.position import Position
|
|
4
|
+
from app.schemas.position import PositionCreate, PositionUpdate
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _serialize_position_data(data: dict) -> dict:
|
|
11
|
+
"""Serialize list fields to JSON strings for database storage."""
|
|
12
|
+
for field in ("required_skills", "search_keywords"):
|
|
13
|
+
if field in data and data[field] is not None:
|
|
14
|
+
data[field] = json.dumps(data[field])
|
|
15
|
+
return data
|
|
16
|
+
|
|
17
|
+
class PositionRepository:
|
|
18
|
+
def __init__(self, db_session: AsyncSession):
|
|
19
|
+
self.db_session = db_session
|
|
20
|
+
|
|
21
|
+
async def create(self, position_data: PositionCreate) -> Position:
|
|
22
|
+
data = _serialize_position_data(position_data.model_dump())
|
|
23
|
+
db_position = Position(**data)
|
|
24
|
+
self.db_session.add(db_position)
|
|
25
|
+
await self.db_session.commit()
|
|
26
|
+
await self.db_session.refresh(db_position)
|
|
27
|
+
return db_position
|
|
28
|
+
|
|
29
|
+
async def get_by_id(self, position_id: uuid.UUID) -> Optional[Position]:
|
|
30
|
+
stmt = select(Position).where(Position.id == str(position_id))
|
|
31
|
+
result = await self.db_session.execute(stmt)
|
|
32
|
+
return result.scalar_one_or_none()
|
|
33
|
+
|
|
34
|
+
async def get_all(self, skip: int = 0, limit: int = 100, status: Optional[str] = None) -> List[Position]:
|
|
35
|
+
stmt = select(Position)
|
|
36
|
+
if status:
|
|
37
|
+
stmt = stmt.where(Position.status == status)
|
|
38
|
+
stmt = stmt.offset(skip).limit(limit)
|
|
39
|
+
result = await self.db_session.execute(stmt)
|
|
40
|
+
return result.scalars().all()
|
|
41
|
+
|
|
42
|
+
async def update(self, position_id: uuid.UUID, position_data: PositionUpdate) -> Optional[Position]:
|
|
43
|
+
db_position = await self.get_by_id(position_id)
|
|
44
|
+
if db_position:
|
|
45
|
+
update_data = _serialize_position_data(position_data.model_dump(exclude_unset=True))
|
|
46
|
+
for field, value in update_data.items():
|
|
47
|
+
setattr(db_position, field, value)
|
|
48
|
+
await self.db_session.commit()
|
|
49
|
+
await self.db_session.refresh(db_position)
|
|
50
|
+
return db_position
|
|
51
|
+
|
|
52
|
+
async def delete(self, position_id: uuid.UUID) -> bool:
|
|
53
|
+
db_position = await self.get_by_id(position_id)
|
|
54
|
+
if db_position:
|
|
55
|
+
await self.db_session.delete(db_position)
|
|
56
|
+
await self.db_session.commit()
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
async def update_status(self, position_id: uuid.UUID, status: str) -> Optional[Position]:
|
|
61
|
+
db_position = await self.get_by_id(position_id)
|
|
62
|
+
if db_position:
|
|
63
|
+
db_position.status = status
|
|
64
|
+
db_position.loop_enabled = (status == "active")
|
|
65
|
+
await self.db_session.commit()
|
|
66
|
+
await self.db_session.refresh(db_position)
|
|
67
|
+
return db_position
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
2
|
+
from sqlalchemy.future import select
|
|
3
|
+
from app.models.scheduler_job import SchedulerJob
|
|
4
|
+
from app.schemas.scheduler_job import SchedulerJobCreate, SchedulerJobUpdate
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
class SchedulerJobRepository:
|
|
10
|
+
def __init__(self, db_session: AsyncSession):
|
|
11
|
+
self.db_session = db_session
|
|
12
|
+
|
|
13
|
+
async def create(self, job_data: dict) -> SchedulerJob:
|
|
14
|
+
db_job = SchedulerJob(**job_data)
|
|
15
|
+
self.db_session.add(db_job)
|
|
16
|
+
await self.db_session.commit()
|
|
17
|
+
await self.db_session.refresh(db_job)
|
|
18
|
+
return db_job
|
|
19
|
+
|
|
20
|
+
async def get_by_id(self, job_id: uuid.UUID) -> Optional[SchedulerJob]:
|
|
21
|
+
stmt = select(SchedulerJob).where(SchedulerJob.id == job_id)
|
|
22
|
+
result = await self.db_session.execute(stmt)
|
|
23
|
+
return result.scalar_one_or_none()
|
|
24
|
+
|
|
25
|
+
async def get_by_position_id(self, position_id: uuid.UUID) -> Optional[SchedulerJob]:
|
|
26
|
+
stmt = select(SchedulerJob).where(SchedulerJob.position_id == position_id)
|
|
27
|
+
result = await self.db_session.execute(stmt)
|
|
28
|
+
return result.scalar_one_or_none()
|
|
29
|
+
|
|
30
|
+
async def get_all(self, skip: int = 0, limit: int = 100) -> List[SchedulerJob]:
|
|
31
|
+
stmt = select(SchedulerJob).offset(skip).limit(limit)
|
|
32
|
+
result = await self.db_session.execute(stmt)
|
|
33
|
+
return result.scalars().all()
|
|
34
|
+
|
|
35
|
+
async def update(self, job_id: uuid.UUID, job_data: dict) -> Optional[SchedulerJob]:
|
|
36
|
+
db_job = await self.get_by_id(job_id)
|
|
37
|
+
if db_job:
|
|
38
|
+
for field, value in job_data.items():
|
|
39
|
+
setattr(db_job, field, value)
|
|
40
|
+
await self.db_session.commit()
|
|
41
|
+
await self.db_session.refresh(db_job)
|
|
42
|
+
return db_job
|
|
43
|
+
|
|
44
|
+
async def update_status(self, position_id: uuid.UUID, status: str) -> Optional[SchedulerJob]:
|
|
45
|
+
db_job = await self.get_by_position_id(position_id)
|
|
46
|
+
if db_job:
|
|
47
|
+
db_job.status = status
|
|
48
|
+
await self.db_session.commit()
|
|
49
|
+
await self.db_session.refresh(db_job)
|
|
50
|
+
return db_job
|
|
51
|
+
|
|
52
|
+
async def update_last_run(self, position_id: uuid.UUID, last_run: datetime) -> Optional[SchedulerJob]:
|
|
53
|
+
db_job = await self.get_by_position_id(position_id)
|
|
54
|
+
if db_job:
|
|
55
|
+
db_job.last_run = last_run
|
|
56
|
+
await self.db_session.commit()
|
|
57
|
+
await self.db_session.refresh(db_job)
|
|
58
|
+
return db_job
|
|
59
|
+
|
|
60
|
+
async def increment_run_count(self, position_id: uuid.UUID) -> Optional[SchedulerJob]:
|
|
61
|
+
db_job = await self.get_by_position_id(position_id)
|
|
62
|
+
if db_job:
|
|
63
|
+
db_job.total_runs += 1
|
|
64
|
+
await self.db_session.commit()
|
|
65
|
+
await self.db_session.refresh(db_job)
|
|
66
|
+
return db_job
|
|
67
|
+
|
|
68
|
+
async def delete(self, job_id: uuid.UUID) -> bool:
|
|
69
|
+
db_job = await self.get_by_id(job_id)
|
|
70
|
+
if db_job:
|
|
71
|
+
await self.db_session.delete(db_job)
|
|
72
|
+
await self.db_session.commit()
|
|
73
|
+
return True
|
|
74
|
+
return False
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/agent_run.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class AgentRunBase(BaseModel):
|
|
7
|
+
position_id: uuid.UUID
|
|
8
|
+
candidates_found: Optional[int] = 0
|
|
9
|
+
candidates_added: Optional[int] = 0
|
|
10
|
+
emails_sent: Optional[int] = 0
|
|
11
|
+
status: Optional[str] = "running" # running, success, failed
|
|
12
|
+
error: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
class AgentRunCreate(AgentRunBase):
|
|
15
|
+
position_id: uuid.UUID
|
|
16
|
+
started_at: datetime
|
|
17
|
+
|
|
18
|
+
class AgentRunUpdate(BaseModel):
|
|
19
|
+
candidates_found: Optional[int] = None
|
|
20
|
+
candidates_added: Optional[int] = None
|
|
21
|
+
emails_sent: Optional[int] = None
|
|
22
|
+
status: Optional[str] = None
|
|
23
|
+
error: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
class AgentRun(AgentRunBase):
|
|
26
|
+
id: uuid.UUID
|
|
27
|
+
started_at: datetime
|
|
28
|
+
finished_at: Optional[datetime] = None
|
|
29
|
+
duration_ms: Optional[int] = None
|
|
30
|
+
|
|
31
|
+
class Config:
|
|
32
|
+
from_attributes = True
|
app/schemas/candidate.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pydantic import BaseModel, field_validator
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
class CandidateBase(BaseModel):
|
|
8
|
+
source: str
|
|
9
|
+
source_id: str
|
|
10
|
+
github_login: Optional[str] = None
|
|
11
|
+
name: Optional[str] = None
|
|
12
|
+
email: Optional[str] = None
|
|
13
|
+
location: Optional[str] = None
|
|
14
|
+
company: Optional[str] = None
|
|
15
|
+
title: Optional[str] = None
|
|
16
|
+
bio: Optional[str] = None
|
|
17
|
+
followers: Optional[int] = 0
|
|
18
|
+
public_repos: Optional[int] = 0
|
|
19
|
+
skills: Optional[List[str]] = None
|
|
20
|
+
profile_url: Optional[str] = None
|
|
21
|
+
avatar_url: Optional[str] = None
|
|
22
|
+
search_keywords: Optional[List[str]] = None
|
|
23
|
+
|
|
24
|
+
class CandidateCreate(CandidateBase):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class CandidateUpdate(BaseModel):
|
|
28
|
+
name: Optional[str] = None
|
|
29
|
+
email: Optional[str] = None
|
|
30
|
+
location: Optional[str] = None
|
|
31
|
+
company: Optional[str] = None
|
|
32
|
+
title: Optional[str] = None
|
|
33
|
+
bio: Optional[str] = None
|
|
34
|
+
followers: Optional[int] = None
|
|
35
|
+
public_repos: Optional[int] = None
|
|
36
|
+
skills: Optional[List[str]] = None
|
|
37
|
+
profile_url: Optional[str] = None
|
|
38
|
+
avatar_url: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
class Candidate(CandidateBase):
|
|
41
|
+
id: uuid.UUID
|
|
42
|
+
appearance_count: int = 1
|
|
43
|
+
source_weight: float = 1.0
|
|
44
|
+
created_at: datetime
|
|
45
|
+
updated_at: datetime
|
|
46
|
+
|
|
47
|
+
@field_validator('skills', 'search_keywords', mode='before')
|
|
48
|
+
@classmethod
|
|
49
|
+
def deserialize_json_fields(cls, v):
|
|
50
|
+
if isinstance(v, str):
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(v)
|
|
53
|
+
except (json.JSONDecodeError, TypeError):
|
|
54
|
+
return v
|
|
55
|
+
return v
|
|
56
|
+
|
|
57
|
+
class Config:
|
|
58
|
+
from_attributes = True
|
app/schemas/node_log.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class NodeLogBase(BaseModel):
|
|
7
|
+
run_id: uuid.UUID
|
|
8
|
+
node_name: str # search, dedup, score, outreach, evaluate
|
|
9
|
+
status: Optional[str] = None # success, failed
|
|
10
|
+
input: Optional[str] = None # JSON string of input data
|
|
11
|
+
output: Optional[str] = None # JSON string of output data
|
|
12
|
+
error: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
class NodeLogCreate(NodeLogBase):
|
|
15
|
+
run_id: uuid.UUID
|
|
16
|
+
node_name: str
|
|
17
|
+
|
|
18
|
+
class NodeLogUpdate(BaseModel):
|
|
19
|
+
status: Optional[str] = None
|
|
20
|
+
input: Optional[str] = None
|
|
21
|
+
output: Optional[str] = None
|
|
22
|
+
error: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
class NodeLog(NodeLogBase):
|
|
25
|
+
id: uuid.UUID
|
|
26
|
+
started_at: datetime
|
|
27
|
+
finished_at: Optional[datetime] = None
|
|
28
|
+
duration_ms: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
class Config:
|
|
31
|
+
from_attributes = True
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class OutreachLogBase(BaseModel):
|
|
7
|
+
pipeline_id: uuid.UUID
|
|
8
|
+
subject: Optional[str] = None
|
|
9
|
+
body: Optional[str] = None
|
|
10
|
+
status: Optional[str] = "pending" # pending, sent, failed
|
|
11
|
+
error: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
class OutreachLogCreate(OutreachLogBase):
|
|
14
|
+
pipeline_id: uuid.UUID
|
|
15
|
+
subject: str
|
|
16
|
+
body: str
|
|
17
|
+
|
|
18
|
+
class OutreachLogUpdate(BaseModel):
|
|
19
|
+
status: Optional[str] = None
|
|
20
|
+
error: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
class OutreachLog(OutreachLogBase):
|
|
23
|
+
id: uuid.UUID
|
|
24
|
+
sent_at: Optional[datetime] = None
|
|
25
|
+
created_at: datetime
|
|
26
|
+
|
|
27
|
+
class Config:
|
|
28
|
+
from_attributes = True
|
app/schemas/pipeline.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class PipelineBase(BaseModel):
|
|
7
|
+
position_id: uuid.UUID
|
|
8
|
+
candidate_id: uuid.UUID
|
|
9
|
+
status: Optional[str] = "discovered"
|
|
10
|
+
score: Optional[float] = 0.0
|
|
11
|
+
contact_count: Optional[int] = 0
|
|
12
|
+
candidate_interest: Optional[str] = None
|
|
13
|
+
notes: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
class PipelineCreate(PipelineBase):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class PipelineUpdate(BaseModel):
|
|
19
|
+
status: Optional[str] = None
|
|
20
|
+
score: Optional[float] = None
|
|
21
|
+
contact_count: Optional[int] = None
|
|
22
|
+
candidate_interest: Optional[str] = None
|
|
23
|
+
notes: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
class Pipeline(PipelineBase):
|
|
26
|
+
id: uuid.UUID
|
|
27
|
+
score_detail: Optional[str] = None
|
|
28
|
+
last_contacted_at: Optional[datetime] = None
|
|
29
|
+
next_followup_at: Optional[datetime] = None
|
|
30
|
+
created_at: datetime
|
|
31
|
+
updated_at: datetime
|
|
32
|
+
|
|
33
|
+
class Config:
|
|
34
|
+
from_attributes = True
|
app/schemas/position.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pydantic import BaseModel, field_validator
|
|
2
|
+
from typing import Optional, List, Union
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
class PositionBase(BaseModel):
|
|
8
|
+
title: str
|
|
9
|
+
company: str
|
|
10
|
+
description: Optional[str] = None
|
|
11
|
+
location: Optional[str] = None
|
|
12
|
+
required_skills: Optional[List[str]] = None
|
|
13
|
+
search_keywords: Optional[List[str]] = None
|
|
14
|
+
loop_interval: Optional[int] = 60
|
|
15
|
+
|
|
16
|
+
class PositionCreate(PositionBase):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class PositionUpdate(BaseModel):
|
|
20
|
+
title: Optional[str] = None
|
|
21
|
+
company: Optional[str] = None
|
|
22
|
+
description: Optional[str] = None
|
|
23
|
+
location: Optional[str] = None
|
|
24
|
+
required_skills: Optional[List[str]] = None
|
|
25
|
+
search_keywords: Optional[List[str]] = None
|
|
26
|
+
loop_interval: Optional[int] = None
|
|
27
|
+
status: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
class Position(PositionBase):
|
|
30
|
+
id: uuid.UUID
|
|
31
|
+
status: str = "active"
|
|
32
|
+
loop_enabled: bool = True
|
|
33
|
+
last_loop_at: Optional[datetime] = None
|
|
34
|
+
next_loop_at: Optional[datetime] = None
|
|
35
|
+
created_at: datetime
|
|
36
|
+
updated_at: datetime
|
|
37
|
+
|
|
38
|
+
@field_validator('required_skills', 'search_keywords', mode='before')
|
|
39
|
+
@classmethod
|
|
40
|
+
def deserialize_json_fields(cls, v):
|
|
41
|
+
if isinstance(v, str):
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(v)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
return v
|
|
46
|
+
return v
|
|
47
|
+
|
|
48
|
+
class Config:
|
|
49
|
+
from_attributes = True
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class SchedulerJobBase(BaseModel):
|
|
7
|
+
position_id: uuid.UUID
|
|
8
|
+
enabled: Optional[bool] = True
|
|
9
|
+
interval_minutes: Optional[int] = 60
|
|
10
|
+
status: Optional[str] = "waiting" # waiting, running, paused, error
|
|
11
|
+
|
|
12
|
+
class SchedulerJobCreate(SchedulerJobBase):
|
|
13
|
+
position_id: uuid.UUID
|
|
14
|
+
|
|
15
|
+
class SchedulerJobUpdate(BaseModel):
|
|
16
|
+
enabled: Optional[bool] = None
|
|
17
|
+
interval_minutes: Optional[int] = None
|
|
18
|
+
status: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
class SchedulerJob(SchedulerJobBase):
|
|
21
|
+
id: uuid.UUID
|
|
22
|
+
next_run: Optional[datetime] = None
|
|
23
|
+
last_run: Optional[datetime] = None
|
|
24
|
+
total_runs: int = 0
|
|
25
|
+
created_at: datetime
|
|
26
|
+
updated_at: datetime
|
|
27
|
+
|
|
28
|
+
class Config:
|
|
29
|
+
from_attributes = True
|
app/services/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from app.repositories.candidate import CandidateRepository
|
|
3
|
+
from app.schemas.candidate import CandidateCreate, CandidateUpdate, Candidate
|
|
4
|
+
from app.core.exceptions import CandidateNotFoundException
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
class CandidateService:
|
|
8
|
+
def __init__(self, candidate_repo: CandidateRepository):
|
|
9
|
+
self.candidate_repo = candidate_repo
|
|
10
|
+
|
|
11
|
+
async def create_candidate(self, candidate_data: CandidateCreate) -> Candidate:
|
|
12
|
+
# Check if candidate already exists by source and source_id
|
|
13
|
+
existing_candidate = await self.candidate_repo.get_by_source_id(
|
|
14
|
+
candidate_data.source,
|
|
15
|
+
candidate_data.source_id
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if existing_candidate:
|
|
19
|
+
# Increment appearance count and return existing candidate
|
|
20
|
+
return await self.candidate_repo.increment_appearance_count(
|
|
21
|
+
candidate_data.source,
|
|
22
|
+
candidate_data.source_id
|
|
23
|
+
)
|
|
24
|
+
else:
|
|
25
|
+
# Create new candidate
|
|
26
|
+
return await self.candidate_repo.create(candidate_data)
|
|
27
|
+
|
|
28
|
+
async def get_candidate_by_id(self, candidate_id: uuid.UUID) -> Candidate:
|
|
29
|
+
candidate = await self.candidate_repo.get_by_id(candidate_id)
|
|
30
|
+
if not candidate:
|
|
31
|
+
raise CandidateNotFoundException(f"Candidate with id {candidate_id} not found")
|
|
32
|
+
return candidate
|
|
33
|
+
|
|
34
|
+
async def get_candidate_by_source_id(self, source: str, source_id: str) -> Optional[Candidate]:
|
|
35
|
+
return await self.candidate_repo.get_by_source_id(source, source_id)
|
|
36
|
+
|
|
37
|
+
async def get_all_candidates(self, skip: int = 0, limit: int = 100, keyword: Optional[str] = None) -> List[Candidate]:
|
|
38
|
+
return await self.candidate_repo.get_all(skip, limit, keyword)
|
|
39
|
+
|
|
40
|
+
async def update_candidate(self, candidate_id: uuid.UUID, candidate_data: CandidateUpdate) -> Optional[Candidate]:
|
|
41
|
+
return await self.candidate_repo.update(candidate_id, candidate_data)
|
|
42
|
+
|
|
43
|
+
async def delete_candidate(self, candidate_id: uuid.UUID) -> bool:
|
|
44
|
+
return await self.candidate_repo.delete(candidate_id)
|
|
45
|
+
|
|
46
|
+
async def deduplicate_candidates(self, source: str, source_ids: List[str]) -> List[Candidate]:
|
|
47
|
+
"""
|
|
48
|
+
Find existing candidates and return a list of new candidates to be created
|
|
49
|
+
"""
|
|
50
|
+
new_candidates = []
|
|
51
|
+
for source_id in source_ids:
|
|
52
|
+
existing = await self.candidate_repo.get_by_source_id(source, source_id)
|
|
53
|
+
if not existing:
|
|
54
|
+
new_candidates.append(source_id)
|
|
55
|
+
else:
|
|
56
|
+
# Increment appearance count for existing candidate
|
|
57
|
+
await self.candidate_repo.increment_appearance_count(source, source_id)
|
|
58
|
+
return new_candidates
|