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
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()