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.
@@ -0,0 +1,178 @@
1
+ """
2
+ GitHub API 客户端模块。
3
+
4
+ 功能:
5
+ - 获取用户基本信息(/users/{login})
6
+ - 获取用户公开仓库列表(/users/{login}/repos)
7
+ - 从 bio/blog 等字段中提取 LinkedIn URL
8
+ - 速率控制:遵守 GitHub API 限制
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import time
15
+ from typing import Optional, Dict, List, Any
16
+
17
+ import requests
18
+
19
+
20
+ class GitHubClient:
21
+ """轻量级 GitHub API 客户端,支持认证和速率控制。"""
22
+
23
+ API_BASE = "https://api.github.com"
24
+
25
+ def __init__(
26
+ self,
27
+ token: Optional[str] = None,
28
+ per_page: int = 30,
29
+ max_repos: int = 10,
30
+ ):
31
+ """
32
+ Args:
33
+ token: GitHub Personal Access Token(可选,不传则使用匿名请求,限制更严)
34
+ per_page: 每页返回数量
35
+ max_repos: 最多获取的仓库数量
36
+ """
37
+ self.token = token or os.getenv("GITHUB_TOKEN", "")
38
+ self.per_page = per_page
39
+ self.max_repos = max_repos
40
+ self.session = requests.Session()
41
+ self.session.headers.update({
42
+ "Accept": "application/vnd.github.v3+json",
43
+ })
44
+ if self.token:
45
+ self.session.headers.update({
46
+ "Authorization": f"token {self.token}",
47
+ })
48
+
49
+ def get_user(self, login: str) -> Dict[str, Any]:
50
+ """
51
+ 获取用户基本信息。
52
+
53
+ Returns:
54
+ {login, name, bio, location, company, blog, public_repos,
55
+ followers, avatar_url, html_url, email}
56
+ """
57
+ resp = self.session.get(f"{self.API_BASE}/users/{login}", timeout=30)
58
+ resp.raise_for_status()
59
+ data = resp.json()
60
+
61
+ return {
62
+ "login": data.get("login", login),
63
+ "name": data.get("name", "") or "",
64
+ "bio": data.get("bio", "") or "",
65
+ "location": data.get("location", "") or "",
66
+ "company": data.get("company", "") or "",
67
+ "blog": data.get("blog", "") or "",
68
+ "public_repos": data.get("public_repos", 0),
69
+ "followers": data.get("followers", 0),
70
+ "avatar_url": data.get("avatar_url", ""),
71
+ "html_url": data.get("html_url", f"https://github.com/{login}"),
72
+ "email": data.get("email", "") or "",
73
+ }
74
+
75
+ def get_user_repos(self, login: str) -> List[Dict[str, Any]]:
76
+ """
77
+ 获取用户公开仓库列表(按 star 数降序,最多 max_repos 个)。
78
+
79
+ Returns:
80
+ [{name, description, language, stargazers_count, html_url, fork}]
81
+ """
82
+ repos = []
83
+ page = 1
84
+
85
+ while len(repos) < self.max_repos:
86
+ resp = self.session.get(
87
+ f"{self.API_BASE}/users/{login}/repos",
88
+ params={
89
+ "type": "public",
90
+ "sort": "updated",
91
+ "per_page": self.per_page,
92
+ "page": page,
93
+ },
94
+ timeout=30,
95
+ )
96
+ resp.raise_for_status()
97
+ page_data = resp.json()
98
+
99
+ if not page_data:
100
+ break
101
+
102
+ for repo in page_data:
103
+ if len(repos) >= self.max_repos:
104
+ break
105
+ # 跳过 fork 的仓库
106
+ if repo.get("fork", False):
107
+ continue
108
+ repos.append({
109
+ "name": repo.get("name", ""),
110
+ "description": repo.get("description", "") or "",
111
+ "language": repo.get("language", "") or "",
112
+ "stargazers_count": repo.get("stargazers_count", 0),
113
+ "html_url": repo.get("html_url", ""),
114
+ "fork": repo.get("fork", False),
115
+ })
116
+
117
+ page += 1
118
+ # 速率控制
119
+ time.sleep(0.5)
120
+
121
+ # 按 star 数降序排列
122
+ repos.sort(key=lambda r: r.get("stargazers_count", 0), reverse=True)
123
+ return repos
124
+
125
+ def fetch_person(self, login: str) -> Dict[str, Any]:
126
+ """
127
+ 获取一个用户的完整信息(基本信息 + 仓库列表 + LinkedIn 提取)。
128
+
129
+ Returns:
130
+ PersonInfo 字典
131
+ """
132
+ user = self.get_user(login)
133
+ repos = self.get_user_repos(login)
134
+
135
+ # 从 bio/blog 中提取 LinkedIn URL
136
+ linkedin_url = self._extract_linkedin(user.get("bio", ""), user.get("blog", ""))
137
+
138
+ return {
139
+ **user,
140
+ "repos": repos,
141
+ "linkedin_url": linkedin_url,
142
+ "web_summary": "",
143
+ "tech_direction": "",
144
+ "reachout_email": "",
145
+ "email_ready": False,
146
+ "email_sent": False,
147
+ "email_sent_error": "",
148
+ }
149
+
150
+ @staticmethod
151
+ def _extract_linkedin(bio: str, blog: str) -> str:
152
+ """
153
+ 从 bio 和 blog 字段中提取 LinkedIn URL。
154
+
155
+ 支持格式:
156
+ - https://www.linkedin.com/in/xxx
157
+ - https://linkedin.com/in/xxx
158
+ - linkedin.com/in/xxx
159
+ """
160
+ text = f"{bio} {blog}"
161
+ patterns = [
162
+ r'https?://(?:www\.)?linkedin\.com/in/[a-zA-Z0-9_-]+',
163
+ r'linkedin\.com/in/[a-zA-Z0-9_-]+',
164
+ ]
165
+ for pattern in patterns:
166
+ match = re.search(pattern, text)
167
+ if match:
168
+ url = match.group(0)
169
+ if not url.startswith("http"):
170
+ url = f"https://{url}"
171
+ return url
172
+ return ""
173
+
174
+ def check_rate_limit(self) -> Dict[str, Any]:
175
+ """检查当前 API 速率限制状态。"""
176
+ resp = self.session.get(f"{self.API_BASE}/rate_limit", timeout=10)
177
+ resp.raise_for_status()
178
+ return resp.json()
@@ -0,0 +1,107 @@
1
+ """
2
+ LangGraph 图编排模块。
3
+
4
+ 图结构:
5
+ plan → [条件路由] → fetch → enrich → summarize → generate → validate → send → evaluate
6
+
7
+ continue_loop=True → 回到 plan
8
+ continue_loop=False → 结束
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Dict, Any
13
+
14
+ from langgraph.graph import StateGraph, END
15
+
16
+ from github_reachout.state import ReachoutState
17
+ from github_reachout.nodes import (
18
+ plan_node, fetch_node, enrich_node, summarize_node,
19
+ generate_node, validate_node, send_node, evaluate_node,
20
+ route_after_plan, route_after_step, route_after_evaluate,
21
+ )
22
+ from github_reachout.llm import LLMClient
23
+ from github_reachout.github_client import GitHubClient
24
+ from github_reachout.web_reader import WebReader
25
+ from github_reachout.email_client import EmailClient
26
+
27
+ # 执行步骤名(不含 plan/evaluate)
28
+ EXECUTION_STEPS = ["fetch", "enrich", "summarize", "generate", "validate", "send"]
29
+
30
+ # 条件路由映射:步骤名 → 节点名(所有执行步骤 + evaluate 共用)
31
+ STEP_ROUTE_MAP = {s: s for s in EXECUTION_STEPS + ["evaluate"]}
32
+
33
+
34
+ def build_graph(
35
+ llm: LLMClient,
36
+ github: GitHubClient,
37
+ web_reader: WebReader,
38
+ email_client: EmailClient,
39
+ ) -> StateGraph:
40
+ """构建 LangGraph 状态图,使用依赖注入。"""
41
+ # 节点名 → 构造函数(闭包注入依赖)
42
+ node_builders = {
43
+ "plan": lambda s: plan_node(s, llm),
44
+ "fetch": lambda s: fetch_node(s, github),
45
+ "enrich": lambda s: enrich_node(s, web_reader),
46
+ "summarize": lambda s: summarize_node(s, llm),
47
+ "generate": lambda s: generate_node(s, llm),
48
+ "validate": lambda s: validate_node(s, llm),
49
+ "send": lambda s: send_node(s, email_client),
50
+ "evaluate": lambda s: evaluate_node(s, llm),
51
+ }
52
+
53
+ graph = StateGraph(ReachoutState)
54
+
55
+ # 注册节点
56
+ for name, builder in node_builders.items():
57
+ graph.add_node(name, builder)
58
+
59
+ graph.set_entry_point("plan")
60
+
61
+ # plan 后:路由到第一个 planned 步骤
62
+ graph.add_conditional_edges("plan", route_after_plan, STEP_ROUTE_MAP)
63
+
64
+ # 每个执行步骤后:路由到下一个 planned 步骤
65
+ for step in EXECUTION_STEPS:
66
+ graph.add_conditional_edges(step, route_after_step(step), STEP_ROUTE_MAP)
67
+
68
+ # evaluate 后:继续循环或结束
69
+ graph.add_conditional_edges("evaluate", route_after_evaluate, {
70
+ "plan": "plan",
71
+ "__end__": END,
72
+ })
73
+
74
+ return graph.compile()
75
+
76
+
77
+ def create_initial_state(
78
+ login_ids: list,
79
+ target_tech_direction: str = "",
80
+ company_name: str = "",
81
+ sender_name: str = "",
82
+ sender_title: str = "",
83
+ sender_email: str = "",
84
+ max_loops: int = 3,
85
+ run_id: str = "",
86
+ ) -> ReachoutState:
87
+ """创建初始 state。"""
88
+ return ReachoutState(
89
+ login_ids=login_ids,
90
+ target_tech_direction=target_tech_direction,
91
+ company_name=company_name,
92
+ sender_name=sender_name,
93
+ sender_title=sender_title,
94
+ sender_email=sender_email,
95
+ planned_steps=[],
96
+ loop_count=0,
97
+ max_loops=max_loops,
98
+ continue_loop=True,
99
+ persons=[],
100
+ processed_logins=[],
101
+ emails_to_send=[],
102
+ sent_results=[],
103
+ loop_history=[],
104
+ errors=[],
105
+ run_id=run_id,
106
+ step_index=0,
107
+ )
github_reachout/llm.py ADDED
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import time
6
+ from typing import Optional, Union, Dict, List
7
+ from openai import OpenAI
8
+ from openai._exceptions import RateLimitError, APITimeoutError
9
+
10
+
11
+
12
+
13
+ class LLMClient:
14
+ """
15
+ 轻量级 LLM 客户端,基于 openai 官方库封装。
16
+ 支持 OpenAI / DeepSeek / 本地 Ollama 等任何兼容 OpenAI Chat API 的服务。
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: Optional[str] = None,
22
+ base_url: Optional[str] = None,
23
+ model: Optional[str] = None,
24
+ timeout: Optional[float] = None,
25
+ max_retries: Optional[int] = None,
26
+ ):
27
+ # 配置参数读取逻辑
28
+ self.api_key = api_key or os.getenv("LLM_API_KEY", "")
29
+ self.base_url = (base_url or os.getenv("LLM_BASE_URL", "")).rstrip("/")
30
+ self.model = model or os.getenv("LLM_MODEL", "deepseek-v3.1-terminus-chat")
31
+ self.timeout = timeout or float(os.getenv("LLM_TIMEOUT", "120"))
32
+ self.max_retries = max_retries or int(os.getenv("LLM_MAX_RETRIES", "3"))
33
+
34
+ # 初始化 OpenAI 客户端
35
+ # 注意:openai 库的 max_retries 参数在较新版本中可能行为不同,
36
+ # 这里我们将重试逻辑主要放在调用层,确保对 429/500 等特定状态码的精细控制。
37
+ self.client = OpenAI(
38
+ api_key=self.api_key,
39
+ base_url=self.base_url,
40
+ timeout=self.timeout,
41
+ max_retries=0 # 暂时在客户端层禁用默认重试,由本类手动控制以兼容旧逻辑
42
+ )
43
+
44
+ def chat(self, system: str, user: str, temperature: float = 0.3) -> str:
45
+ """
46
+ 同步调用 Chat Completion API,返回助手回复文本。
47
+ 内置指数退避重试机制。
48
+
49
+ Args:
50
+ system: 系统提示词
51
+ user: 用户消息
52
+ temperature: 生成温度
53
+
54
+ Returns:
55
+ 助手回复的文本内容
56
+ """
57
+ last_error = None
58
+
59
+ # 构建消息列表
60
+ messages = [
61
+ {"role": "system", "content": system},
62
+ {"role": "user", "content": user},
63
+ ]
64
+
65
+ for attempt in range(self.max_retries):
66
+ try:
67
+ # 使用流式请求以支持更好的大模型长文本处理,并手动拼接
68
+ stream = self.client.chat.completions.create(
69
+ model=self.model,
70
+ messages=messages,
71
+ stream=True,
72
+ temperature=temperature,
73
+ timeout=self.timeout
74
+ )
75
+
76
+ response_content = ""
77
+ for chunk in stream:
78
+ if chunk.choices and chunk.choices[0].delta.content:
79
+ response_content += chunk.choices[0].delta.content
80
+
81
+ # 成功返回
82
+ return response_content.strip()
83
+
84
+ except (RateLimitError, APITimeoutError) as e:
85
+ # 可重试错误
86
+ last_error = e
87
+ wait = 2 ** attempt # 指数退避: 1s, 2s, 4s
88
+ print(f" [LLM] 请求失败 (HTTP 429/Timeout),第 {attempt + 1}/{self.max_retries} 次重试,等待 {wait}s...")
89
+ time.sleep(wait)
90
+ continue
91
+
92
+ except Exception as e:
93
+ # 不可重试的错误 (如 401, 403, 404 或 JSON 解析错误) 直接抛出
94
+ raise e
95
+
96
+ # 所有重试都失败
97
+ raise ConnectionError(
98
+ f"LLM API 调用失败,已重试 {self.max_retries} 次。最后错误: {last_error}"
99
+ )
100
+
101
+ def chat_json(self, system: str, user: str, temperature: float = 0.1) -> Union[Dict, List]:
102
+ """
103
+ 调用 Chat API 并解析 JSON 响应。
104
+ 自动从 markdown 代码块中提取 JSON。
105
+ """
106
+ raw = self.chat(system, user, temperature)
107
+ return self._extract_json(raw)
108
+
109
+ @staticmethod
110
+ def _extract_json(text: str) -> Union[Dict, List]:
111
+ """从 LLM 回复中提取 JSON(支持 markdown 代码块包裹)"""
112
+ text = text.strip()
113
+
114
+ # 1. 尝试直接解析
115
+ try:
116
+ return json.loads(text)
117
+ except json.JSONDecodeError:
118
+ pass
119
+
120
+ # 2. 尝试从 ```json ... ``` 中提取
121
+ if "```json" in text:
122
+ start = text.index("```json") + 7
123
+ end = text.index("```", start)
124
+ return json.loads(text[start:end].strip())
125
+
126
+ # 3. 尝试从 ``` ... ``` 中提取 (不指定语言)
127
+ if "```" in text:
128
+ start = text.index("```") + 3
129
+ end = text.index("```", start)
130
+ return json.loads(text[start:end].strip())
131
+
132
+ raise ValueError(f"无法从 LLM 回复中提取 JSON: {text[:200]}")