github-reachout 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.
- github_reachout/__init__.py +6 -0
- github_reachout/__main__.py +5 -0
- github_reachout/checkpoint.py +227 -0
- github_reachout/cli.py +232 -0
- github_reachout/email_client.py +158 -0
- github_reachout/github_client.py +178 -0
- github_reachout/graph.py +107 -0
- github_reachout/llm.py +132 -0
- github_reachout/nodes.py +386 -0
- github_reachout/state.py +70 -0
- github_reachout/web_reader.py +102 -0
- github_reachout-0.1.0.dist-info/METADATA +519 -0
- github_reachout-0.1.0.dist-info/RECORD +17 -0
- github_reachout-0.1.0.dist-info/WHEEL +5 -0
- github_reachout-0.1.0.dist-info/entry_points.txt +2 -0
- github_reachout-0.1.0.dist-info/licenses/LICENSE +21 -0
- github_reachout-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checkpoint 持久化模块。
|
|
3
|
+
|
|
4
|
+
功能:
|
|
5
|
+
- 每个步骤完成后自动保存 state 到本地 JSON 文件
|
|
6
|
+
- 支持 --resume 从断点恢复运行
|
|
7
|
+
- 搜索到的人员单独保存为独立文件(方便查看和复用)
|
|
8
|
+
|
|
9
|
+
存储结构:
|
|
10
|
+
.github_reachout_checkpoints/
|
|
11
|
+
└── {run_id}/
|
|
12
|
+
├── state.json # 完整 state 快照
|
|
13
|
+
├── step_{n}_{name}.json # 每步完成后的增量快照
|
|
14
|
+
└── persons/
|
|
15
|
+
├── {login}.json # 每个人的详细数据
|
|
16
|
+
└── all_persons.json # 所有人汇总
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from typing import Dict, Any, Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# 默认 checkpoint 根目录
|
|
26
|
+
DEFAULT_CHECKPOINT_DIR = ".github_reachout_checkpoints"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_run_dir(run_id: str, base_dir: str = DEFAULT_CHECKPOINT_DIR) -> str:
|
|
30
|
+
"""获取某次运行的 checkpoint 目录。"""
|
|
31
|
+
run_dir = os.path.join(base_dir, run_id)
|
|
32
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
33
|
+
return run_dir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_persons_dir(run_id: str, base_dir: str = DEFAULT_CHECKPOINT_DIR) -> str:
|
|
37
|
+
"""获取人员存储目录。"""
|
|
38
|
+
person_dir = os.path.join(get_run_dir(run_id, base_dir), "persons")
|
|
39
|
+
os.makedirs(person_dir, exist_ok=True)
|
|
40
|
+
return person_dir
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_checkpoint(
|
|
44
|
+
state: Dict[str, Any],
|
|
45
|
+
step_name: str,
|
|
46
|
+
step_index: int,
|
|
47
|
+
run_id: str,
|
|
48
|
+
base_dir: str = DEFAULT_CHECKPOINT_DIR,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""
|
|
51
|
+
保存 checkpoint:完整 state 快照 + 候选人独立文件。
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
state: 当前完整 state
|
|
55
|
+
step_name: 步骤名称(如 search, dedup, score 等)
|
|
56
|
+
step_index: 步骤序号
|
|
57
|
+
run_id: 运行 ID
|
|
58
|
+
base_dir: checkpoint 根目录
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
保存的 checkpoint 文件路径
|
|
62
|
+
"""
|
|
63
|
+
run_dir = get_run_dir(run_id, base_dir)
|
|
64
|
+
|
|
65
|
+
# 1. 保存步骤快照
|
|
66
|
+
step_file = os.path.join(run_dir, f"step_{step_index:03d}_{step_name}.json")
|
|
67
|
+
_write_json(step_file, state)
|
|
68
|
+
|
|
69
|
+
# 2. 更新最新 state(始终指向最新状态)
|
|
70
|
+
latest_file = os.path.join(run_dir, "state.json")
|
|
71
|
+
_write_json(latest_file, state)
|
|
72
|
+
|
|
73
|
+
# 3. 如果有人员数据,单独保存
|
|
74
|
+
persons = state.get("persons", [])
|
|
75
|
+
if persons and step_name in ("fetch", "enrich", "summarize"):
|
|
76
|
+
_save_persons(persons, run_id, base_dir)
|
|
77
|
+
|
|
78
|
+
# 4. 如果有邮件结果,也保存
|
|
79
|
+
sent_results = state.get("sent_results", [])
|
|
80
|
+
if sent_results and step_name in ("validate", "send"):
|
|
81
|
+
sent_file = os.path.join(run_dir, "sent_results.json")
|
|
82
|
+
_write_json(sent_file, sent_results)
|
|
83
|
+
|
|
84
|
+
return step_file
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _save_persons(
|
|
88
|
+
persons: list,
|
|
89
|
+
run_id: str,
|
|
90
|
+
base_dir: str = DEFAULT_CHECKPOINT_DIR,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""保存人员数据:汇总文件 + 独立文件。"""
|
|
93
|
+
person_dir = get_persons_dir(run_id, base_dir)
|
|
94
|
+
|
|
95
|
+
# 汇总文件
|
|
96
|
+
all_file = os.path.join(person_dir, "all_persons.json")
|
|
97
|
+
_write_json(all_file, persons)
|
|
98
|
+
|
|
99
|
+
# 独立文件(每个人一个 JSON)
|
|
100
|
+
for p in persons:
|
|
101
|
+
login = str(p.get("login") or p.get("id") or "unknown")
|
|
102
|
+
if login and login != "unknown":
|
|
103
|
+
p_file = os.path.join(person_dir, f"{login}.json")
|
|
104
|
+
# 不覆盖已有文件(去重后可能重复保存)
|
|
105
|
+
if not os.path.exists(p_file):
|
|
106
|
+
_write_json(p_file, p)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_checkpoint(run_id: str, base_dir: str = DEFAULT_CHECKPOINT_DIR) -> Optional[Dict[str, Any]]:
|
|
110
|
+
"""
|
|
111
|
+
加载最新的 checkpoint state。
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
run_id: 运行 ID
|
|
115
|
+
base_dir: checkpoint 根目录
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
最新的 state dict,如果不存在返回 None
|
|
119
|
+
"""
|
|
120
|
+
latest_file = os.path.join(get_run_dir(run_id, base_dir), "state.json")
|
|
121
|
+
if not os.path.isfile(latest_file):
|
|
122
|
+
return None
|
|
123
|
+
return _read_json(latest_file)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_checkpoints(base_dir: str = DEFAULT_CHECKPOINT_DIR) -> list:
|
|
127
|
+
"""
|
|
128
|
+
列出所有可恢复的 checkpoint 运行。
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
[{"run_id": "...", "timestamp": "...", "step_count": N, "latest_step": "..."}]
|
|
132
|
+
"""
|
|
133
|
+
if not os.path.isdir(base_dir):
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
results = []
|
|
137
|
+
for run_id in os.listdir(base_dir):
|
|
138
|
+
run_dir = os.path.join(base_dir, run_id)
|
|
139
|
+
if not os.path.isdir(run_dir):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
state_file = os.path.join(run_dir, "state.json")
|
|
143
|
+
if not os.path.isfile(state_file):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
state = _read_json(state_file)
|
|
148
|
+
# 统计步骤快照数
|
|
149
|
+
step_files = [f for f in os.listdir(run_dir) if f.startswith("step_") and f.endswith(".json")]
|
|
150
|
+
results.append({
|
|
151
|
+
"run_id": run_id,
|
|
152
|
+
"target_tech": state.get("target_tech_direction", "N/A"),
|
|
153
|
+
"loop_count": state.get("loop_count", 0),
|
|
154
|
+
"continue_loop": state.get("continue_loop", False),
|
|
155
|
+
"step_count": len(step_files),
|
|
156
|
+
"persons_count": len(state.get("persons", [])),
|
|
157
|
+
"sent_count": len(state.get("sent_results", [])),
|
|
158
|
+
"errors_count": len(state.get("errors", [])),
|
|
159
|
+
})
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_resume_info(run_id: str, base_dir: str = DEFAULT_CHECKPOINT_DIR) -> Optional[Dict[str, Any]]:
|
|
167
|
+
"""
|
|
168
|
+
获取断点续跑信息:当前执行到哪一步、下一步应该执行什么。
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
{"run_id": "...", "completed_steps": [...], "next_step": "...", "loop_count": N}
|
|
172
|
+
"""
|
|
173
|
+
state = load_checkpoint(run_id, base_dir)
|
|
174
|
+
if not state:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
run_dir = get_run_dir(run_id, base_dir)
|
|
178
|
+
step_files = sorted([f for f in os.listdir(run_dir) if f.startswith("step_") and f.endswith(".json")])
|
|
179
|
+
|
|
180
|
+
completed_steps = []
|
|
181
|
+
for f in step_files:
|
|
182
|
+
# step_001_search.json -> search
|
|
183
|
+
parts = f.replace(".json", "").split("_", 2)
|
|
184
|
+
if len(parts) >= 3:
|
|
185
|
+
completed_steps.append(parts[2])
|
|
186
|
+
|
|
187
|
+
# 确定下一步
|
|
188
|
+
all_steps = ["plan", "fetch", "enrich", "summarize", "generate", "validate", "send", "evaluate"]
|
|
189
|
+
planned = state.get("planned_steps", all_steps)
|
|
190
|
+
next_step = None
|
|
191
|
+
for s in planned:
|
|
192
|
+
if s not in completed_steps:
|
|
193
|
+
next_step = s
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"run_id": run_id,
|
|
198
|
+
"completed_steps": completed_steps,
|
|
199
|
+
"next_step": next_step,
|
|
200
|
+
"loop_count": state.get("loop_count", 0),
|
|
201
|
+
"continue_loop": state.get("continue_loop", False),
|
|
202
|
+
"state": state,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cleanup_checkpoint(run_id: str, base_dir: str = DEFAULT_CHECKPOINT_DIR) -> None:
|
|
207
|
+
"""删除某次运行的 checkpoint 数据。"""
|
|
208
|
+
import shutil
|
|
209
|
+
run_dir = os.path.join(base_dir, run_id)
|
|
210
|
+
if os.path.isdir(run_dir):
|
|
211
|
+
shutil.rmtree(run_dir)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ──────────────────────────────────────────────
|
|
215
|
+
# 内部辅助
|
|
216
|
+
# ──────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def _write_json(path: str, data: Any) -> None:
|
|
219
|
+
"""写入 JSON 文件。"""
|
|
220
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
221
|
+
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_json(path: str) -> Any:
|
|
225
|
+
"""读取 JSON 文件。"""
|
|
226
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
227
|
+
return json.load(f)
|
github_reachout/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Github-reachout CLI 入口。
|
|
3
|
+
|
|
4
|
+
命令:
|
|
5
|
+
run — 执行触达流程
|
|
6
|
+
resume — 从断点恢复
|
|
7
|
+
list — 列出所有 checkpoint
|
|
8
|
+
show — 查看某次运行详情
|
|
9
|
+
cleanup — 删除 checkpoint
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, List
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
load_dotenv()
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="github-reachout",
|
|
26
|
+
help="GitHub 人才触达工具:获取 GitHub 信息 → 总结技术方向 → 生成触达邮件 → 发送",
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _init_components(dry_run: bool = False):
|
|
32
|
+
"""初始化所有组件。"""
|
|
33
|
+
from github_reachout.llm import LLMClient
|
|
34
|
+
from github_reachout.github_client import GitHubClient
|
|
35
|
+
from github_reachout.web_reader import WebReader
|
|
36
|
+
from github_reachout.email_client import EmailClient
|
|
37
|
+
|
|
38
|
+
llm = LLMClient()
|
|
39
|
+
github = GitHubClient()
|
|
40
|
+
web_reader = WebReader()
|
|
41
|
+
email_client = EmailClient()
|
|
42
|
+
|
|
43
|
+
if dry_run:
|
|
44
|
+
class DryRunEmailClient(EmailClient):
|
|
45
|
+
def send_email(self, to, subject, body, html=False):
|
|
46
|
+
typer.echo(f" [DRY RUN] 邮件将发送给: {to}, 主题: {subject}")
|
|
47
|
+
return {"success": True, "error": ""}
|
|
48
|
+
email_client = DryRunEmailClient()
|
|
49
|
+
|
|
50
|
+
return llm, github, web_reader, email_client
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_graph(state: dict, compiled_graph, run_id: str, step_index: int = 0) -> dict:
|
|
54
|
+
"""执行图并保存 checkpoint,返回最终 state。"""
|
|
55
|
+
from github_reachout.checkpoint import save_checkpoint
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
for output_state in compiled_graph.stream(state):
|
|
59
|
+
for node_name, node_output in output_state.items():
|
|
60
|
+
step_index += 1
|
|
61
|
+
typer.echo(f"\n── 步骤 {step_index}: {node_name} ──")
|
|
62
|
+
state.update(node_output)
|
|
63
|
+
save_checkpoint(state, node_name, step_index, run_id)
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
typer.echo("\n\n中断!已保存 checkpoint,可用 --resume 恢复")
|
|
66
|
+
save_checkpoint(state, "interrupted", step_index, run_id)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
typer.echo(f"\n运行出错: {e}")
|
|
70
|
+
save_checkpoint(state, "error", step_index, run_id)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
return state
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _print_summary(state: dict) -> None:
|
|
77
|
+
"""打印运行结果摘要。"""
|
|
78
|
+
persons = state.get("persons", [])
|
|
79
|
+
errors = state.get("errors", [])
|
|
80
|
+
|
|
81
|
+
typer.echo("\n" + "=" * 60)
|
|
82
|
+
typer.echo("运行结果摘要")
|
|
83
|
+
typer.echo("=" * 60)
|
|
84
|
+
typer.echo(f"\n总人数: {len(persons)}")
|
|
85
|
+
|
|
86
|
+
for person in persons:
|
|
87
|
+
login = person.get("login", "?")
|
|
88
|
+
name = person.get("name", "") or login
|
|
89
|
+
tech = person.get("tech_direction", "unknown")
|
|
90
|
+
email_sent = person.get("email_sent", False)
|
|
91
|
+
no_email = person.get("no_email", False)
|
|
92
|
+
tech_mismatch = person.get("tech_mismatch", False)
|
|
93
|
+
email_ready = person.get("email_ready", False)
|
|
94
|
+
|
|
95
|
+
if email_sent:
|
|
96
|
+
status = "已发送"
|
|
97
|
+
elif no_email:
|
|
98
|
+
status = "无邮箱"
|
|
99
|
+
elif tech_mismatch:
|
|
100
|
+
status = "方向不匹配"
|
|
101
|
+
elif not email_ready:
|
|
102
|
+
status = "校验未通过"
|
|
103
|
+
else:
|
|
104
|
+
status = "待发送"
|
|
105
|
+
|
|
106
|
+
typer.echo(f"\n {name} (@{login})")
|
|
107
|
+
typer.echo(f" 技术方向: {tech}")
|
|
108
|
+
typer.echo(f" 状态: {status}")
|
|
109
|
+
if person.get("email_body"):
|
|
110
|
+
typer.echo(f" 邮件主题: {person.get('email_subject', 'N/A')}")
|
|
111
|
+
|
|
112
|
+
if errors:
|
|
113
|
+
typer.echo(f"\n错误数: {len(errors)}")
|
|
114
|
+
for err in errors[:5]:
|
|
115
|
+
typer.echo(f" - [{err.get('step', '?')}] {err.get('login', '?')}: {err.get('error', '?')}")
|
|
116
|
+
typer.echo("")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def run(
|
|
121
|
+
login_ids: List[str] = typer.Argument(..., help="GitHub login ID 列表"),
|
|
122
|
+
target_tech: str = typer.Option("", "--target-tech", "-t", help="目标技术方向"),
|
|
123
|
+
company: str = typer.Option("", "--company", "-c", help="我方公司名称"),
|
|
124
|
+
sender_name: str = typer.Option("", "--sender-name", "-n", help="发件人姓名"),
|
|
125
|
+
sender_title: str = typer.Option("", "--sender-title", help="发件人职位"),
|
|
126
|
+
max_loops: int = typer.Option(3, "--max-loops", "-l", help="最大循环次数"),
|
|
127
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-d", help="试运行:不实际发送邮件"),
|
|
128
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="输出结果到 JSON 文件"),
|
|
129
|
+
):
|
|
130
|
+
"""执行 GitHub 人才触达流程。"""
|
|
131
|
+
from github_reachout.graph import build_graph, create_initial_state
|
|
132
|
+
|
|
133
|
+
# 从 .env 补全参数
|
|
134
|
+
sender_email = os.getenv("SENDER_EMAIL", "")
|
|
135
|
+
company = company or os.getenv("COMPANY_NAME", "")
|
|
136
|
+
sender_name = sender_name or os.getenv("SENDER_NAME", "")
|
|
137
|
+
sender_title = sender_title or os.getenv("SENDER_TITLE", "")
|
|
138
|
+
target_tech = target_tech or os.getenv("TARGET_TECH", "")
|
|
139
|
+
|
|
140
|
+
run_id = uuid.uuid4().hex[:8]
|
|
141
|
+
|
|
142
|
+
typer.echo(f"Github-reachout 启动 (run_id: {run_id})")
|
|
143
|
+
typer.echo(f" 目标人数: {len(login_ids)} | 技术方向: {target_tech or '未指定'} | 公司: {company or '未指定'}")
|
|
144
|
+
typer.echo(f" 发件人: {sender_name or '未指定'} | 最大循环: {max_loops} | 试运行: {'是' if dry_run else '否'}")
|
|
145
|
+
|
|
146
|
+
llm, github, web_reader, email_client = _init_components(dry_run)
|
|
147
|
+
compiled_graph = build_graph(llm, github, web_reader, email_client)
|
|
148
|
+
|
|
149
|
+
state = create_initial_state(
|
|
150
|
+
login_ids=login_ids, target_tech_direction=target_tech,
|
|
151
|
+
company_name=company, sender_name=sender_name,
|
|
152
|
+
sender_title=sender_title, sender_email=sender_email,
|
|
153
|
+
max_loops=max_loops, run_id=run_id,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
state = _run_graph(state, compiled_graph, run_id)
|
|
157
|
+
_print_summary(state)
|
|
158
|
+
|
|
159
|
+
if output:
|
|
160
|
+
Path(output).write_text(json.dumps(state, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
|
161
|
+
typer.echo(f"结果已保存到: {output}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def resume(
|
|
166
|
+
run_id: str = typer.Argument(..., help="要恢复的运行 ID"),
|
|
167
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-d", help="试运行"),
|
|
168
|
+
):
|
|
169
|
+
"""从断点恢复运行。"""
|
|
170
|
+
from github_reachout.graph import build_graph
|
|
171
|
+
from github_reachout.checkpoint import load_checkpoint
|
|
172
|
+
|
|
173
|
+
state = load_checkpoint(run_id)
|
|
174
|
+
if not state:
|
|
175
|
+
typer.echo(f"未找到运行 ID: {run_id}")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
|
|
178
|
+
typer.echo(f"恢复运行 (run_id: {run_id})")
|
|
179
|
+
typer.echo(f" 已处理人数: {len(state.get('persons', []))} | 循环次数: {state.get('loop_count', 0)}")
|
|
180
|
+
|
|
181
|
+
llm, github, web_reader, email_client = _init_components(dry_run)
|
|
182
|
+
compiled_graph = build_graph(llm, github, web_reader, email_client)
|
|
183
|
+
|
|
184
|
+
state = _run_graph(state, compiled_graph, run_id, state.get("step_index", 0))
|
|
185
|
+
_print_summary(state)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command(name="list")
|
|
189
|
+
def list_runs():
|
|
190
|
+
"""列出所有可恢复的 checkpoint 运行。"""
|
|
191
|
+
from github_reachout.checkpoint import list_checkpoints
|
|
192
|
+
|
|
193
|
+
checkpoints = list_checkpoints()
|
|
194
|
+
if not checkpoints:
|
|
195
|
+
typer.echo("没有找到任何 checkpoint 运行记录")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
typer.echo(f"{'Run ID':<12} {'技术方向':<16} {'循环':<6} {'步骤':<6} {'人数':<6} {'已发送':<8}")
|
|
199
|
+
typer.echo("-" * 56)
|
|
200
|
+
for cp in checkpoints:
|
|
201
|
+
typer.echo(
|
|
202
|
+
f"{cp.get('run_id', '?'):<12} "
|
|
203
|
+
f"{cp.get('target_tech', 'N/A'):<16} "
|
|
204
|
+
f"{cp.get('loop_count', 0):<6} "
|
|
205
|
+
f"{cp.get('step_count', 0):<6} "
|
|
206
|
+
f"{cp.get('persons_count', 0):<6} "
|
|
207
|
+
f"{cp.get('sent_count', 0):<8}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def show(run_id: str = typer.Argument(..., help="运行 ID")):
|
|
213
|
+
"""查看某次运行的详细信息。"""
|
|
214
|
+
from github_reachout.checkpoint import load_checkpoint
|
|
215
|
+
|
|
216
|
+
state = load_checkpoint(run_id)
|
|
217
|
+
if not state:
|
|
218
|
+
typer.echo(f"未找到运行 ID: {run_id}")
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
typer.echo(json.dumps(state, ensure_ascii=False, indent=2, default=str))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def cleanup(run_id: str = typer.Argument(..., help="要删除的运行 ID")):
|
|
225
|
+
"""删除某次运行的 checkpoint 数据。"""
|
|
226
|
+
from github_reachout.checkpoint import cleanup_checkpoint
|
|
227
|
+
cleanup_checkpoint(run_id)
|
|
228
|
+
typer.echo(f"已删除运行 {run_id} 的 checkpoint 数据")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
app()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
邮件客户端模块。
|
|
3
|
+
|
|
4
|
+
功能:
|
|
5
|
+
- 通过 SMTP 发送触达邮件
|
|
6
|
+
- 从 .env 读取邮箱配置
|
|
7
|
+
- 支持 HTML 和纯文本格式
|
|
8
|
+
- 发送前校验(无占位符、收件人有效)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import smtplib
|
|
15
|
+
from email.mime.text import MIMEText
|
|
16
|
+
from email.mime.multipart import MIMEMultipart
|
|
17
|
+
from typing import Optional, Dict, Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmailClient:
|
|
21
|
+
"""SMTP 邮件客户端,用于发送触达邮件。"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
smtp_host: Optional[str] = None,
|
|
26
|
+
smtp_port: Optional[int] = None,
|
|
27
|
+
smtp_user: Optional[str] = None,
|
|
28
|
+
smtp_password: Optional[str] = None,
|
|
29
|
+
sender_email: Optional[str] = None,
|
|
30
|
+
sender_name: Optional[str] = None,
|
|
31
|
+
use_ssl: Optional[bool] = None,
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Args:
|
|
35
|
+
smtp_host: SMTP 服务器地址
|
|
36
|
+
smtp_port: SMTP 服务器端口
|
|
37
|
+
smtp_user: SMTP 登录用户名
|
|
38
|
+
smtp_password: SMTP 登录密码/授权码
|
|
39
|
+
sender_email: 发件人邮箱地址
|
|
40
|
+
sender_name: 发件人姓名
|
|
41
|
+
use_ssl: 是否使用 SSL(默认根据端口判断)
|
|
42
|
+
"""
|
|
43
|
+
self.smtp_host = smtp_host or os.getenv("SMTP_HOST", "smtp.gmail.com")
|
|
44
|
+
self.smtp_port = int(smtp_port or os.getenv("SMTP_PORT", "587"))
|
|
45
|
+
self.smtp_user = smtp_user or os.getenv("SMTP_USER", "")
|
|
46
|
+
self.smtp_password = smtp_password or os.getenv("SMTP_PASSWORD", "")
|
|
47
|
+
self.sender_email = sender_email or os.getenv("SENDER_EMAIL", self.smtp_user)
|
|
48
|
+
self.sender_name = sender_name or os.getenv("SENDER_NAME", "")
|
|
49
|
+
self.use_ssl = use_ssl if use_ssl is not None else (self.smtp_port == 465)
|
|
50
|
+
|
|
51
|
+
def send_email(
|
|
52
|
+
self,
|
|
53
|
+
to: str,
|
|
54
|
+
subject: str,
|
|
55
|
+
body: str,
|
|
56
|
+
html: bool = False,
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
发送一封邮件。
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
to: 收件人邮箱
|
|
63
|
+
subject: 邮件主题
|
|
64
|
+
body: 邮件正文
|
|
65
|
+
html: 是否为 HTML 格式
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
{"success": bool, "error": str}
|
|
69
|
+
"""
|
|
70
|
+
if not self.smtp_user or not self.smtp_password:
|
|
71
|
+
return {
|
|
72
|
+
"success": False,
|
|
73
|
+
"error": "SMTP 凭据未配置,请设置 SMTP_USER 和 SMTP_PASSWORD 环境变量",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if not to or "@" not in to:
|
|
77
|
+
return {"success": False, "error": f"无效的收件人邮箱: {to}"}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
msg = MIMEMultipart("alternative")
|
|
81
|
+
msg["From"] = f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email
|
|
82
|
+
msg["To"] = to
|
|
83
|
+
msg["Subject"] = subject
|
|
84
|
+
|
|
85
|
+
if html:
|
|
86
|
+
msg.attach(MIMEText(body, "html", "utf-8"))
|
|
87
|
+
else:
|
|
88
|
+
msg.attach(MIMEText(body, "plain", "utf-8"))
|
|
89
|
+
|
|
90
|
+
# 连接 SMTP 服务器并发送
|
|
91
|
+
if self.use_ssl:
|
|
92
|
+
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=30)
|
|
93
|
+
else:
|
|
94
|
+
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=30)
|
|
95
|
+
server.ehlo()
|
|
96
|
+
server.starttls()
|
|
97
|
+
server.ehlo()
|
|
98
|
+
|
|
99
|
+
server.login(self.smtp_user, self.smtp_password)
|
|
100
|
+
server.sendmail(self.sender_email, [to], msg.as_string())
|
|
101
|
+
server.quit()
|
|
102
|
+
|
|
103
|
+
return {"success": True, "error": ""}
|
|
104
|
+
|
|
105
|
+
except smtplib.SMTPAuthenticationError as e:
|
|
106
|
+
return {"success": False, "error": f"SMTP 认证失败: {e}"}
|
|
107
|
+
except smtplib.SMTPException as e:
|
|
108
|
+
return {"success": False, "error": f"SMTP 发送失败: {e}"}
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return {"success": False, "error": f"邮件发送异常: {e}"}
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def validate_email_ready(email_body: str, subject: str = "", to: str = "") -> Dict[str, Any]:
|
|
114
|
+
"""
|
|
115
|
+
校验邮件是否可以直接发送。
|
|
116
|
+
|
|
117
|
+
检查项:
|
|
118
|
+
1. 无占位符(如 {name}, [Company], {{xxx}} 等)
|
|
119
|
+
2. 收件人邮箱有效
|
|
120
|
+
3. 主题和正文不为空
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
{"ready": bool, "issues": [str]}
|
|
124
|
+
"""
|
|
125
|
+
issues = []
|
|
126
|
+
|
|
127
|
+
# 检查占位符
|
|
128
|
+
placeholder_patterns = [
|
|
129
|
+
r'\{[a-zA-Z_][a-zA-Z0-9_]*\}', # {name}, {company}
|
|
130
|
+
r'\{\{[a-zA-Z_][a-zA-Z0-9_]*\}\}', # {{name}}, {{company}}
|
|
131
|
+
r'\[[A-Z][A-Z_]*\]', # [NAME], [COMPANY]
|
|
132
|
+
r'<<[a-zA-Z_]+>>', # <<name>>
|
|
133
|
+
]
|
|
134
|
+
for pattern in placeholder_patterns:
|
|
135
|
+
matches = re.findall(pattern, email_body)
|
|
136
|
+
if matches:
|
|
137
|
+
issues.append(f"邮件正文包含占位符: {matches}")
|
|
138
|
+
|
|
139
|
+
# 检查主题占位符
|
|
140
|
+
for pattern in placeholder_patterns:
|
|
141
|
+
matches = re.findall(pattern, subject)
|
|
142
|
+
if matches:
|
|
143
|
+
issues.append(f"邮件主题包含占位符: {matches}")
|
|
144
|
+
|
|
145
|
+
# 检查收件人
|
|
146
|
+
if to and "@" not in to:
|
|
147
|
+
issues.append(f"收件人邮箱无效: {to}")
|
|
148
|
+
|
|
149
|
+
# 检查非空
|
|
150
|
+
if not email_body.strip():
|
|
151
|
+
issues.append("邮件正文为空")
|
|
152
|
+
if subject and not subject.strip():
|
|
153
|
+
issues.append("邮件主题为空")
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"ready": len(issues) == 0,
|
|
157
|
+
"issues": issues,
|
|
158
|
+
}
|