openrecruiter 0.1.0__tar.gz
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.
- openrecruiter-0.1.0/.gitignore +52 -0
- openrecruiter-0.1.0/PKG-INFO +175 -0
- openrecruiter-0.1.0/README.md +162 -0
- openrecruiter-0.1.0/openrecruiter/__init__.py +72 -0
- openrecruiter-0.1.0/openrecruiter/agent.py +237 -0
- openrecruiter-0.1.0/openrecruiter/client.py +280 -0
- openrecruiter-0.1.0/openrecruiter/config.py +66 -0
- openrecruiter-0.1.0/openrecruiter/context.py +130 -0
- openrecruiter-0.1.0/openrecruiter/events.py +84 -0
- openrecruiter-0.1.0/openrecruiter/prompts.py +79 -0
- openrecruiter-0.1.0/openrecruiter/providers/__init__.py +5 -0
- openrecruiter-0.1.0/openrecruiter/providers/llm.py +209 -0
- openrecruiter-0.1.0/openrecruiter/ranking/__init__.py +8 -0
- openrecruiter-0.1.0/openrecruiter/ranking/api.py +84 -0
- openrecruiter-0.1.0/openrecruiter/ranking/base.py +44 -0
- openrecruiter-0.1.0/openrecruiter/ranking/embedding.py +39 -0
- openrecruiter-0.1.0/openrecruiter/ranking/two_stage.py +56 -0
- openrecruiter-0.1.0/openrecruiter/store/__init__.py +13 -0
- openrecruiter-0.1.0/openrecruiter/store/base.py +97 -0
- openrecruiter-0.1.0/openrecruiter/store/sqlite.py +244 -0
- openrecruiter-0.1.0/openrecruiter/store/vector.py +169 -0
- openrecruiter-0.1.0/openrecruiter/tools/__init__.py +6 -0
- openrecruiter-0.1.0/openrecruiter/tools/base.py +155 -0
- openrecruiter-0.1.0/openrecruiter/tools/recruiting.py +217 -0
- openrecruiter-0.1.0/openrecruiter/types.py +133 -0
- openrecruiter-0.1.0/pyproject.toml +29 -0
- openrecruiter-0.1.0/tests/conftest.py +112 -0
- openrecruiter-0.1.0/tests/test_agent.py +273 -0
- openrecruiter-0.1.0/tests/test_client.py +270 -0
- openrecruiter-0.1.0/tests/test_context.py +104 -0
- openrecruiter-0.1.0/tests/test_llm.py +224 -0
- openrecruiter-0.1.0/tests/test_ranking.py +170 -0
- openrecruiter-0.1.0/tests/test_store.py +105 -0
- openrecruiter-0.1.0/tests/test_tools.py +90 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
.eggs/
|
|
7
|
+
*.egg
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Node
|
|
12
|
+
node_modules/
|
|
13
|
+
product/frontend/dist/
|
|
14
|
+
product/electron/dist/
|
|
15
|
+
|
|
16
|
+
# Database
|
|
17
|
+
*.db
|
|
18
|
+
*.sqlite
|
|
19
|
+
chroma_data/
|
|
20
|
+
|
|
21
|
+
# Environment
|
|
22
|
+
.env
|
|
23
|
+
|
|
24
|
+
# IDE
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
|
|
28
|
+
# Cache
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.ruff_cache/
|
|
31
|
+
.mypy_cache/
|
|
32
|
+
|
|
33
|
+
# OS
|
|
34
|
+
.DS_Store
|
|
35
|
+
Thumbs.db
|
|
36
|
+
|
|
37
|
+
# Credentials
|
|
38
|
+
credentials.json
|
|
39
|
+
token.json
|
|
40
|
+
|
|
41
|
+
# Uploads (resumes)
|
|
42
|
+
product/backend/uploads/
|
|
43
|
+
|
|
44
|
+
# Build artifacts
|
|
45
|
+
*.tsbuildinfo
|
|
46
|
+
|
|
47
|
+
# Electron build
|
|
48
|
+
release/
|
|
49
|
+
product/release/
|
|
50
|
+
|
|
51
|
+
# Python package builds
|
|
52
|
+
dist/
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openrecruiter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The recruiting engine behind Open Recruiter — parse, retrieve, rank, and an agent over tools.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: agent,ats,candidate-search,llm,ranking,recruiting
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: chromadb>=0.5
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: litellm>=1.60
|
|
11
|
+
Requires-Dist: pydantic>=2.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# openrecruiter
|
|
15
|
+
|
|
16
|
+
The recruiting engine behind [Open Recruiter](https://github.com/miao4ai/open_recruiter):
|
|
17
|
+
parse resumes and job descriptions, retrieve and rank candidates, draft outreach, and run an
|
|
18
|
+
agent that does all of it through tools.
|
|
19
|
+
|
|
20
|
+
The desktop app is a consumer of this package, so everything shipped here is exercised by a
|
|
21
|
+
real application rather than only by its own tests.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Straight from the repository:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install "git+https://github.com/miao4ai/open_recruiter.git#subdirectory=sdk/core"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or a released wheel, from the [Releases](https://github.com/miao4ai/open_recruiter/releases)
|
|
32
|
+
page:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install https://github.com/miao4ai/open_recruiter/releases/download/sdk-core-v0.1.0/openrecruiter-0.1.0-py3-none-any.whl
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> Not on PyPI yet, so plain `pip install openrecruiter` will not find it. GitHub Packages has
|
|
39
|
+
> no Python registry, which is why the wheel is attached to a Release rather than appearing in
|
|
40
|
+
> the repository's Packages panel.
|
|
41
|
+
|
|
42
|
+
No local model is downloaded, at import or at runtime. Embeddings are an API call and chat is
|
|
43
|
+
a hosted provider, so it runs on CPU, on macOS, and in a container with no GPU — 97 packages
|
|
44
|
+
installed, none of them a training stack.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from openrecruiter import Recruiter
|
|
50
|
+
|
|
51
|
+
r = Recruiter(anthropic_api_key="sk-ant-...", voyage_api_key="pa-...")
|
|
52
|
+
|
|
53
|
+
job = r.add_job(open("jd.txt").read()) # parsed into title, skills, requirements
|
|
54
|
+
r.add_candidate(open("resume.txt").read()) # parsed into a structured profile
|
|
55
|
+
|
|
56
|
+
for match in r.rank(job.id, top_k=10):
|
|
57
|
+
print(f"{match.score:.2f} {match.candidate_id} {match.reasoning}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Without a Voyage key, retrieval is disabled and ranking falls back to the LLM — the package
|
|
61
|
+
still works, it just reads every candidate instead of shortlisting first.
|
|
62
|
+
|
|
63
|
+
## The agent
|
|
64
|
+
|
|
65
|
+
The model is given real tools and decides what to call and when it is done, so one request
|
|
66
|
+
can span several steps: rank a job, read the result, then draft the emails.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
for event in r.chat("who are the three strongest fits for the CUDA role, and draft an intro to each"):
|
|
70
|
+
match event:
|
|
71
|
+
case TextDelta(): print(event.text, end="", flush=True)
|
|
72
|
+
case ToolCall(): print(f"\n[{event.name}]")
|
|
73
|
+
case ApprovalRequired(): ... # a gated tool is waiting for a human
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Events are `TextDelta`, `ToolCall`, `ToolResult`, `ApprovalRequired`, and `Finished`. Text
|
|
77
|
+
streams as it is generated; tool calls are only emitted once their arguments are complete.
|
|
78
|
+
|
|
79
|
+
For a one-liner, `r.ask("...")` returns just the final text.
|
|
80
|
+
|
|
81
|
+
### Approval gates
|
|
82
|
+
|
|
83
|
+
A tool marked `requires_approval` stops the run rather than acting. Anything the model
|
|
84
|
+
queued behind it is held too, so the gate cannot be stepped around:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
events = list(r.chat("email the top candidate"))
|
|
88
|
+
if agent.pending: # serialisable — store it, decide later
|
|
89
|
+
list(agent.resume(agent.pending, approved=True))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Your own tools
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from openrecruiter import Tool
|
|
96
|
+
|
|
97
|
+
check_calendar = Tool(
|
|
98
|
+
name="check_calendar",
|
|
99
|
+
description="Look at the recruiter's availability this week",
|
|
100
|
+
parameters={"type": "object", "properties": {"days": {"type": "integer"}}},
|
|
101
|
+
fn=lambda days=7: my_calendar.free_slots(days),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
r = Recruiter(anthropic_api_key="...", extra_tools=[check_calendar])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Ranking
|
|
108
|
+
|
|
109
|
+
Ranking is the main extension point. One interface, several backends:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Ranker
|
|
113
|
+
├── EmbeddingRanker vector similarity — the default, cheap enough for the whole pool
|
|
114
|
+
├── APIRanker an LLM scores each candidate and explains itself
|
|
115
|
+
├── TwoStageRanker retrieve with one, rerank the shortlist with the other
|
|
116
|
+
└── your own implement rank(job, candidates, top_k) and pass it in
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`TwoStageRanker` is the shape the ranking research targets — the first stage optimises
|
|
120
|
+
recall, the second optimises relevance over a few hundred candidates:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from openrecruiter import APIRanker, EmbeddingRanker, TwoStageRanker
|
|
124
|
+
|
|
125
|
+
r.ranker = TwoStageRanker(
|
|
126
|
+
EmbeddingRanker(r.index),
|
|
127
|
+
APIRanker(r.llm),
|
|
128
|
+
shortlist=200,
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Backends that need a local model live in their own distributions — `recruitgpt` for the
|
|
133
|
+
distilled ranker, `openrecruiter-fairness` for bias-aware reranking — so nothing heavy
|
|
134
|
+
reaches this install. Both must lazy-load: no download until a user selects that backend.
|
|
135
|
+
|
|
136
|
+
## Bringing your own storage
|
|
137
|
+
|
|
138
|
+
`Store` and `VectorIndex` are protocols. Implement them over a database you already have and
|
|
139
|
+
nothing above the storage layer changes — that is how the desktop app keeps its existing
|
|
140
|
+
schema while running on this package.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class MyStore:
|
|
144
|
+
def add_job(self, job): ...
|
|
145
|
+
def get_job(self, job_id): ...
|
|
146
|
+
def list_jobs(self, limit=100): ...
|
|
147
|
+
# ... six more, all in openrecruiter.store.base
|
|
148
|
+
|
|
149
|
+
r = Recruiter(config, store=MyStore())
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The default `SQLiteStore` is a working reference in four tables.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
cd sdk/core
|
|
158
|
+
uv sync
|
|
159
|
+
uv run pytest # no network calls: the LLM is faked end to end
|
|
160
|
+
uv build --out-dir dist
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Releasing: bump `version` in `pyproject.toml`, then push a matching tag.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
git tag sdk-core-v0.1.1 && git push origin sdk-core-v0.1.1
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
CI checks the tag against the version, runs the tests, builds, installs the wheel in a clean
|
|
170
|
+
environment and imports it, verifies no training stack came along, and attaches the artifacts
|
|
171
|
+
to a Release.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# openrecruiter
|
|
2
|
+
|
|
3
|
+
The recruiting engine behind [Open Recruiter](https://github.com/miao4ai/open_recruiter):
|
|
4
|
+
parse resumes and job descriptions, retrieve and rank candidates, draft outreach, and run an
|
|
5
|
+
agent that does all of it through tools.
|
|
6
|
+
|
|
7
|
+
The desktop app is a consumer of this package, so everything shipped here is exercised by a
|
|
8
|
+
real application rather than only by its own tests.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Straight from the repository:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install "git+https://github.com/miao4ai/open_recruiter.git#subdirectory=sdk/core"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or a released wheel, from the [Releases](https://github.com/miao4ai/open_recruiter/releases)
|
|
19
|
+
page:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install https://github.com/miao4ai/open_recruiter/releases/download/sdk-core-v0.1.0/openrecruiter-0.1.0-py3-none-any.whl
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> Not on PyPI yet, so plain `pip install openrecruiter` will not find it. GitHub Packages has
|
|
26
|
+
> no Python registry, which is why the wheel is attached to a Release rather than appearing in
|
|
27
|
+
> the repository's Packages panel.
|
|
28
|
+
|
|
29
|
+
No local model is downloaded, at import or at runtime. Embeddings are an API call and chat is
|
|
30
|
+
a hosted provider, so it runs on CPU, on macOS, and in a container with no GPU — 97 packages
|
|
31
|
+
installed, none of them a training stack.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from openrecruiter import Recruiter
|
|
37
|
+
|
|
38
|
+
r = Recruiter(anthropic_api_key="sk-ant-...", voyage_api_key="pa-...")
|
|
39
|
+
|
|
40
|
+
job = r.add_job(open("jd.txt").read()) # parsed into title, skills, requirements
|
|
41
|
+
r.add_candidate(open("resume.txt").read()) # parsed into a structured profile
|
|
42
|
+
|
|
43
|
+
for match in r.rank(job.id, top_k=10):
|
|
44
|
+
print(f"{match.score:.2f} {match.candidate_id} {match.reasoning}")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without a Voyage key, retrieval is disabled and ranking falls back to the LLM — the package
|
|
48
|
+
still works, it just reads every candidate instead of shortlisting first.
|
|
49
|
+
|
|
50
|
+
## The agent
|
|
51
|
+
|
|
52
|
+
The model is given real tools and decides what to call and when it is done, so one request
|
|
53
|
+
can span several steps: rank a job, read the result, then draft the emails.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
for event in r.chat("who are the three strongest fits for the CUDA role, and draft an intro to each"):
|
|
57
|
+
match event:
|
|
58
|
+
case TextDelta(): print(event.text, end="", flush=True)
|
|
59
|
+
case ToolCall(): print(f"\n[{event.name}]")
|
|
60
|
+
case ApprovalRequired(): ... # a gated tool is waiting for a human
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Events are `TextDelta`, `ToolCall`, `ToolResult`, `ApprovalRequired`, and `Finished`. Text
|
|
64
|
+
streams as it is generated; tool calls are only emitted once their arguments are complete.
|
|
65
|
+
|
|
66
|
+
For a one-liner, `r.ask("...")` returns just the final text.
|
|
67
|
+
|
|
68
|
+
### Approval gates
|
|
69
|
+
|
|
70
|
+
A tool marked `requires_approval` stops the run rather than acting. Anything the model
|
|
71
|
+
queued behind it is held too, so the gate cannot be stepped around:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
events = list(r.chat("email the top candidate"))
|
|
75
|
+
if agent.pending: # serialisable — store it, decide later
|
|
76
|
+
list(agent.resume(agent.pending, approved=True))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Your own tools
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from openrecruiter import Tool
|
|
83
|
+
|
|
84
|
+
check_calendar = Tool(
|
|
85
|
+
name="check_calendar",
|
|
86
|
+
description="Look at the recruiter's availability this week",
|
|
87
|
+
parameters={"type": "object", "properties": {"days": {"type": "integer"}}},
|
|
88
|
+
fn=lambda days=7: my_calendar.free_slots(days),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
r = Recruiter(anthropic_api_key="...", extra_tools=[check_calendar])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Ranking
|
|
95
|
+
|
|
96
|
+
Ranking is the main extension point. One interface, several backends:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
Ranker
|
|
100
|
+
├── EmbeddingRanker vector similarity — the default, cheap enough for the whole pool
|
|
101
|
+
├── APIRanker an LLM scores each candidate and explains itself
|
|
102
|
+
├── TwoStageRanker retrieve with one, rerank the shortlist with the other
|
|
103
|
+
└── your own implement rank(job, candidates, top_k) and pass it in
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`TwoStageRanker` is the shape the ranking research targets — the first stage optimises
|
|
107
|
+
recall, the second optimises relevance over a few hundred candidates:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from openrecruiter import APIRanker, EmbeddingRanker, TwoStageRanker
|
|
111
|
+
|
|
112
|
+
r.ranker = TwoStageRanker(
|
|
113
|
+
EmbeddingRanker(r.index),
|
|
114
|
+
APIRanker(r.llm),
|
|
115
|
+
shortlist=200,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Backends that need a local model live in their own distributions — `recruitgpt` for the
|
|
120
|
+
distilled ranker, `openrecruiter-fairness` for bias-aware reranking — so nothing heavy
|
|
121
|
+
reaches this install. Both must lazy-load: no download until a user selects that backend.
|
|
122
|
+
|
|
123
|
+
## Bringing your own storage
|
|
124
|
+
|
|
125
|
+
`Store` and `VectorIndex` are protocols. Implement them over a database you already have and
|
|
126
|
+
nothing above the storage layer changes — that is how the desktop app keeps its existing
|
|
127
|
+
schema while running on this package.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
class MyStore:
|
|
131
|
+
def add_job(self, job): ...
|
|
132
|
+
def get_job(self, job_id): ...
|
|
133
|
+
def list_jobs(self, limit=100): ...
|
|
134
|
+
# ... six more, all in openrecruiter.store.base
|
|
135
|
+
|
|
136
|
+
r = Recruiter(config, store=MyStore())
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The default `SQLiteStore` is a working reference in four tables.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd sdk/core
|
|
145
|
+
uv sync
|
|
146
|
+
uv run pytest # no network calls: the LLM is faked end to end
|
|
147
|
+
uv build --out-dir dist
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Releasing: bump `version` in `pyproject.toml`, then push a matching tag.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
git tag sdk-core-v0.1.1 && git push origin sdk-core-v0.1.1
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
CI checks the tag against the version, runs the tests, builds, installs the wheel in a clean
|
|
157
|
+
environment and imports it, verifies no training stack came along, and attaches the artifacts
|
|
158
|
+
to a Release.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Open Recruiter — the recruiting engine behind the Open Recruiter app.
|
|
2
|
+
|
|
3
|
+
Parse resumes and job descriptions, retrieve and rank candidates, draft
|
|
4
|
+
outreach, and run an agent that does all of it through tools.
|
|
5
|
+
|
|
6
|
+
from openrecruiter import Recruiter
|
|
7
|
+
|
|
8
|
+
r = Recruiter(anthropic_api_key="sk-ant-...")
|
|
9
|
+
job = r.add_job(jd_text)
|
|
10
|
+
matches = r.rank(job.id, top_k=10)
|
|
11
|
+
|
|
12
|
+
No local model is downloaded, at import or at runtime. Embeddings are an API
|
|
13
|
+
call and chat is a hosted provider, so the package runs on CPU, on macOS, and in
|
|
14
|
+
a container without a GPU.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from openrecruiter.agent import Agent, PendingApproval
|
|
18
|
+
from openrecruiter.client import Recruiter
|
|
19
|
+
from openrecruiter.config import Config
|
|
20
|
+
from openrecruiter.context import build_pipeline_context
|
|
21
|
+
from openrecruiter.events import (
|
|
22
|
+
ApprovalRequired,
|
|
23
|
+
Event,
|
|
24
|
+
Finished,
|
|
25
|
+
TextDelta,
|
|
26
|
+
ToolCall,
|
|
27
|
+
ToolResult,
|
|
28
|
+
)
|
|
29
|
+
from openrecruiter.ranking import APIRanker, EmbeddingRanker, Ranker, TwoStageRanker
|
|
30
|
+
from openrecruiter.store import (
|
|
31
|
+
ChromaVectorIndex,
|
|
32
|
+
NullVectorIndex,
|
|
33
|
+
SQLiteStore,
|
|
34
|
+
Store,
|
|
35
|
+
VectorIndex,
|
|
36
|
+
)
|
|
37
|
+
from openrecruiter.tools import Tool, ToolRegistry, tool
|
|
38
|
+
from openrecruiter.types import Candidate, CandidateStatus, EmailDraft, Job, Match
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"APIRanker",
|
|
44
|
+
"Agent",
|
|
45
|
+
"ApprovalRequired",
|
|
46
|
+
"build_pipeline_context",
|
|
47
|
+
"Candidate",
|
|
48
|
+
"CandidateStatus",
|
|
49
|
+
"ChromaVectorIndex",
|
|
50
|
+
"Config",
|
|
51
|
+
"EmailDraft",
|
|
52
|
+
"EmbeddingRanker",
|
|
53
|
+
"Event",
|
|
54
|
+
"Finished",
|
|
55
|
+
"Job",
|
|
56
|
+
"Match",
|
|
57
|
+
"NullVectorIndex",
|
|
58
|
+
"PendingApproval",
|
|
59
|
+
"Ranker",
|
|
60
|
+
"Recruiter",
|
|
61
|
+
"SQLiteStore",
|
|
62
|
+
"Store",
|
|
63
|
+
"TextDelta",
|
|
64
|
+
"Tool",
|
|
65
|
+
"ToolCall",
|
|
66
|
+
"ToolRegistry",
|
|
67
|
+
"ToolResult",
|
|
68
|
+
"TwoStageRanker",
|
|
69
|
+
"VectorIndex",
|
|
70
|
+
"__version__",
|
|
71
|
+
"tool",
|
|
72
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The agent loop.
|
|
2
|
+
|
|
3
|
+
The model is given real tools and decides for itself what to call, in what
|
|
4
|
+
order, and when it is finished. That is the whole difference from asking a model
|
|
5
|
+
to emit an action name and dispatching it: a single request can retrieve a job,
|
|
6
|
+
rank the pool, read the top result, and draft three emails, because each tool
|
|
7
|
+
result feeds the next decision.
|
|
8
|
+
|
|
9
|
+
The loop is a generator of `Event`s so a caller can render text as it arrives
|
|
10
|
+
rather than after the whole turn completes, and so it can stop at an approval
|
|
11
|
+
gate without the agent needing to know what a user interface is.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from openrecruiter.events import (
|
|
23
|
+
ApprovalRequired,
|
|
24
|
+
Event,
|
|
25
|
+
Finished,
|
|
26
|
+
TextDelta,
|
|
27
|
+
ToolCall,
|
|
28
|
+
ToolResult,
|
|
29
|
+
)
|
|
30
|
+
from openrecruiter.prompts import AGENT_SYSTEM
|
|
31
|
+
from openrecruiter.providers.llm import LLM, LLMError
|
|
32
|
+
from openrecruiter.tools.base import ToolError, ToolRegistry
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DEFAULT_MAX_STEPS = 8
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PendingApproval(BaseModel):
|
|
40
|
+
"""Everything needed to carry on once a human has decided.
|
|
41
|
+
|
|
42
|
+
A plain model rather than a closure, because the decision usually arrives on
|
|
43
|
+
a later HTTP request in a different process. Store it, then hand it back to
|
|
44
|
+
`resume()`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
messages: list[dict] = Field(default_factory=list)
|
|
48
|
+
tool_name: str = ""
|
|
49
|
+
tool_call_id: str = ""
|
|
50
|
+
arguments: dict = Field(default_factory=dict)
|
|
51
|
+
#: Calls the model made in the same turn that have not run yet.
|
|
52
|
+
queued: list[dict] = Field(default_factory=list)
|
|
53
|
+
step: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Agent:
|
|
57
|
+
"""Runs a conversation with tools until the work is done."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
llm: LLM,
|
|
62
|
+
tools: ToolRegistry,
|
|
63
|
+
system: str = AGENT_SYSTEM,
|
|
64
|
+
max_steps: int = DEFAULT_MAX_STEPS,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.llm = llm
|
|
67
|
+
self.tools = tools
|
|
68
|
+
self.system = system
|
|
69
|
+
self.max_steps = max_steps
|
|
70
|
+
#: Set when a run stops at an approval gate; None otherwise.
|
|
71
|
+
self.pending: PendingApproval | None = None
|
|
72
|
+
|
|
73
|
+
# ── entry points ─────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def run(self, message: str, history: list[dict] | None = None) -> Iterator[Event]:
|
|
76
|
+
"""Answer `message`, calling tools as needed."""
|
|
77
|
+
messages = [*(history or []), {"role": "user", "content": message}]
|
|
78
|
+
self.pending = None
|
|
79
|
+
yield from self._loop(messages, step=0)
|
|
80
|
+
|
|
81
|
+
def resume(self, pending: PendingApproval, approved: bool) -> Iterator[Event]:
|
|
82
|
+
"""Continue a run that stopped for approval.
|
|
83
|
+
|
|
84
|
+
Declining is not an error: the model is told the user refused and gets to
|
|
85
|
+
respond to that, which is usually more useful than an abandoned turn.
|
|
86
|
+
"""
|
|
87
|
+
self.pending = None
|
|
88
|
+
messages = list(pending.messages)
|
|
89
|
+
|
|
90
|
+
if approved:
|
|
91
|
+
result = self._invoke(pending.tool_name, pending.arguments)
|
|
92
|
+
yield result
|
|
93
|
+
messages.append(_tool_message(pending.tool_call_id, result))
|
|
94
|
+
else:
|
|
95
|
+
declined = ToolResult(
|
|
96
|
+
id=pending.tool_call_id,
|
|
97
|
+
name=pending.tool_name,
|
|
98
|
+
error="The user declined this action. It was not performed.",
|
|
99
|
+
)
|
|
100
|
+
yield declined
|
|
101
|
+
messages.append(_tool_message(pending.tool_call_id, declined))
|
|
102
|
+
|
|
103
|
+
queued = [ToolCall(**c) for c in pending.queued]
|
|
104
|
+
stopped = yield from self._run_tools(queued, messages, pending.step)
|
|
105
|
+
if stopped:
|
|
106
|
+
return
|
|
107
|
+
yield from self._loop(messages, step=pending.step + 1)
|
|
108
|
+
|
|
109
|
+
# ── the loop ─────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def _loop(self, messages: list[dict], step: int) -> Iterator[Event]:
|
|
112
|
+
schemas = self.tools.schemas()
|
|
113
|
+
|
|
114
|
+
while step < self.max_steps:
|
|
115
|
+
text_parts: list[str] = []
|
|
116
|
+
calls: list[ToolCall] = []
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
for event in self.llm.stream(self.system, messages, tools=schemas):
|
|
120
|
+
if isinstance(event, TextDelta):
|
|
121
|
+
text_parts.append(event.text)
|
|
122
|
+
yield event
|
|
123
|
+
else:
|
|
124
|
+
calls.append(event)
|
|
125
|
+
except LLMError as exc:
|
|
126
|
+
log.error("Agent step %d failed: %s", step, exc)
|
|
127
|
+
yield Finished(text="".join(text_parts), stop_reason="error", steps=step, error=str(exc))
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
text = "".join(text_parts)
|
|
131
|
+
|
|
132
|
+
if not calls:
|
|
133
|
+
yield Finished(text=text, stop_reason="end_turn", steps=step)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
messages.append(_assistant_message(text, calls))
|
|
137
|
+
|
|
138
|
+
stopped = yield from self._run_tools(calls, messages, step)
|
|
139
|
+
if stopped:
|
|
140
|
+
return
|
|
141
|
+
step += 1
|
|
142
|
+
|
|
143
|
+
yield Finished(stop_reason="max_steps", steps=step)
|
|
144
|
+
|
|
145
|
+
def _run_tools(
|
|
146
|
+
self, calls: list[ToolCall], messages: list[dict], step: int
|
|
147
|
+
) -> Iterator[Event]:
|
|
148
|
+
"""Run each call in order. Returns True if the run stopped for approval.
|
|
149
|
+
|
|
150
|
+
Anything queued behind an approval gate is carried into `PendingApproval`
|
|
151
|
+
rather than run — the user has not agreed to those either, and running
|
|
152
|
+
them would make the gate cosmetic.
|
|
153
|
+
"""
|
|
154
|
+
for i, call in enumerate(calls):
|
|
155
|
+
if self._needs_approval(call.name):
|
|
156
|
+
self.pending = PendingApproval(
|
|
157
|
+
messages=messages,
|
|
158
|
+
tool_name=call.name,
|
|
159
|
+
tool_call_id=call.id,
|
|
160
|
+
arguments=call.arguments,
|
|
161
|
+
queued=[
|
|
162
|
+
{"id": c.id, "name": c.name, "arguments": c.arguments}
|
|
163
|
+
for c in calls[i + 1 :]
|
|
164
|
+
],
|
|
165
|
+
step=step,
|
|
166
|
+
)
|
|
167
|
+
yield ApprovalRequired(
|
|
168
|
+
id=call.id,
|
|
169
|
+
name=call.name,
|
|
170
|
+
arguments=call.arguments,
|
|
171
|
+
description=self.tools.get(call.name).description,
|
|
172
|
+
)
|
|
173
|
+
yield Finished(stop_reason="awaiting_approval", steps=step)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
# Announced before it runs, so a caller can show what is happening
|
|
177
|
+
# rather than a spinner — a rank over a large pool is not instant.
|
|
178
|
+
yield call
|
|
179
|
+
|
|
180
|
+
result = self._invoke(call.name, call.arguments, call_id=call.id)
|
|
181
|
+
yield result
|
|
182
|
+
messages.append(_tool_message(call.id, result))
|
|
183
|
+
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def _needs_approval(self, name: str) -> bool:
|
|
189
|
+
try:
|
|
190
|
+
return self.tools.get(name).requires_approval
|
|
191
|
+
except ToolError:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def _invoke(self, name: str, arguments: dict, call_id: str = "") -> ToolResult:
|
|
195
|
+
"""Call a tool, turning any failure into a result the model can read.
|
|
196
|
+
|
|
197
|
+
A tool raising must not end the turn: the model can often recover — fix
|
|
198
|
+
an argument, try a different tool — if it is told what went wrong.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
value = self.tools.call(name, arguments)
|
|
202
|
+
except ToolError as exc:
|
|
203
|
+
return ToolResult(id=call_id, name=name, error=str(exc))
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - tools are arbitrary user code
|
|
205
|
+
log.exception("Tool %s raised", name)
|
|
206
|
+
return ToolResult(id=call_id, name=name, error=f"{type(exc).__name__}: {exc}")
|
|
207
|
+
return ToolResult(id=call_id, name=name, result=value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── provider message shapes ──────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _assistant_message(text: str, calls: list[ToolCall]) -> dict:
|
|
214
|
+
return {
|
|
215
|
+
"role": "assistant",
|
|
216
|
+
"content": text or None,
|
|
217
|
+
"tool_calls": [
|
|
218
|
+
{
|
|
219
|
+
"id": c.id,
|
|
220
|
+
"type": "function",
|
|
221
|
+
"function": {"name": c.name, "arguments": json.dumps(c.arguments)},
|
|
222
|
+
}
|
|
223
|
+
for c in calls
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _tool_message(call_id: str, result: ToolResult) -> dict:
|
|
229
|
+
payload = {"error": result.error} if result.error else {"result": result.result}
|
|
230
|
+
return {
|
|
231
|
+
"role": "tool",
|
|
232
|
+
"tool_call_id": call_id,
|
|
233
|
+
"content": json.dumps(payload, default=str),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["Agent", "PendingApproval", "DEFAULT_MAX_STEPS"]
|