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
github_reachout/nodes.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LangGraph 节点函数模块。
|
|
3
|
+
|
|
4
|
+
节点列表:
|
|
5
|
+
1. plan — 规划本轮执行步骤
|
|
6
|
+
2. fetch — 获取 GitHub 用户信息和仓库
|
|
7
|
+
3. enrich — 读取 LinkedIn/个人主页等网页内容
|
|
8
|
+
4. summarize — LLM 总结技术方向
|
|
9
|
+
5. generate — LLM 生成触达邮件
|
|
10
|
+
6. validate — 校验邮件可发送性 + 技术方向匹配
|
|
11
|
+
7. send — 发送邮件
|
|
12
|
+
8. evaluate — 评估是否继续循环
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Dict, Any
|
|
18
|
+
|
|
19
|
+
from github_reachout.llm import LLMClient
|
|
20
|
+
from github_reachout.github_client import GitHubClient
|
|
21
|
+
from github_reachout.web_reader import WebReader
|
|
22
|
+
from github_reachout.email_client import EmailClient
|
|
23
|
+
from github_reachout.state import ReachoutState
|
|
24
|
+
|
|
25
|
+
STEP_ORDER = ["plan", "fetch", "enrich", "summarize", "generate", "validate", "send", "evaluate"]
|
|
26
|
+
ALL_STEPS = ["fetch", "enrich", "summarize", "generate", "validate", "send", "evaluate"]
|
|
27
|
+
VALID_STEPS = set(ALL_STEPS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── 公共辅助 ──
|
|
31
|
+
|
|
32
|
+
def _state_get(state: dict, *keys_with_defaults):
|
|
33
|
+
"""批量从 state 取值,返回 tuple。用法: persons, errors = _state_get(state, ('persons', []), ('errors', []))"""
|
|
34
|
+
return tuple(state.get(k, d) for k, d in keys_with_defaults)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── 1. plan ──
|
|
38
|
+
|
|
39
|
+
def plan_node(state: ReachoutState, llm: LLMClient) -> Dict[str, Any]:
|
|
40
|
+
"""规划本轮执行步骤。第 1 轮全量,后续 LLM 规划。"""
|
|
41
|
+
loop_count = state.get("loop_count", 0)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if loop_count == 0:
|
|
45
|
+
planned = list(ALL_STEPS)
|
|
46
|
+
else:
|
|
47
|
+
persons = state.get("persons", [])
|
|
48
|
+
errors = state.get("errors", [])
|
|
49
|
+
system = (
|
|
50
|
+
"你是一个流程规划专家。根据当前状态,决定下一轮需要执行哪些步骤。\n"
|
|
51
|
+
"可选步骤:fetch, enrich, summarize, generate, validate, send, evaluate\n"
|
|
52
|
+
"规则:\n"
|
|
53
|
+
"- evaluate 必须始终包含\n"
|
|
54
|
+
"- 如果有人未获取 GitHub 信息,需要 fetch\n"
|
|
55
|
+
"- 如果有人未读取网页内容,需要 enrich\n"
|
|
56
|
+
"- 如果有人未总结技术方向,需要 summarize\n"
|
|
57
|
+
"- 如果有人未生成邮件,需要 generate\n"
|
|
58
|
+
"- 如果有人未校验邮件,需要 validate\n"
|
|
59
|
+
"- 如果有邮件校验通过但未发送,需要 send\n"
|
|
60
|
+
"- 步骤必须按顺序执行\n"
|
|
61
|
+
"返回 JSON:{\"planned_steps\": [\"step1\", \"step2\", ...]}"
|
|
62
|
+
)
|
|
63
|
+
user_msg = f"循环次数: {loop_count}\n已处理人数: {len(persons)}\n错误数: {len(errors)}\n人员状态:\n"
|
|
64
|
+
for p in persons:
|
|
65
|
+
user_msg += f" - {p.get('login', '?')}: tech={'Y' if p.get('tech_direction') else 'N'}, ready={'Y' if p.get('email_ready') else 'N'}, sent={'Y' if p.get('email_sent') else 'N'}\n"
|
|
66
|
+
|
|
67
|
+
result = llm.chat_json(system, user_msg, temperature=0.1)
|
|
68
|
+
planned = [s for s in result.get("planned_steps", []) if s in VALID_STEPS]
|
|
69
|
+
if "evaluate" not in planned:
|
|
70
|
+
planned.append("evaluate")
|
|
71
|
+
|
|
72
|
+
return {"planned_steps": planned, "loop_count": loop_count}
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f" [plan] 规划失败,回退全量执行: {e}")
|
|
76
|
+
return {"planned_steps": list(ALL_STEPS), "loop_count": loop_count}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── 2. fetch ──
|
|
80
|
+
|
|
81
|
+
def fetch_node(state: ReachoutState, github: GitHubClient) -> Dict[str, Any]:
|
|
82
|
+
"""获取 GitHub 用户信息和仓库列表。增量处理:跳过已处理的 login。"""
|
|
83
|
+
login_ids, processed, existing_persons, errors = _state_get(
|
|
84
|
+
state, ("login_ids", []), ("processed_logins", []), ("persons", []), ("errors", []))
|
|
85
|
+
|
|
86
|
+
new_persons, new_processed, new_errors = [], list(processed), list(errors)
|
|
87
|
+
|
|
88
|
+
for login in login_ids:
|
|
89
|
+
if login in processed:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
print(f" [fetch] 获取 {login} 的 GitHub 信息...")
|
|
93
|
+
new_persons.append(github.fetch_person(login))
|
|
94
|
+
new_processed.append(login)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f" [fetch] 获取 {login} 失败: {e}")
|
|
97
|
+
new_errors.append({"step": "fetch", "login": login, "error": str(e)})
|
|
98
|
+
new_processed.append(login)
|
|
99
|
+
|
|
100
|
+
return {"persons": existing_persons + new_persons, "processed_logins": new_processed, "errors": new_errors}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── 3. enrich ──
|
|
104
|
+
|
|
105
|
+
def enrich_node(state: ReachoutState, web_reader: WebReader) -> Dict[str, Any]:
|
|
106
|
+
"""读取 LinkedIn/个人主页等网页内容。"""
|
|
107
|
+
persons, errors = _state_get(state, ("persons", []), ("errors", []))
|
|
108
|
+
|
|
109
|
+
for p in persons:
|
|
110
|
+
if p.get("web_summary"):
|
|
111
|
+
continue
|
|
112
|
+
linkedin_url, blog_url, github_url = p.get("linkedin_url", ""), p.get("blog", ""), p.get("html_url", "")
|
|
113
|
+
if not linkedin_url and not blog_url and not github_url:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
print(f" [enrich] 读取 {p.get('login', '?')} 的网页内容...")
|
|
117
|
+
web_text = web_reader.read_person_pages(linkedin_url=linkedin_url, github_url=github_url, blog_url=blog_url)
|
|
118
|
+
if web_text:
|
|
119
|
+
p["web_summary"] = web_text
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f" [enrich] 读取 {p.get('login', '?')} 网页失败: {e}")
|
|
122
|
+
errors.append({"step": "enrich", "login": p.get("login", "?"), "error": str(e)})
|
|
123
|
+
|
|
124
|
+
return {"persons": persons, "errors": errors}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── 4. summarize ──
|
|
128
|
+
|
|
129
|
+
def summarize_node(state: ReachoutState, llm: LLMClient) -> Dict[str, Any]:
|
|
130
|
+
"""LLM 总结每个人的技术方向。"""
|
|
131
|
+
persons, errors = _state_get(state, ("persons", []), ("errors", []))
|
|
132
|
+
|
|
133
|
+
for p in persons:
|
|
134
|
+
if p.get("tech_direction"):
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
print(f" [summarize] 总结 {p.get('login', '?')} 的技术方向...")
|
|
138
|
+
context_parts = []
|
|
139
|
+
for key, label in [("bio", "Bio"), ("company", "Company"), ("location", "Location")]:
|
|
140
|
+
if p.get(key):
|
|
141
|
+
context_parts.append(f"{label}: {p[key]}")
|
|
142
|
+
|
|
143
|
+
repos = p.get("repos", [])
|
|
144
|
+
if repos:
|
|
145
|
+
repo_desc = "\n".join(
|
|
146
|
+
f" - {r.get('name', '?')} ({r.get('language', '?')}, stars={r.get('stargazers_count', 0)}): {r.get('description', '')}"
|
|
147
|
+
for r in repos[:10])
|
|
148
|
+
context_parts.append(f"Top Repos:\n{repo_desc}")
|
|
149
|
+
|
|
150
|
+
web_summary = p.get("web_summary", "")
|
|
151
|
+
if web_summary:
|
|
152
|
+
context_parts.append(f"Web Content:\n{web_summary[:2000]}")
|
|
153
|
+
|
|
154
|
+
if not context_parts:
|
|
155
|
+
p["tech_direction"] = "unknown"
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
system = (
|
|
159
|
+
"你是一个技术方向分析专家。根据 GitHub 用户的仓库、bio、网页内容等信息,总结该用户的核心技术方向。\n"
|
|
160
|
+
"要求:\n- 用 3-5 个关键词描述技术方向\n- 识别主要编程语言和技术栈\n- 判断技术深度和领域专长\n"
|
|
161
|
+
"返回 JSON:{\"tech_direction\": \"关键词1, 关键词2, 关键词3\", \"primary_language\": \"xxx\", \"seniority\": \"junior/mid/senior/staff\"}"
|
|
162
|
+
)
|
|
163
|
+
result = llm.chat_json(system, f"GitHub 用户: {p.get('login', '?')}\n\n" + "\n\n".join(context_parts), temperature=0.1)
|
|
164
|
+
p["tech_direction"] = result.get("tech_direction", "unknown")
|
|
165
|
+
p["primary_language"] = result.get("primary_language", "")
|
|
166
|
+
p["seniority"] = result.get("seniority", "")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f" [summarize] 总结 {p.get('login', '?')} 失败: {e}")
|
|
169
|
+
p["tech_direction"] = p.get("tech_direction") or "unknown"
|
|
170
|
+
errors.append({"step": "summarize", "login": p.get("login", "?"), "error": str(e)})
|
|
171
|
+
|
|
172
|
+
return {"persons": persons, "errors": errors}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── 5. generate ──
|
|
176
|
+
|
|
177
|
+
def generate_node(state: ReachoutState, llm: LLMClient) -> Dict[str, Any]:
|
|
178
|
+
"""LLM 为每个人生成触达邮件。"""
|
|
179
|
+
persons, errors = _state_get(state, ("persons", []), ("errors", []))
|
|
180
|
+
target_tech = state.get("target_tech_direction", "")
|
|
181
|
+
company_name = state.get("company_name", "")
|
|
182
|
+
sender_name = state.get("sender_name", "")
|
|
183
|
+
sender_title = state.get("sender_title", "")
|
|
184
|
+
|
|
185
|
+
for p in persons:
|
|
186
|
+
if p.get("reachout_email"):
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
print(f" [generate] 生成 {p.get('login', '?')} 的触达邮件...")
|
|
190
|
+
context_parts = []
|
|
191
|
+
if p.get("name"):
|
|
192
|
+
context_parts.append(f"Name: {p['name']}")
|
|
193
|
+
context_parts.append(f"GitHub: {p.get('login', '?')}")
|
|
194
|
+
for key, label in [("tech_direction", "Tech Direction"), ("bio", "Bio"), ("company", "Current Company")]:
|
|
195
|
+
if p.get(key):
|
|
196
|
+
context_parts.append(f"{label}: {p[key]}")
|
|
197
|
+
|
|
198
|
+
repos = p.get("repos", [])
|
|
199
|
+
if repos:
|
|
200
|
+
repo_list = ", ".join(f"{r.get('name', '?')}({r.get('language', '?')})" for r in repos[:5])
|
|
201
|
+
context_parts.append(f"Notable Projects: {repo_list}")
|
|
202
|
+
|
|
203
|
+
system = (
|
|
204
|
+
"你是一个专业的技术招聘邮件撰写专家。根据候选人的技术背景,生成一封个性化的触达邮件。\n"
|
|
205
|
+
"要求:\n- 邮件必须完全写好,不能包含任何占位符\n- 用候选人的真实姓名或 GitHub ID 称呼\n"
|
|
206
|
+
"- 提及候选人的具体项目或技术方向\n- 语气专业但友好\n- 邮件长度 150-300 字\n"
|
|
207
|
+
"返回 JSON:{\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"
|
|
208
|
+
)
|
|
209
|
+
user_msg = (
|
|
210
|
+
f"候选人信息:\n" + "\n".join(context_parts) + "\n\n"
|
|
211
|
+
f"我方信息:\n- 公司: {company_name}\n- 联系人: {sender_name} ({sender_title})\n- 目标技术方向: {target_tech}\n"
|
|
212
|
+
)
|
|
213
|
+
result = llm.chat_json(system, user_msg, temperature=0.5)
|
|
214
|
+
p["reachout_email"] = json.dumps(result, ensure_ascii=False)
|
|
215
|
+
p["email_subject"] = result.get("subject", "")
|
|
216
|
+
p["email_body"] = result.get("body", "")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f" [generate] 生成 {p.get('login', '?')} 邮件失败: {e}")
|
|
219
|
+
errors.append({"step": "generate", "login": p.get("login", "?"), "error": str(e)})
|
|
220
|
+
|
|
221
|
+
return {"persons": persons, "errors": errors}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ── 6. validate ──
|
|
225
|
+
|
|
226
|
+
def validate_node(state: ReachoutState, llm: LLMClient) -> Dict[str, Any]:
|
|
227
|
+
"""校验邮件是否可直接发送:无占位符 + 技术方向匹配 + 有邮箱。"""
|
|
228
|
+
persons, errors = _state_get(state, ("persons", []), ("errors", []))
|
|
229
|
+
target_tech = state.get("target_tech_direction", "")
|
|
230
|
+
emails_to_send = []
|
|
231
|
+
|
|
232
|
+
for p in persons:
|
|
233
|
+
login = p.get("login", "?")
|
|
234
|
+
if p.get("email_ready"):
|
|
235
|
+
emails_to_send.append(p)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
email_body, email_subject = p.get("email_body", ""), p.get("email_subject", "")
|
|
239
|
+
if not email_body:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# 占位符检查
|
|
243
|
+
validation = EmailClient.validate_email_ready(email_body, email_subject)
|
|
244
|
+
if not validation["ready"]:
|
|
245
|
+
print(f" [validate] {login} 邮件校验不通过: {validation['issues']}")
|
|
246
|
+
p["email_ready"] = False
|
|
247
|
+
errors.append({"step": "validate", "login": login, "error": f"邮件校验失败: {validation['issues']}"})
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# 技术方向匹配
|
|
251
|
+
if target_tech:
|
|
252
|
+
try:
|
|
253
|
+
if not _check_tech_match(llm, p.get("tech_direction", ""), target_tech):
|
|
254
|
+
print(f" [validate] {login} 技术方向不匹配")
|
|
255
|
+
p["email_ready"] = False
|
|
256
|
+
p["tech_mismatch"] = True
|
|
257
|
+
continue
|
|
258
|
+
except Exception:
|
|
259
|
+
pass # 降级:匹配失败时仍允许发送
|
|
260
|
+
|
|
261
|
+
# 收件人邮箱
|
|
262
|
+
if not p.get("email"):
|
|
263
|
+
print(f" [validate] {login} 无公开邮箱")
|
|
264
|
+
p["email_ready"] = False
|
|
265
|
+
p["no_email"] = True
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
p["email_ready"] = True
|
|
269
|
+
emails_to_send.append(p)
|
|
270
|
+
print(f" [validate] {login} 邮件校验通过")
|
|
271
|
+
|
|
272
|
+
return {"persons": persons, "emails_to_send": emails_to_send, "errors": errors}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _check_tech_match(llm: LLMClient, tech_direction: str, target_tech: str) -> bool:
|
|
276
|
+
"""用 LLM 判断技术方向是否匹配。"""
|
|
277
|
+
result = llm.chat_json(
|
|
278
|
+
"判断候选人的技术方向是否与目标方向匹配。匹配标准:有至少一个核心技术领域重叠即可。\n"
|
|
279
|
+
"返回 JSON:{\"match\": true/false, \"reason\": \"简短说明\"}",
|
|
280
|
+
f"候选人技术方向: {tech_direction}\n目标技术方向: {target_tech}",
|
|
281
|
+
temperature=0.0,
|
|
282
|
+
)
|
|
283
|
+
return bool(result.get("match", False))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── 7. send ──
|
|
287
|
+
|
|
288
|
+
def send_node(state: ReachoutState, email_client: EmailClient) -> Dict[str, Any]:
|
|
289
|
+
"""发送校验通过的触达邮件。"""
|
|
290
|
+
persons, sent_results, errors = _state_get(
|
|
291
|
+
state, ("persons", []), ("sent_results", []), ("errors", []))
|
|
292
|
+
new_sent = list(sent_results)
|
|
293
|
+
|
|
294
|
+
for p in persons:
|
|
295
|
+
login = p.get("login", "?")
|
|
296
|
+
if p.get("email_sent") or not p.get("email_ready"):
|
|
297
|
+
continue
|
|
298
|
+
to_email = p.get("email", "")
|
|
299
|
+
if not to_email:
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
print(f" [send] 发送邮件给 {login} ({to_email})...")
|
|
304
|
+
result = email_client.send_email(to=to_email, subject=p.get("email_subject", ""), body=p.get("email_body", ""))
|
|
305
|
+
p["email_sent"] = result["success"]
|
|
306
|
+
p["email_sent_error"] = "" if result["success"] else result["error"]
|
|
307
|
+
new_sent.append({"login": login, "success": result["success"], "error": result.get("error", "")})
|
|
308
|
+
if not result["success"]:
|
|
309
|
+
errors.append({"step": "send", "login": login, "error": result["error"]})
|
|
310
|
+
print(f" [send] {'OK' if result['success'] else 'FAIL'} {login}")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
p["email_sent"] = False
|
|
313
|
+
p["email_sent_error"] = str(e)
|
|
314
|
+
new_sent.append({"login": login, "success": False, "error": str(e)})
|
|
315
|
+
errors.append({"step": "send", "login": login, "error": str(e)})
|
|
316
|
+
|
|
317
|
+
return {"persons": persons, "sent_results": new_sent, "errors": errors}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── 8. evaluate ──
|
|
321
|
+
|
|
322
|
+
def evaluate_node(state: ReachoutState, llm: LLMClient) -> Dict[str, Any]:
|
|
323
|
+
"""评估是否继续循环。"""
|
|
324
|
+
persons = state.get("persons", [])
|
|
325
|
+
login_ids = state.get("login_ids", [])
|
|
326
|
+
loop_count = state.get("loop_count", 0)
|
|
327
|
+
max_loops = state.get("max_loops", 3)
|
|
328
|
+
errors = state.get("errors", [])
|
|
329
|
+
loop_history = state.get("loop_history", [])
|
|
330
|
+
|
|
331
|
+
# 统计
|
|
332
|
+
email_sent = sum(1 for p in persons if p.get("email_sent"))
|
|
333
|
+
no_email = sum(1 for p in persons if p.get("no_email"))
|
|
334
|
+
tech_mismatch = sum(1 for p in persons if p.get("tech_mismatch"))
|
|
335
|
+
|
|
336
|
+
loop_history.append({
|
|
337
|
+
"loop": loop_count,
|
|
338
|
+
"total": len(login_ids),
|
|
339
|
+
"processed": len(persons),
|
|
340
|
+
"email_sent": email_sent,
|
|
341
|
+
"no_email": no_email,
|
|
342
|
+
"tech_mismatch": tech_mismatch,
|
|
343
|
+
"errors_count": len(errors),
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
# 硬性判断
|
|
347
|
+
all_done = len(persons) >= len(login_ids)
|
|
348
|
+
all_resolved = (email_sent + no_email + tech_mismatch) >= len(persons)
|
|
349
|
+
|
|
350
|
+
if (all_done and all_resolved) or loop_count >= max_loops - 1:
|
|
351
|
+
print(f" [evaluate] 结束循环 (已处理: {len(persons)}, 已发送: {email_sent})")
|
|
352
|
+
return {"continue_loop": False, "loop_count": loop_count + 1, "loop_history": loop_history}
|
|
353
|
+
|
|
354
|
+
print(f" [evaluate] 继续循环 (已处理: {len(persons)}/{len(login_ids)}, 已发送: {email_sent})")
|
|
355
|
+
return {"continue_loop": True, "loop_count": loop_count + 1, "loop_history": loop_history}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── 条件路由 ──
|
|
359
|
+
|
|
360
|
+
def route_after_plan(state: ReachoutState) -> str:
|
|
361
|
+
"""plan 后路由:按 STEP_ORDER 找第一个 planned 步骤。"""
|
|
362
|
+
planned = state.get("planned_steps", [])
|
|
363
|
+
for step in STEP_ORDER:
|
|
364
|
+
if step != "plan" and step in planned:
|
|
365
|
+
return step
|
|
366
|
+
return "evaluate"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def route_after_step(current_step: str):
|
|
370
|
+
"""步骤后路由:找 planned_steps 中当前步骤之后的第一个步骤。"""
|
|
371
|
+
def router(state: ReachoutState) -> str:
|
|
372
|
+
planned = state.get("planned_steps", [])
|
|
373
|
+
found = False
|
|
374
|
+
for step in STEP_ORDER:
|
|
375
|
+
if step == current_step:
|
|
376
|
+
found = True
|
|
377
|
+
continue
|
|
378
|
+
if found and step in planned:
|
|
379
|
+
return step
|
|
380
|
+
return "evaluate"
|
|
381
|
+
return router
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def route_after_evaluate(state: ReachoutState) -> str:
|
|
385
|
+
"""evaluate 后路由:继续循环或结束。"""
|
|
386
|
+
return "plan" if state.get("continue_loop", False) else "__end__"
|
github_reachout/state.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Github-reachout 状态定义模块。
|
|
3
|
+
|
|
4
|
+
定义 LangGraph 图中流转的 State 结构,
|
|
5
|
+
遵循设计哲学:状态驱动、单向流动、逐步丰富。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict, Optional, List, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PersonInfo(TypedDict, total=False):
|
|
13
|
+
"""单个 GitHub 用户的完整信息。"""
|
|
14
|
+
login: str # GitHub login id
|
|
15
|
+
name: str # 显示名称
|
|
16
|
+
bio: str # GitHub bio
|
|
17
|
+
location: str # 所在地
|
|
18
|
+
company: str # 公司
|
|
19
|
+
blog: str # 个人网站/博客
|
|
20
|
+
linkedin_url: str # LinkedIn 主页链接(从 bio/blog 等提取)
|
|
21
|
+
public_repos: int # 公开仓库数
|
|
22
|
+
followers: int # 粉丝数
|
|
23
|
+
avatar_url: str # 头像 URL
|
|
24
|
+
html_url: str # GitHub 主页 URL
|
|
25
|
+
repos: List[Dict[str, Any]] # 仓库列表 [{name, description, language, stars, url}]
|
|
26
|
+
web_summary: str # 网页阅读总结(LinkedIn/个人主页等)
|
|
27
|
+
tech_direction: str # LLM 总结的技术方向
|
|
28
|
+
email: str # 联系邮箱(从 GitHub profile 或网页获取)
|
|
29
|
+
reachout_email: str # 生成的触达邮件内容
|
|
30
|
+
email_ready: bool # 邮件是否可直接发送(无占位符、技术方向匹配)
|
|
31
|
+
email_sent: bool # 邮件是否已发送
|
|
32
|
+
email_sent_error: str # 发送失败时的错误信息
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReachoutState(TypedDict, total=False):
|
|
36
|
+
"""
|
|
37
|
+
Github-reachout 主状态。
|
|
38
|
+
|
|
39
|
+
数据流:login_ids → fetch → enrich → summarize → generate_email → validate → send
|
|
40
|
+
每个节点只关心自己需要的字段,返回自己负责的字段。
|
|
41
|
+
"""
|
|
42
|
+
# ── 输入 ──
|
|
43
|
+
login_ids: List[str] # 输入的 GitHub login id 列表
|
|
44
|
+
target_tech_direction: str # 目标技术方向(用于匹配和邮件生成)
|
|
45
|
+
company_name: str # 我方公司名称
|
|
46
|
+
sender_name: str # 发件人姓名
|
|
47
|
+
sender_title: str # 发件人职位
|
|
48
|
+
sender_email: str # 发件人邮箱(从 .env 读取)
|
|
49
|
+
|
|
50
|
+
# ── 规划 ──
|
|
51
|
+
planned_steps: List[str] # 本轮计划执行的步骤
|
|
52
|
+
loop_count: int # 当前循环次数
|
|
53
|
+
max_loops: int # 最大循环次数
|
|
54
|
+
continue_loop: bool # 是否继续循环
|
|
55
|
+
|
|
56
|
+
# ── 执行结果 ──
|
|
57
|
+
persons: List[PersonInfo] # 所有人员信息(逐步丰富)
|
|
58
|
+
processed_logins: List[str] # 已处理的 login id(增量去重)
|
|
59
|
+
|
|
60
|
+
# ── 邮件 ──
|
|
61
|
+
emails_to_send: List[Dict[str, Any]] # 待发送邮件列表 [{to, subject, body}]
|
|
62
|
+
sent_results: List[Dict[str, Any]] # 发送结果 [{login, success, error}]
|
|
63
|
+
|
|
64
|
+
# ── 循环控制 ──
|
|
65
|
+
loop_history: List[Dict[str, Any]] # 每轮循环摘要
|
|
66
|
+
errors: List[Dict[str, Any]] # 错误记录 [{step, login, error}]
|
|
67
|
+
|
|
68
|
+
# ── 断点续跑 ──
|
|
69
|
+
run_id: str # 运行 ID
|
|
70
|
+
step_index: int # 当前步骤序号
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
网页阅读器模块。
|
|
3
|
+
|
|
4
|
+
功能:
|
|
5
|
+
- 读取 LinkedIn/GitHub/个人博客等网页内容
|
|
6
|
+
- 提取纯文本(去除 HTML 标签)
|
|
7
|
+
- 速率控制
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebReader:
|
|
18
|
+
"""轻量级网页阅读器。"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, timeout: int = 30, max_length: int = 8000, delay: float = 1.0):
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
self.max_length = max_length
|
|
23
|
+
self.delay = delay
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers.update({
|
|
26
|
+
"User-Agent": (
|
|
27
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
28
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
29
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
30
|
+
),
|
|
31
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
32
|
+
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8",
|
|
33
|
+
})
|
|
34
|
+
self._last_request_time = 0.0
|
|
35
|
+
|
|
36
|
+
def fetch_page(self, url: str) -> str:
|
|
37
|
+
"""获取网页内容并提取纯文本。"""
|
|
38
|
+
elapsed = time.time() - self._last_request_time
|
|
39
|
+
if elapsed < self.delay:
|
|
40
|
+
time.sleep(self.delay - elapsed)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
resp = self.session.get(url, timeout=self.timeout, allow_redirects=True)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
self._last_request_time = time.time()
|
|
46
|
+
|
|
47
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
48
|
+
if "text/html" not in content_type and "text/plain" not in content_type:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
text = self._html_to_text(resp.text)
|
|
52
|
+
if len(text) > self.max_length:
|
|
53
|
+
text = text[:self.max_length] + "\n...[truncated]"
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
except requests.RequestException as e:
|
|
57
|
+
print(f" [WebReader] 获取网页失败 {url}: {e}")
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
def read_person_pages(
|
|
61
|
+
self,
|
|
62
|
+
linkedin_url: str = "",
|
|
63
|
+
github_url: str = "",
|
|
64
|
+
blog_url: str = "",
|
|
65
|
+
) -> str:
|
|
66
|
+
"""读取一个人的所有相关网页,合并为一段文本。"""
|
|
67
|
+
sources = [
|
|
68
|
+
("LinkedIn", linkedin_url),
|
|
69
|
+
("Personal Blog", blog_url),
|
|
70
|
+
("GitHub Profile", github_url),
|
|
71
|
+
]
|
|
72
|
+
parts = []
|
|
73
|
+
for label, url in sources:
|
|
74
|
+
if not url:
|
|
75
|
+
continue
|
|
76
|
+
# blog_url 可能没有协议前缀
|
|
77
|
+
if not url.startswith(("http://", "https://")):
|
|
78
|
+
url = f"https://{url}"
|
|
79
|
+
text = self.fetch_page(url)
|
|
80
|
+
if text:
|
|
81
|
+
parts.append(f"=== {label} ===\n{text}")
|
|
82
|
+
return "\n\n".join(parts)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _html_to_text(html: str) -> str:
|
|
86
|
+
"""将 HTML 转换为纯文本(不依赖 BeautifulSoup)。"""
|
|
87
|
+
# 移除 script/style
|
|
88
|
+
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
|
89
|
+
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
|
90
|
+
# 块级标签换行
|
|
91
|
+
text = re.sub(r'<br\s*/?\s*>', '\n', text, flags=re.IGNORECASE)
|
|
92
|
+
text = re.sub(r'</?(p|div|h[1-6]|li|tr|section|article)[^>]*>', '\n', text, flags=re.IGNORECASE)
|
|
93
|
+
# 移除标签
|
|
94
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
95
|
+
# HTML 实体
|
|
96
|
+
for entity, char in [("&", "&"), ("<", "<"), (">", ">"),
|
|
97
|
+
(""", '"'), ("'", "'"), (" ", " ")]:
|
|
98
|
+
text = text.replace(entity, char)
|
|
99
|
+
# 压缩空白
|
|
100
|
+
text = re.sub(r'[ \t]+', ' ', text)
|
|
101
|
+
text = re.sub(r'\n\s*\n', '\n\n', text)
|
|
102
|
+
return text.strip()
|