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
loop_agent_cli/cli.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""
|
|
2
|
+
loop-agent-cli — Typer CLI entry point.
|
|
3
|
+
|
|
4
|
+
15 commands + 3 helper functions, built on Typer + Rich.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Optional, Any, List
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich import box
|
|
21
|
+
|
|
22
|
+
from loop_agent_cli import __version__
|
|
23
|
+
from loop_agent_cli.container import Container
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Typer app
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="loop-agent",
|
|
33
|
+
help="Recruiting Loop Agent — CLI frontend",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
run_app = typer.Typer(help="执行招聘循环")
|
|
39
|
+
position_app = typer.Typer(help="职位管理")
|
|
40
|
+
candidate_app = typer.Typer(help="候选人管理")
|
|
41
|
+
pipeline_app = typer.Typer(help="招聘管道管理")
|
|
42
|
+
schedule_app = typer.Typer(help="调度管理")
|
|
43
|
+
|
|
44
|
+
app.add_typer(run_app, name="run")
|
|
45
|
+
app.add_typer(position_app, name="position")
|
|
46
|
+
app.add_typer(candidate_app, name="candidate")
|
|
47
|
+
app.add_typer(pipeline_app, name="pipeline")
|
|
48
|
+
app.add_typer(schedule_app, name="schedule")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ===================================================================
|
|
52
|
+
# Helper functions (3)
|
|
53
|
+
# ===================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run(coro: Any) -> Any:
|
|
57
|
+
"""在事件循环中运行异步协程。"""
|
|
58
|
+
return asyncio.run(coro)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_json_or_file(value: Optional[str]) -> Optional[list]:
|
|
62
|
+
"""
|
|
63
|
+
解析输入为 list[str]。
|
|
64
|
+
优先级: JSON 解析 → 文件读取 → 逗号分割。
|
|
65
|
+
"""
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# 1. Try JSON
|
|
70
|
+
try:
|
|
71
|
+
parsed = json.loads(value)
|
|
72
|
+
if isinstance(parsed, list):
|
|
73
|
+
return parsed
|
|
74
|
+
except (json.JSONDecodeError, TypeError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
# 2. Try file
|
|
78
|
+
if os.path.isfile(value):
|
|
79
|
+
with open(value, "r", encoding="utf-8") as f:
|
|
80
|
+
content = f.read().strip()
|
|
81
|
+
# Try JSON inside file
|
|
82
|
+
try:
|
|
83
|
+
parsed = json.loads(content)
|
|
84
|
+
if isinstance(parsed, list):
|
|
85
|
+
return parsed
|
|
86
|
+
except (json.JSONDecodeError, TypeError):
|
|
87
|
+
pass
|
|
88
|
+
# Line-by-line
|
|
89
|
+
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
|
90
|
+
if lines:
|
|
91
|
+
return lines
|
|
92
|
+
|
|
93
|
+
# 3. Comma-separated
|
|
94
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _print_result(result: dict) -> None:
|
|
98
|
+
"""打印 run_recruiting_loop 的结果到 Rich Panel。"""
|
|
99
|
+
status = result.get("status", "unknown")
|
|
100
|
+
duration = result.get("duration_ms", 0)
|
|
101
|
+
|
|
102
|
+
if status == "failed":
|
|
103
|
+
error_msg = result.get("error", "Unknown error")
|
|
104
|
+
console.print(
|
|
105
|
+
Panel(
|
|
106
|
+
f"[red]Error:[/] {error_msg}\nDuration: {duration} ms",
|
|
107
|
+
title="Recruiting Loop — Failed",
|
|
108
|
+
border_style="red",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# completed
|
|
114
|
+
res = result.get("results", {})
|
|
115
|
+
lines = [
|
|
116
|
+
f"Candidates found: {res.get('candidates_found', 0)}",
|
|
117
|
+
f"Candidates added: {res.get('candidates_added', 0)}",
|
|
118
|
+
f"Emails sent: {res.get('emails_sent', 0)}",
|
|
119
|
+
f"Duration: {duration} ms",
|
|
120
|
+
f"Continue loop: {res.get('continue_loop', 'N/A')}",
|
|
121
|
+
]
|
|
122
|
+
errors = res.get("errors", [])
|
|
123
|
+
body = "\n".join(lines)
|
|
124
|
+
if errors:
|
|
125
|
+
body += "\n\n[red]Errors:[/]"
|
|
126
|
+
for err in errors:
|
|
127
|
+
body += f"\n • {err}"
|
|
128
|
+
|
|
129
|
+
console.print(
|
|
130
|
+
Panel(body, title="Recruiting Loop — Completed", border_style="green")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _validate_uuid(value: str) -> Optional[uuid.UUID]:
|
|
135
|
+
"""校验 UUID 格式,返回 UUID 对象或 None。"""
|
|
136
|
+
try:
|
|
137
|
+
return uuid.UUID(value)
|
|
138
|
+
except (ValueError, AttributeError):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ===================================================================
|
|
143
|
+
# run commands
|
|
144
|
+
# ===================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@run_app.command("position")
|
|
148
|
+
def run_position(
|
|
149
|
+
position_id: str = typer.Argument(..., help="Position UUID"),
|
|
150
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
151
|
+
) -> None:
|
|
152
|
+
"""对已有职位执行一次招聘循环。"""
|
|
153
|
+
pid = _validate_uuid(position_id)
|
|
154
|
+
if pid is None:
|
|
155
|
+
console.print(f"[red]无效的 UUID: {position_id}[/]")
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
async def _do() -> None:
|
|
159
|
+
async with Container(db_url) as c:
|
|
160
|
+
# 校验职位存在
|
|
161
|
+
pos = await c.position_repo.get_by_id(pid)
|
|
162
|
+
if not pos:
|
|
163
|
+
console.print(f"[red]职位不存在: {position_id}[/]")
|
|
164
|
+
return
|
|
165
|
+
console.print(f"[dim]正在对职位 '{pos.title}' 执行招聘循环…[/]")
|
|
166
|
+
result = await c.runner.run_recruiting_loop(pid)
|
|
167
|
+
_print_result(result)
|
|
168
|
+
|
|
169
|
+
_run(_do())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@run_app.command("create-and-run")
|
|
173
|
+
def run_create_and_run(
|
|
174
|
+
title: str = typer.Option(..., "--title", "-t", help="职位名称"),
|
|
175
|
+
company: str = typer.Option(..., "--company", "-c", help="公司名称"),
|
|
176
|
+
desc: Optional[str] = typer.Option(None, "--desc", "-d", help="职位描述"),
|
|
177
|
+
location: Optional[str] = typer.Option(None, "--location", "-l", help="工作地点"),
|
|
178
|
+
skills: Optional[str] = typer.Option(None, "--skills", "-s", help="技能要求 (JSON/文件/逗号分隔)"),
|
|
179
|
+
keywords: Optional[str] = typer.Option(None, "--keywords", "-k", help="搜索关键词"),
|
|
180
|
+
interval: int = typer.Option(60, "--interval", "-i", help="循环间隔(分钟)"),
|
|
181
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
182
|
+
) -> None:
|
|
183
|
+
"""创建新职位并立即执行招聘循环。"""
|
|
184
|
+
required_skills = _parse_json_or_file(skills)
|
|
185
|
+
search_keywords = _parse_json_or_file(keywords)
|
|
186
|
+
|
|
187
|
+
async def _do() -> None:
|
|
188
|
+
async with Container(db_url) as c:
|
|
189
|
+
# 延迟导入 PositionCreate
|
|
190
|
+
from app.schemas.position import PositionCreate
|
|
191
|
+
|
|
192
|
+
position_data = PositionCreate(
|
|
193
|
+
title=title,
|
|
194
|
+
company=company,
|
|
195
|
+
description=desc,
|
|
196
|
+
location=location,
|
|
197
|
+
required_skills=required_skills,
|
|
198
|
+
search_keywords=search_keywords,
|
|
199
|
+
loop_interval=interval,
|
|
200
|
+
)
|
|
201
|
+
position = await c.position_repo.create(position_data)
|
|
202
|
+
console.print(
|
|
203
|
+
f"[green]✓ 职位已创建:[/] {position.title} "
|
|
204
|
+
f"[dim](ID: {position.id})[/]"
|
|
205
|
+
)
|
|
206
|
+
console.print(f"[dim]正在执行招聘循环…[/]")
|
|
207
|
+
result = await c.runner.run_recruiting_loop(
|
|
208
|
+
uuid.UUID(position.id) if isinstance(position.id, str) else position.id
|
|
209
|
+
)
|
|
210
|
+
_print_result(result)
|
|
211
|
+
|
|
212
|
+
_run(_do())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ===================================================================
|
|
216
|
+
# position commands
|
|
217
|
+
# ===================================================================
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@position_app.command("list")
|
|
221
|
+
def position_list(
|
|
222
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
223
|
+
) -> None:
|
|
224
|
+
"""列出所有职位。"""
|
|
225
|
+
|
|
226
|
+
async def _do() -> None:
|
|
227
|
+
async with Container(db_url) as c:
|
|
228
|
+
positions = await c.position_repo.get_all()
|
|
229
|
+
if not positions:
|
|
230
|
+
console.print("[dim]没有找到任何职位[/]")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
table = Table(title="Positions", box=box.ROUNDED)
|
|
234
|
+
table.add_column("ID", style="dim", max_width=36)
|
|
235
|
+
table.add_column("Title", style="cyan")
|
|
236
|
+
table.add_column("Company", style="green")
|
|
237
|
+
table.add_column("Status")
|
|
238
|
+
table.add_column("Skills")
|
|
239
|
+
table.add_column("Loop", justify="center")
|
|
240
|
+
|
|
241
|
+
for pos in positions:
|
|
242
|
+
# Status colour
|
|
243
|
+
status_map = {"active": "green", "paused": "yellow", "closed": "red"}
|
|
244
|
+
status_style = status_map.get(pos.status, "white")
|
|
245
|
+
|
|
246
|
+
# Skills — try to parse from JSON string
|
|
247
|
+
skills_display = ""
|
|
248
|
+
if pos.required_skills:
|
|
249
|
+
if isinstance(pos.required_skills, str):
|
|
250
|
+
try:
|
|
251
|
+
skills_list = json.loads(pos.required_skills)
|
|
252
|
+
except (json.JSONDecodeError, TypeError):
|
|
253
|
+
skills_list = [pos.required_skills]
|
|
254
|
+
elif isinstance(pos.required_skills, list):
|
|
255
|
+
skills_list = pos.required_skills
|
|
256
|
+
else:
|
|
257
|
+
skills_list = []
|
|
258
|
+
skills_display = ", ".join(str(s) for s in skills_list)
|
|
259
|
+
if len(skills_display) > 40:
|
|
260
|
+
skills_display = skills_display[:37] + "…"
|
|
261
|
+
|
|
262
|
+
# Loop
|
|
263
|
+
loop_display = (
|
|
264
|
+
f"{pos.loop_interval}m"
|
|
265
|
+
if pos.loop_enabled
|
|
266
|
+
else "off"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
table.add_row(
|
|
270
|
+
str(pos.id),
|
|
271
|
+
pos.title or "",
|
|
272
|
+
pos.company or "",
|
|
273
|
+
f"[{status_style}]{pos.status}[/]",
|
|
274
|
+
skills_display,
|
|
275
|
+
loop_display,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
console.print(table)
|
|
279
|
+
|
|
280
|
+
_run(_do())
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@position_app.command("create")
|
|
284
|
+
def position_create(
|
|
285
|
+
title: str = typer.Option(..., "--title", "-t", help="职位名称"),
|
|
286
|
+
company: str = typer.Option(..., "--company", "-c", help="公司名称"),
|
|
287
|
+
desc: Optional[str] = typer.Option(None, "--desc", "-d", help="职位描述"),
|
|
288
|
+
location: Optional[str] = typer.Option(None, "--location", "-l", help="工作地点"),
|
|
289
|
+
skills: Optional[str] = typer.Option(None, "--skills", "-s", help="技能要求"),
|
|
290
|
+
keywords: Optional[str] = typer.Option(None, "--keywords", "-k", help="搜索关键词"),
|
|
291
|
+
interval: int = typer.Option(60, "--interval", "-i", help="循环间隔(分钟)"),
|
|
292
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""创建新职位。"""
|
|
295
|
+
required_skills = _parse_json_or_file(skills)
|
|
296
|
+
search_keywords = _parse_json_or_file(keywords)
|
|
297
|
+
|
|
298
|
+
async def _do() -> None:
|
|
299
|
+
async with Container(db_url) as c:
|
|
300
|
+
from app.schemas.position import PositionCreate
|
|
301
|
+
|
|
302
|
+
position_data = PositionCreate(
|
|
303
|
+
title=title,
|
|
304
|
+
company=company,
|
|
305
|
+
description=desc,
|
|
306
|
+
location=location,
|
|
307
|
+
required_skills=required_skills,
|
|
308
|
+
search_keywords=search_keywords,
|
|
309
|
+
loop_interval=interval,
|
|
310
|
+
)
|
|
311
|
+
position = await c.position_repo.create(position_data)
|
|
312
|
+
console.print(
|
|
313
|
+
f"[green]✓ 职位已创建:[/] {position.title}\n"
|
|
314
|
+
f" [dim]ID: {position.id}[/]"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
_run(_do())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@position_app.command("show")
|
|
321
|
+
def position_show(
|
|
322
|
+
position_id: str = typer.Argument(..., help="Position UUID"),
|
|
323
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
324
|
+
) -> None:
|
|
325
|
+
"""查看职位详情。"""
|
|
326
|
+
pid = _validate_uuid(position_id)
|
|
327
|
+
if pid is None:
|
|
328
|
+
console.print(f"[red]无效的 UUID: {position_id}[/]")
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
|
|
331
|
+
async def _do() -> None:
|
|
332
|
+
async with Container(db_url) as c:
|
|
333
|
+
pos = await c.position_repo.get_by_id(pid)
|
|
334
|
+
if not pos:
|
|
335
|
+
console.print(f"[red]职位不存在: {position_id}[/]")
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
# Parse skills
|
|
339
|
+
skills_display = "—"
|
|
340
|
+
if pos.required_skills:
|
|
341
|
+
if isinstance(pos.required_skills, str):
|
|
342
|
+
try:
|
|
343
|
+
skills_display = ", ".join(json.loads(pos.required_skills))
|
|
344
|
+
except (json.JSONDecodeError, TypeError):
|
|
345
|
+
skills_display = pos.required_skills
|
|
346
|
+
elif isinstance(pos.required_skills, list):
|
|
347
|
+
skills_display = ", ".join(pos.required_skills)
|
|
348
|
+
|
|
349
|
+
keywords_display = "—"
|
|
350
|
+
if pos.search_keywords:
|
|
351
|
+
if isinstance(pos.search_keywords, str):
|
|
352
|
+
try:
|
|
353
|
+
keywords_display = ", ".join(json.loads(pos.search_keywords))
|
|
354
|
+
except (json.JSONDecodeError, TypeError):
|
|
355
|
+
keywords_display = pos.search_keywords
|
|
356
|
+
elif isinstance(pos.search_keywords, list):
|
|
357
|
+
keywords_display = ", ".join(pos.search_keywords)
|
|
358
|
+
|
|
359
|
+
loop_info = (
|
|
360
|
+
f"enabled, every {pos.loop_interval}m"
|
|
361
|
+
if pos.loop_enabled
|
|
362
|
+
else "disabled"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
body = "\n".join([
|
|
366
|
+
f" ID: {pos.id}",
|
|
367
|
+
f" Status: {pos.status}",
|
|
368
|
+
f" Location: {pos.location or '—'}",
|
|
369
|
+
f" Description: {pos.description or '—'}",
|
|
370
|
+
f" Skills: {skills_display}",
|
|
371
|
+
f" Keywords: {keywords_display}",
|
|
372
|
+
f" Loop: {loop_info}",
|
|
373
|
+
f" Created: {pos.created_at}",
|
|
374
|
+
])
|
|
375
|
+
console.print(Panel(body, title=f"{pos.title} @ {pos.company}"))
|
|
376
|
+
|
|
377
|
+
_run(_do())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@position_app.command("close")
|
|
381
|
+
def position_close(
|
|
382
|
+
position_id: str = typer.Argument(..., help="Position UUID"),
|
|
383
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
384
|
+
) -> None:
|
|
385
|
+
"""关闭职位。"""
|
|
386
|
+
pid = _validate_uuid(position_id)
|
|
387
|
+
if pid is None:
|
|
388
|
+
console.print(f"[red]无效的 UUID: {position_id}[/]")
|
|
389
|
+
raise typer.Exit(1)
|
|
390
|
+
|
|
391
|
+
async def _do() -> None:
|
|
392
|
+
async with Container(db_url) as c:
|
|
393
|
+
pos = await c.position_service.close_position(pid)
|
|
394
|
+
if not pos:
|
|
395
|
+
console.print(f"[red]职位不存在: {position_id}[/]")
|
|
396
|
+
return
|
|
397
|
+
console.print(f"[green]✓ 职位已关闭:[/] {pos.title} ({pos.id})")
|
|
398
|
+
|
|
399
|
+
_run(_do())
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ===================================================================
|
|
403
|
+
# candidate commands
|
|
404
|
+
# ===================================================================
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@candidate_app.command("list")
|
|
408
|
+
def candidate_list(
|
|
409
|
+
limit: int = typer.Option(20, "--limit", "-n", help="显示数量"),
|
|
410
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
411
|
+
) -> None:
|
|
412
|
+
"""列出候选人。"""
|
|
413
|
+
|
|
414
|
+
async def _do() -> None:
|
|
415
|
+
async with Container(db_url) as c:
|
|
416
|
+
candidates = await c.candidate_repo.get_all()
|
|
417
|
+
candidates = candidates[:limit]
|
|
418
|
+
if not candidates:
|
|
419
|
+
console.print("[dim]没有找到任何候选人[/]")
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
table = Table(title="Candidates", box=box.ROUNDED)
|
|
423
|
+
table.add_column("ID", style="dim", max_width=36)
|
|
424
|
+
table.add_column("Name", style="cyan")
|
|
425
|
+
table.add_column("GitHub", style="green")
|
|
426
|
+
table.add_column("Company")
|
|
427
|
+
table.add_column("Followers", justify="right")
|
|
428
|
+
table.add_column("Repos", justify="right")
|
|
429
|
+
table.add_column("Source")
|
|
430
|
+
|
|
431
|
+
for cand in candidates:
|
|
432
|
+
table.add_row(
|
|
433
|
+
str(cand.id),
|
|
434
|
+
cand.name or "—",
|
|
435
|
+
cand.github_login or "—",
|
|
436
|
+
cand.company or "—",
|
|
437
|
+
str(cand.followers or 0),
|
|
438
|
+
str(cand.public_repos or 0),
|
|
439
|
+
cand.source or "—",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
console.print(table)
|
|
443
|
+
|
|
444
|
+
_run(_do())
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@candidate_app.command("show")
|
|
448
|
+
def candidate_show(
|
|
449
|
+
candidate_id: str = typer.Argument(..., help="Candidate UUID"),
|
|
450
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
451
|
+
) -> None:
|
|
452
|
+
"""查看候选人详情。"""
|
|
453
|
+
cid = _validate_uuid(candidate_id)
|
|
454
|
+
if cid is None:
|
|
455
|
+
console.print(f"[red]无效的 UUID: {candidate_id}[/]")
|
|
456
|
+
raise typer.Exit(1)
|
|
457
|
+
|
|
458
|
+
async def _do() -> None:
|
|
459
|
+
async with Container(db_url) as c:
|
|
460
|
+
cand = await c.candidate_repo.get_by_id(cid)
|
|
461
|
+
if not cand:
|
|
462
|
+
console.print(f"[red]候选人不存在: {candidate_id}[/]")
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
# Parse skills
|
|
466
|
+
skills_display = "—"
|
|
467
|
+
if cand.skills:
|
|
468
|
+
if isinstance(cand.skills, str):
|
|
469
|
+
try:
|
|
470
|
+
skills_display = ", ".join(json.loads(cand.skills))
|
|
471
|
+
except (json.JSONDecodeError, TypeError):
|
|
472
|
+
skills_display = cand.skills
|
|
473
|
+
elif isinstance(cand.skills, list):
|
|
474
|
+
skills_display = ", ".join(cand.skills)
|
|
475
|
+
|
|
476
|
+
bio = cand.bio or "—"
|
|
477
|
+
if len(bio) > 80:
|
|
478
|
+
bio = bio[:77] + "…"
|
|
479
|
+
|
|
480
|
+
body = "\n".join([
|
|
481
|
+
f" ID: {cand.id}",
|
|
482
|
+
f" GitHub: {cand.github_login or '—'}",
|
|
483
|
+
f" Email: {cand.email or '—'}",
|
|
484
|
+
f" Company: {cand.company or '—'}",
|
|
485
|
+
f" Title: {cand.title or '—'}",
|
|
486
|
+
f" Location: {cand.location or '—'}",
|
|
487
|
+
f" Bio: {bio}",
|
|
488
|
+
f" Skills: {skills_display}",
|
|
489
|
+
f" Followers: {cand.followers or 0}",
|
|
490
|
+
f" Public Repos: {cand.public_repos or 0}",
|
|
491
|
+
f" Source: {cand.source or '—'}",
|
|
492
|
+
f" Appearances: {cand.appearance_count or 1}",
|
|
493
|
+
])
|
|
494
|
+
console.print(Panel(body, title=cand.name or cand.github_login or "Candidate"))
|
|
495
|
+
|
|
496
|
+
_run(_do())
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ===================================================================
|
|
500
|
+
# pipeline commands
|
|
501
|
+
# ===================================================================
|
|
502
|
+
|
|
503
|
+
_STATUS_COLORS = {
|
|
504
|
+
"discovered": "dim",
|
|
505
|
+
"contacted": "yellow",
|
|
506
|
+
"replied": "green",
|
|
507
|
+
"interview": "cyan",
|
|
508
|
+
"offer": "bold green",
|
|
509
|
+
"rejected": "red",
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
_VALID_STATUSES = list(_STATUS_COLORS.keys())
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@pipeline_app.command("list")
|
|
516
|
+
def pipeline_list(
|
|
517
|
+
position: Optional[str] = typer.Option(None, "--position", "-p", help="按职位过滤"),
|
|
518
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="按状态过滤"),
|
|
519
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
520
|
+
) -> None:
|
|
521
|
+
"""列出招聘管道。"""
|
|
522
|
+
|
|
523
|
+
async def _do() -> None:
|
|
524
|
+
async with Container(db_url) as c:
|
|
525
|
+
pipelines: list = []
|
|
526
|
+
|
|
527
|
+
if position:
|
|
528
|
+
pid = _validate_uuid(position)
|
|
529
|
+
if pid is None:
|
|
530
|
+
console.print(f"[red]无效的 UUID: {position}[/]")
|
|
531
|
+
raise typer.Exit(1)
|
|
532
|
+
pipelines = await c.pipeline_repo.get_by_position(pid)
|
|
533
|
+
elif status:
|
|
534
|
+
pipelines = await c.pipeline_repo.get_by_status(None, status) # type: ignore[arg-type]
|
|
535
|
+
else:
|
|
536
|
+
pipelines = await c.pipeline_repo.get_all()
|
|
537
|
+
|
|
538
|
+
if not pipelines:
|
|
539
|
+
console.print("[dim]没有找到任何管道记录[/]")
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
table = Table(title="Pipelines", box=box.ROUNDED)
|
|
543
|
+
table.add_column("ID", style="dim", max_width=36)
|
|
544
|
+
table.add_column("Position", max_width=36)
|
|
545
|
+
table.add_column("Candidate", max_width=36)
|
|
546
|
+
table.add_column("Status")
|
|
547
|
+
table.add_column("Score", justify="right")
|
|
548
|
+
table.add_column("Notes")
|
|
549
|
+
|
|
550
|
+
for pl in pipelines:
|
|
551
|
+
color = _STATUS_COLORS.get(pl.status, "white")
|
|
552
|
+
table.add_row(
|
|
553
|
+
str(pl.id),
|
|
554
|
+
str(pl.position_id),
|
|
555
|
+
str(pl.candidate_id),
|
|
556
|
+
f"[{color}]{pl.status}[/]",
|
|
557
|
+
f"{pl.score:.1f}" if pl.score is not None else "—",
|
|
558
|
+
(pl.notes or "—")[:40],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
console.print(table)
|
|
562
|
+
|
|
563
|
+
_run(_do())
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@pipeline_app.command("update-status")
|
|
567
|
+
def pipeline_update_status(
|
|
568
|
+
pipeline_id: str = typer.Argument(..., help="Pipeline UUID"),
|
|
569
|
+
status: str = typer.Argument(..., help="新状态"),
|
|
570
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
571
|
+
) -> None:
|
|
572
|
+
"""更新管道状态。"""
|
|
573
|
+
pid = _validate_uuid(pipeline_id)
|
|
574
|
+
if pid is None:
|
|
575
|
+
console.print(f"[red]无效的 UUID: {pipeline_id}[/]")
|
|
576
|
+
raise typer.Exit(1)
|
|
577
|
+
|
|
578
|
+
if status not in _VALID_STATUSES:
|
|
579
|
+
console.print(
|
|
580
|
+
f"[red]无效状态: {status}[/]\n"
|
|
581
|
+
f"合法值: {', '.join(_VALID_STATUSES)}"
|
|
582
|
+
)
|
|
583
|
+
raise typer.Exit(1)
|
|
584
|
+
|
|
585
|
+
async def _do() -> None:
|
|
586
|
+
async with Container(db_url) as c:
|
|
587
|
+
pl = await c.pipeline_service.update_pipeline_status(pid, status)
|
|
588
|
+
if not pl:
|
|
589
|
+
console.print(f"[red]管道不存在: {pipeline_id}[/]")
|
|
590
|
+
return
|
|
591
|
+
console.print(
|
|
592
|
+
f"[green]✓ 管道状态已更新:[/] {pl.id} → [{_STATUS_COLORS.get(status, 'white')}]{status}[/]"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
_run(_do())
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ===================================================================
|
|
599
|
+
# schedule commands
|
|
600
|
+
# ===================================================================
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@schedule_app.command("start")
|
|
604
|
+
def schedule_start(
|
|
605
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
606
|
+
) -> None:
|
|
607
|
+
"""启动后台调度器 (Ctrl+C 退出)。"""
|
|
608
|
+
|
|
609
|
+
async def _scheduler_loop() -> None:
|
|
610
|
+
async with Container(db_url) as c:
|
|
611
|
+
await c.scheduler.start()
|
|
612
|
+
console.print("[green]调度器已启动[/] — 按 Ctrl+C 停止")
|
|
613
|
+
try:
|
|
614
|
+
while True:
|
|
615
|
+
await asyncio.sleep(1)
|
|
616
|
+
except KeyboardInterrupt:
|
|
617
|
+
await c.scheduler.stop()
|
|
618
|
+
console.print("\n[yellow]调度器已停止[/]")
|
|
619
|
+
|
|
620
|
+
_run(_scheduler_loop())
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@schedule_app.command("list")
|
|
624
|
+
def schedule_list(
|
|
625
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
626
|
+
) -> None:
|
|
627
|
+
"""列出调度任务。"""
|
|
628
|
+
|
|
629
|
+
async def _do() -> None:
|
|
630
|
+
async with Container(db_url) as c:
|
|
631
|
+
jobs = await c.scheduler_job_repo.get_all()
|
|
632
|
+
if not jobs:
|
|
633
|
+
console.print("[dim]没有找到任何调度任务[/]")
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
table = Table(title="Scheduler Jobs", box=box.ROUNDED)
|
|
637
|
+
table.add_column("ID", style="dim", max_width=36)
|
|
638
|
+
table.add_column("Position ID", max_width=36)
|
|
639
|
+
table.add_column("Enabled")
|
|
640
|
+
table.add_column("Interval", justify="right")
|
|
641
|
+
table.add_column("Total Runs", justify="right")
|
|
642
|
+
table.add_column("Status")
|
|
643
|
+
table.add_column("Next Run")
|
|
644
|
+
|
|
645
|
+
for job in jobs:
|
|
646
|
+
table.add_row(
|
|
647
|
+
str(job.id),
|
|
648
|
+
str(job.position_id),
|
|
649
|
+
"✓" if job.enabled else "✗",
|
|
650
|
+
f"{job.interval_minutes}m",
|
|
651
|
+
str(job.total_runs or 0),
|
|
652
|
+
job.status or "—",
|
|
653
|
+
str(job.next_run or "—"),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
console.print(table)
|
|
657
|
+
|
|
658
|
+
_run(_do())
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ===================================================================
|
|
662
|
+
# dashboard
|
|
663
|
+
# ===================================================================
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@app.command("dashboard")
|
|
667
|
+
def dashboard(
|
|
668
|
+
db_url: Optional[str] = typer.Option(None, "--db", help="数据库 URL"),
|
|
669
|
+
) -> None:
|
|
670
|
+
"""查看仪表盘摘要。"""
|
|
671
|
+
|
|
672
|
+
async def _do() -> None:
|
|
673
|
+
async with Container(db_url) as c:
|
|
674
|
+
summary = await c.dashboard_service.get_dashboard_summary()
|
|
675
|
+
body = "\n".join([
|
|
676
|
+
f" Running Positions: {summary.get('running_positions', 0)}",
|
|
677
|
+
f" Today Loops: {summary.get('today_loops', 0)}",
|
|
678
|
+
f" Today Candidates: {summary.get('today_candidates', 0)}",
|
|
679
|
+
f" Today Emails: {summary.get('today_emails', 0)}",
|
|
680
|
+
f" Today Replies: {summary.get('today_replies', 0)}",
|
|
681
|
+
f" Today Errors: {summary.get('today_errors', 0)}",
|
|
682
|
+
])
|
|
683
|
+
console.print(Panel(body, title="Dashboard"))
|
|
684
|
+
|
|
685
|
+
_run(_do())
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ===================================================================
|
|
689
|
+
# graph
|
|
690
|
+
# ===================================================================
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@app.command("graph")
|
|
694
|
+
def graph() -> None:
|
|
695
|
+
"""显示 LangGraph 图结构 (ASCII)。"""
|
|
696
|
+
diagram = """\
|
|
697
|
+
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
698
|
+
│ Search │───▶│ Score │───▶│ Pipeline │
|
|
699
|
+
└─────────┘ └─────────┘ └─────────┘
|
|
700
|
+
│
|
|
701
|
+
┌─────────┐ ┌──────────┐ ┌──────▼───┐
|
|
702
|
+
│Evaluate │◀───│ Outreach │◀───│ Dedup │
|
|
703
|
+
└─────────┘ └──────────┘ └──────────┘
|
|
704
|
+
│
|
|
705
|
+
▼
|
|
706
|
+
[loop / stop]\
|
|
707
|
+
"""
|
|
708
|
+
console.print(Panel(diagram, title="LangGraph — Recruiting Loop"))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ===================================================================
|
|
712
|
+
# version
|
|
713
|
+
# ===================================================================
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@app.command("version")
|
|
717
|
+
def version() -> None:
|
|
718
|
+
"""显示版本信息。"""
|
|
719
|
+
console.print(f"loop-agent-cli v{__version__}")
|
|
720
|
+
console.print("[dim]Based on recruit-loop-agent core engine[/]")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ===================================================================
|
|
724
|
+
# Entry point
|
|
725
|
+
# ===================================================================
|
|
726
|
+
|
|
727
|
+
if __name__ == "__main__":
|
|
728
|
+
app()
|