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
@@ -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
File without changes
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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