cold-msg 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.
- cold_msg/__init__.py +7 -0
- cold_msg/cli.py +282 -0
- cold_msg/core/__init__.py +8 -0
- cold_msg/core/config.py +215 -0
- cold_msg/core/generator.py +129 -0
- cold_msg/core/llm.py +78 -0
- cold_msg/core/prompt.py +128 -0
- cold_msg/web/__init__.py +1 -0
- cold_msg/web/app.py +279 -0
- cold_msg/web/templates/config.html +357 -0
- cold_msg/web/templates/index.html +276 -0
- cold_msg/web/templates/jobs.html +518 -0
- cold_msg-0.1.0.dist-info/METADATA +244 -0
- cold_msg-0.1.0.dist-info/RECORD +17 -0
- cold_msg-0.1.0.dist-info/WHEEL +5 -0
- cold_msg-0.1.0.dist-info/entry_points.txt +2 -0
- cold_msg-0.1.0.dist-info/top_level.txt +1 -0
cold_msg/__init__.py
ADDED
cold_msg/cli.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""CLI 命令行接口模块
|
|
2
|
+
|
|
3
|
+
基于 Typer 实现,提供以下命令:
|
|
4
|
+
- cold-msg generate: 从简历文本生成冷邮件
|
|
5
|
+
- cold-msg web: 启动 Web 界面
|
|
6
|
+
- cold-msg config: 查看/管理配置
|
|
7
|
+
- cold-msg jobs: 管理职位列表
|
|
8
|
+
- cold-msg template: 管理邮件模板
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
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.json import JSON as RichJSON
|
|
21
|
+
|
|
22
|
+
from cold_msg.core.config import (
|
|
23
|
+
ColdMsgConfig,
|
|
24
|
+
JobPosition,
|
|
25
|
+
EmailTemplate,
|
|
26
|
+
load_config,
|
|
27
|
+
save_config,
|
|
28
|
+
load_jobs_from_file,
|
|
29
|
+
)
|
|
30
|
+
from cold_msg.core.generator import generate_cold_email, ColdEmailResult
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="cold-msg",
|
|
34
|
+
help="招聘冷邮件智能生成工具 - 基于候选人简历文本自动生成 Cold Email",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_cfg(config_file: Optional[Path] = None, env_file: Optional[Path] = None) -> ColdMsgConfig:
|
|
41
|
+
"""加载配置的辅助函数"""
|
|
42
|
+
return load_config(env_file=env_file, config_file=config_file)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def generate(
|
|
47
|
+
resume: str = typer.Argument(..., help="候选人简历文本"),
|
|
48
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM 模型名称"),
|
|
49
|
+
config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
50
|
+
env_file: Optional[Path] = typer.Option(None, "--env", help=".env 文件路径"),
|
|
51
|
+
jobs_file: Optional[Path] = typer.Option(None, "--jobs", "-j", help="职位列表文件路径 (yaml/json)"),
|
|
52
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="输出文件路径"),
|
|
53
|
+
raw: bool = typer.Option(False, "--raw", help="输出原始 JSON"),
|
|
54
|
+
):
|
|
55
|
+
"""从简历文本生成冷邮件"""
|
|
56
|
+
cfg = _load_cfg(config_file, env_file)
|
|
57
|
+
|
|
58
|
+
jobs = cfg.jobs
|
|
59
|
+
if jobs_file:
|
|
60
|
+
jobs = load_jobs_from_file(jobs_file)
|
|
61
|
+
|
|
62
|
+
if not jobs:
|
|
63
|
+
console.print("[red]错误: 职位列表为空,请通过配置文件或 --jobs 指定职位列表[/red]")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
with console.status("[bold green]正在生成冷邮件..."):
|
|
67
|
+
result = generate_cold_email(
|
|
68
|
+
resume_text=resume,
|
|
69
|
+
cfg=cfg,
|
|
70
|
+
jobs=jobs,
|
|
71
|
+
model=model,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if not result.match_job_title:
|
|
75
|
+
console.print("[yellow]候选人未匹配到合适职位[/yellow]")
|
|
76
|
+
if raw:
|
|
77
|
+
console.print_json(result.to_json())
|
|
78
|
+
raise typer.Exit(0)
|
|
79
|
+
|
|
80
|
+
if raw:
|
|
81
|
+
console.print_json(result.to_json())
|
|
82
|
+
else:
|
|
83
|
+
console.print(Panel(
|
|
84
|
+
result.email_content,
|
|
85
|
+
title=f"[bold green]推荐职位: {result.recommend_title}[/bold green]",
|
|
86
|
+
border_style="green",
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
if output:
|
|
90
|
+
output.write_text(result.to_json(), encoding="utf-8")
|
|
91
|
+
console.print(f"[dim]结果已保存: {output}[/dim]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def web(
|
|
96
|
+
host: Optional[str] = typer.Option(None, "--host", "-h", help="监听地址"),
|
|
97
|
+
port: Optional[int] = typer.Option(None, "--port", "-p", help="监听端口"),
|
|
98
|
+
config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
99
|
+
env_file: Optional[Path] = typer.Option(None, "--env", help=".env 文件路径"),
|
|
100
|
+
debug: bool = typer.Option(None, "--debug/--no-debug", help="调试模式"),
|
|
101
|
+
):
|
|
102
|
+
"""启动 Web 界面"""
|
|
103
|
+
cfg = _load_cfg(config_file, env_file)
|
|
104
|
+
|
|
105
|
+
from cold_msg.web.app import create_app
|
|
106
|
+
application = create_app(cfg)
|
|
107
|
+
|
|
108
|
+
_host = host or cfg.flask_host
|
|
109
|
+
_port = port or cfg.flask_port
|
|
110
|
+
_debug = debug if debug is not None else cfg.flask_debug
|
|
111
|
+
|
|
112
|
+
console.print(f"[bold green]启动 ColdMsg Web 界面: http://{_host}:{_port}[/bold green]")
|
|
113
|
+
application.run(host=_host, port=_port, debug=_debug)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command(name="config")
|
|
117
|
+
def config_cmd(
|
|
118
|
+
action: str = typer.Argument("show", help="操作: show / init / set"),
|
|
119
|
+
key: Optional[str] = typer.Argument(None, help="配置键名 (set 操作时使用)"),
|
|
120
|
+
value: Optional[str] = typer.Argument(None, help="配置值 (set 操作时使用)"),
|
|
121
|
+
config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
122
|
+
):
|
|
123
|
+
"""查看或管理配置"""
|
|
124
|
+
cfg = _load_cfg(config_file)
|
|
125
|
+
|
|
126
|
+
if action == "show":
|
|
127
|
+
console.print(Panel(RichJSON(json.dumps(cfg.to_dict(), ensure_ascii=False, default=str)),
|
|
128
|
+
title="[bold]当前配置[/bold]", border_style="blue"))
|
|
129
|
+
elif action == "init":
|
|
130
|
+
path = save_config(cfg, config_file)
|
|
131
|
+
console.print(f"[green]配置已初始化: {path}[/green]")
|
|
132
|
+
elif action == "set":
|
|
133
|
+
if not key or value is None:
|
|
134
|
+
console.print("[red]set 操作需要提供 key 和 value[/red]")
|
|
135
|
+
raise typer.Exit(1)
|
|
136
|
+
if hasattr(cfg, key):
|
|
137
|
+
setattr(cfg, key, value)
|
|
138
|
+
path = save_config(cfg, config_file)
|
|
139
|
+
console.print(f"[green]配置已更新: {key}={value}[/green]")
|
|
140
|
+
console.print(f"[dim]保存至: {path}[/dim]")
|
|
141
|
+
else:
|
|
142
|
+
console.print(f"[red]未知配置项: {key}[/red]")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
else:
|
|
145
|
+
console.print(f"[red]未知操作: {action},支持: show / init / set[/red]")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def jobs(
|
|
151
|
+
action: str = typer.Argument("list", help="操作: list / add / import / export"),
|
|
152
|
+
job_title: Optional[str] = typer.Option(None, "--title", "-t", help="职位名称 (add 时使用)"),
|
|
153
|
+
qualification: Optional[str] = typer.Option(None, "--qual", "-q", help="任职要求 (add 时使用)"),
|
|
154
|
+
location: Optional[str] = typer.Option(None, "--loc", "-l", help="工作地点 (add 时使用)"),
|
|
155
|
+
file: Optional[Path] = typer.Option(None, "--file", "-f", help="文件路径 (import/export 时使用)"),
|
|
156
|
+
config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
157
|
+
):
|
|
158
|
+
"""管理职位列表"""
|
|
159
|
+
cfg = _load_cfg(config_file)
|
|
160
|
+
|
|
161
|
+
if action == "list":
|
|
162
|
+
if not cfg.jobs:
|
|
163
|
+
console.print("[yellow]职位列表为空[/yellow]")
|
|
164
|
+
return
|
|
165
|
+
for i, job in enumerate(cfg.jobs, 1):
|
|
166
|
+
console.print(f"[bold]{i}. {job.job_title}[/bold]")
|
|
167
|
+
console.print(f" 要求: {job.qualification}")
|
|
168
|
+
console.print(f" 地点: {job.location}")
|
|
169
|
+
console.print()
|
|
170
|
+
elif action == "add":
|
|
171
|
+
if not job_title:
|
|
172
|
+
console.print("[red]add 操作需要 --title[/red]")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
new_job = JobPosition(
|
|
175
|
+
job_title=job_title,
|
|
176
|
+
qualification=qualification or "",
|
|
177
|
+
location=location or "",
|
|
178
|
+
)
|
|
179
|
+
cfg.jobs.append(new_job)
|
|
180
|
+
save_config(cfg, config_file)
|
|
181
|
+
console.print(f"[green]已添加职位: {job_title}[/green]")
|
|
182
|
+
elif action == "import":
|
|
183
|
+
if not file:
|
|
184
|
+
console.print("[red]import 操作需要 --file[/red]")
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
imported = load_jobs_from_file(file)
|
|
187
|
+
cfg.jobs.extend(imported)
|
|
188
|
+
save_config(cfg, config_file)
|
|
189
|
+
console.print(f"[green]已导入 {len(imported)} 个职位[/green]")
|
|
190
|
+
elif action == "export":
|
|
191
|
+
if not file:
|
|
192
|
+
console.print("[red]export 操作需要 --file[/red]")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
data = {"jobs": [j.to_dict() for j in cfg.jobs]}
|
|
195
|
+
file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
196
|
+
console.print(f"[green]已导出 {len(cfg.jobs)} 个职位至 {file}[/green]")
|
|
197
|
+
else:
|
|
198
|
+
console.print(f"[red]未知操作: {action},支持: list / add / import / export[/red]")
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def template(
|
|
204
|
+
action: str = typer.Argument("show", help="操作: show / set / preview / reset"),
|
|
205
|
+
field: Optional[str] = typer.Argument(None, help="模板字段名 (set 操作时使用)"),
|
|
206
|
+
value: Optional[str] = typer.Argument(None, help="字段值 (set 操作时使用)"),
|
|
207
|
+
config_file: Optional[Path] = typer.Option(None, "--config", "-c", help="配置文件路径"),
|
|
208
|
+
):
|
|
209
|
+
"""管理邮件模板
|
|
210
|
+
|
|
211
|
+
模板字段列表:
|
|
212
|
+
sender_name - 发件人姓名
|
|
213
|
+
team_name - 招聘团队名称
|
|
214
|
+
company_desc - 公司描述
|
|
215
|
+
greeting - 称呼/开头 (支持 {FullName})
|
|
216
|
+
intro - 自我介绍 (支持 {TeamName})
|
|
217
|
+
body - 职位推荐正文 (支持 {CompanyDesc}, {JobTitle})
|
|
218
|
+
location_line - 职位地点行 (支持 {Location})
|
|
219
|
+
closing - 结尾联系语
|
|
220
|
+
sign_off - 祝颂语
|
|
221
|
+
signature - 签名(默认使用发件人姓名)
|
|
222
|
+
"""
|
|
223
|
+
cfg = _load_cfg(config_file)
|
|
224
|
+
tpl = cfg.email_template
|
|
225
|
+
|
|
226
|
+
if action == "show":
|
|
227
|
+
table = Table(title="邮件模板配置", show_header=True, header_style="bold blue")
|
|
228
|
+
table.add_column("字段", style="cyan", width=16)
|
|
229
|
+
table.add_column("当前值", style="white")
|
|
230
|
+
for key, val in tpl.to_dict().items():
|
|
231
|
+
table.add_row(key, str(val))
|
|
232
|
+
console.print(table)
|
|
233
|
+
|
|
234
|
+
elif action == "set":
|
|
235
|
+
if not field or value is None:
|
|
236
|
+
console.print("[red]set 操作需要提供 field 和 value[/red]")
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
if not hasattr(tpl, field):
|
|
239
|
+
console.print(f"[red]未知模板字段: {field}[/red]")
|
|
240
|
+
console.print(f"[dim]可用字段: {', '.join(tpl.to_dict().keys())}[/dim]")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
setattr(tpl, field, value)
|
|
243
|
+
save_config(cfg, config_file)
|
|
244
|
+
console.print(f"[green]模板已更新: {field}={value}[/green]")
|
|
245
|
+
|
|
246
|
+
elif action == "preview":
|
|
247
|
+
parts = [
|
|
248
|
+
tpl.greeting,
|
|
249
|
+
tpl.intro,
|
|
250
|
+
"",
|
|
251
|
+
tpl.body,
|
|
252
|
+
"",
|
|
253
|
+
tpl.location_line,
|
|
254
|
+
"",
|
|
255
|
+
tpl.closing,
|
|
256
|
+
"",
|
|
257
|
+
tpl.sign_off,
|
|
258
|
+
tpl.signature,
|
|
259
|
+
]
|
|
260
|
+
text = "\n".join(parts)
|
|
261
|
+
text = text.replace("{TeamName}", tpl.team_name)
|
|
262
|
+
text = text.replace("{CompanyDesc}", tpl.company_desc)
|
|
263
|
+
text = text.replace("{SenderName}", tpl.sender_name)
|
|
264
|
+
console.print(Panel(text, title="[bold]邮件模板预览[/bold]", border_style="green"))
|
|
265
|
+
|
|
266
|
+
elif action == "reset":
|
|
267
|
+
cfg.email_template = EmailTemplate()
|
|
268
|
+
save_config(cfg, config_file)
|
|
269
|
+
console.print("[green]邮件模板已恢复为默认值[/green]")
|
|
270
|
+
|
|
271
|
+
else:
|
|
272
|
+
console.print(f"[red]未知操作: {action},支持: show / set / preview / reset[/red]")
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main():
|
|
277
|
+
"""CLI 入口"""
|
|
278
|
+
app()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""核心模块:配置、提示词、LLM调用、邮件生成"""
|
|
2
|
+
|
|
3
|
+
from cold_msg.core.config import ColdMsgConfig, load_config
|
|
4
|
+
from cold_msg.core.generator import generate_cold_email
|
|
5
|
+
from cold_msg.core.prompt import build_prompt
|
|
6
|
+
from cold_msg.core.llm import call_llm
|
|
7
|
+
|
|
8
|
+
__all__ = ["ColdMsgConfig", "load_config", "generate_cold_email", "build_prompt", "call_llm"]
|
cold_msg/core/config.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""配置管理模块
|
|
2
|
+
|
|
3
|
+
负责加载和管理 cold-msg 的所有配置项,包括:
|
|
4
|
+
- LLM 配置(通过 llmdog)
|
|
5
|
+
- 职位列表配置
|
|
6
|
+
- 邮件模板配置
|
|
7
|
+
- Web 服务配置
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class JobPosition:
|
|
22
|
+
"""单个职位信息"""
|
|
23
|
+
job_title: str
|
|
24
|
+
qualification: str
|
|
25
|
+
location: str
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return asdict(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EmailTemplate:
|
|
33
|
+
"""邮件模板配置
|
|
34
|
+
|
|
35
|
+
所有字段均可自定义,用于控制生成邮件的各个组成部分。
|
|
36
|
+
模板中使用 {占位符} 标记变量,运行时由系统替换。
|
|
37
|
+
"""
|
|
38
|
+
sender_name: str = "Michael"
|
|
39
|
+
team_name: str = "高端招聘团队"
|
|
40
|
+
company_desc: str = "国际知名大型科技公司"
|
|
41
|
+
|
|
42
|
+
greeting: str = "您好{FullName},"
|
|
43
|
+
intro: str = "我来自{TeamName},很冒昧的给您发邮件。通过Linkedin了解到您,了解到您在XXX, XXX等方向有丰富经验。"
|
|
44
|
+
body: str = "我负责{CompanyDesc}招聘,非常希望邀请到您这样的人才在此向您推荐\"{JobTitle}\"的职位。负责带领技术团队攻克更多XXX,XXX技术难题,进一步提升您在行业内的影响力。"
|
|
45
|
+
location_line: str = "职位地点: {Location}"
|
|
46
|
+
closing: str = "如果您有兴趣具体了解的话,可以留个电话或者微信,给您详细介绍一下,期待与您的沟通~"
|
|
47
|
+
sign_off: str = "祝颂商祺"
|
|
48
|
+
signature: str = "Michael"
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
return asdict(self)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ColdMsgConfig:
|
|
56
|
+
"""cold-msg 完整配置"""
|
|
57
|
+
sender_name: str = "Michael"
|
|
58
|
+
team_name: str = "高端招聘团队"
|
|
59
|
+
company_desc: str = "国际知名大型科技公司"
|
|
60
|
+
|
|
61
|
+
llm_model: str = "deepseek-v3.1-terminus"
|
|
62
|
+
|
|
63
|
+
flask_host: str = "127.0.0.1"
|
|
64
|
+
flask_port: int = 6060
|
|
65
|
+
flask_debug: bool = True
|
|
66
|
+
|
|
67
|
+
jobs: list[JobPosition] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
email_template: EmailTemplate = field(default_factory=EmailTemplate)
|
|
70
|
+
|
|
71
|
+
config_dir: Path = field(default_factory=lambda: Path.cwd())
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict:
|
|
74
|
+
d = asdict(self)
|
|
75
|
+
d["config_dir"] = str(self.config_dir)
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _env_bool(val: Optional[str], default: bool = False) -> bool:
|
|
80
|
+
"""将环境变量字符串转为布尔值"""
|
|
81
|
+
if val is None:
|
|
82
|
+
return default
|
|
83
|
+
return val.lower() in ("1", "true", "yes")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_config(
|
|
87
|
+
env_file: Optional[Path] = None,
|
|
88
|
+
config_file: Optional[Path] = None,
|
|
89
|
+
) -> ColdMsgConfig:
|
|
90
|
+
"""加载配置
|
|
91
|
+
|
|
92
|
+
优先级(从高到低):
|
|
93
|
+
1. 环境变量
|
|
94
|
+
2. .env 文件
|
|
95
|
+
3. 配置文件 (./config.yaml)
|
|
96
|
+
4. 内置默认值
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
env_file: 指定 .env 文件路径,默认自动搜索
|
|
100
|
+
config_file: 指定配置文件路径,默认使用 ./config.yaml
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
ColdMsgConfig 实例
|
|
104
|
+
"""
|
|
105
|
+
if env_file and env_file.exists():
|
|
106
|
+
load_dotenv(env_file)
|
|
107
|
+
else:
|
|
108
|
+
for p in [Path(".env"), Path.cwd() / ".env"]:
|
|
109
|
+
if p.exists():
|
|
110
|
+
load_dotenv(p)
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
file_config: dict = {}
|
|
114
|
+
if config_file and config_file.exists():
|
|
115
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
116
|
+
if config_file.suffix in (".yaml", ".yml"):
|
|
117
|
+
file_config = yaml.safe_load(f) or {}
|
|
118
|
+
elif config_file.suffix == ".json":
|
|
119
|
+
file_config = json.load(f)
|
|
120
|
+
else:
|
|
121
|
+
default_config = Path.cwd() / "config.yaml"
|
|
122
|
+
if default_config.exists():
|
|
123
|
+
with open(default_config, "r", encoding="utf-8") as f:
|
|
124
|
+
file_config = yaml.safe_load(f) or {}
|
|
125
|
+
|
|
126
|
+
cfg = ColdMsgConfig(
|
|
127
|
+
sender_name=os.getenv("COLDMSG_SENDER_NAME", file_config.get("sender_name", ColdMsgConfig.sender_name)),
|
|
128
|
+
team_name=os.getenv("COLDMSG_TEAM_NAME", file_config.get("team_name", ColdMsgConfig.team_name)),
|
|
129
|
+
company_desc=file_config.get("company_desc", ColdMsgConfig.company_desc),
|
|
130
|
+
llm_model=os.getenv("LLM_MODEL", file_config.get("llm_model", ColdMsgConfig.llm_model)),
|
|
131
|
+
flask_host=os.getenv("FLASK_HOST", file_config.get("flask_host", ColdMsgConfig.flask_host)),
|
|
132
|
+
flask_port=int(os.getenv("FLASK_PORT", file_config.get("flask_port", ColdMsgConfig.flask_port))),
|
|
133
|
+
flask_debug=_env_bool(os.getenv("FLASK_DEBUG"), file_config.get("flask_debug", ColdMsgConfig.flask_debug)),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
jobs_data = file_config.get("jobs", [])
|
|
137
|
+
cfg.jobs = [
|
|
138
|
+
JobPosition(
|
|
139
|
+
job_title=j.get("job_title", j.get("JobTitle", "")),
|
|
140
|
+
qualification=j.get("qualification", j.get("Qualification", "")),
|
|
141
|
+
location=j.get("location", j.get("Location", "")),
|
|
142
|
+
)
|
|
143
|
+
for j in jobs_data
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
tpl_data = file_config.get("email_template", {})
|
|
147
|
+
if tpl_data:
|
|
148
|
+
cfg.email_template = EmailTemplate(**tpl_data)
|
|
149
|
+
|
|
150
|
+
return cfg
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_config(cfg: ColdMsgConfig, config_file: Optional[Path] = None) -> Path:
|
|
154
|
+
"""保存配置到文件
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
cfg: 配置实例
|
|
158
|
+
config_file: 指定保存路径,默认 ./config.yaml
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
保存的文件路径
|
|
162
|
+
"""
|
|
163
|
+
if config_file is None:
|
|
164
|
+
config_file = cfg.config_dir / "config.yaml"
|
|
165
|
+
|
|
166
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
data = {
|
|
169
|
+
"sender_name": cfg.sender_name,
|
|
170
|
+
"team_name": cfg.team_name,
|
|
171
|
+
"company_desc": cfg.company_desc,
|
|
172
|
+
"llm_model": cfg.llm_model,
|
|
173
|
+
"flask_host": cfg.flask_host,
|
|
174
|
+
"flask_port": cfg.flask_port,
|
|
175
|
+
"flask_debug": cfg.flask_debug,
|
|
176
|
+
"jobs": [j.to_dict() for j in cfg.jobs],
|
|
177
|
+
"email_template": cfg.email_template.to_dict(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
181
|
+
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
|
182
|
+
|
|
183
|
+
return config_file
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_jobs_from_file(filepath: Path) -> list[JobPosition]:
|
|
187
|
+
"""从外部文件加载职位列表
|
|
188
|
+
|
|
189
|
+
支持 YAML 和 JSON 格式。
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
filepath: 文件路径
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
职位列表
|
|
196
|
+
"""
|
|
197
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
198
|
+
if filepath.suffix in (".yaml", ".yml"):
|
|
199
|
+
data = yaml.safe_load(f) or []
|
|
200
|
+
elif filepath.suffix == ".json":
|
|
201
|
+
data = json.load(f)
|
|
202
|
+
else:
|
|
203
|
+
raise ValueError(f"不支持的文件格式: {filepath.suffix},请使用 yaml/yml/json")
|
|
204
|
+
|
|
205
|
+
if isinstance(data, dict):
|
|
206
|
+
data = data.get("jobs", data.get("positions", []))
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
JobPosition(
|
|
210
|
+
job_title=j.get("job_title", j.get("JobTitle", "")),
|
|
211
|
+
qualification=j.get("qualification", j.get("Qualification", "")),
|
|
212
|
+
location=j.get("location", j.get("Location", "")),
|
|
213
|
+
)
|
|
214
|
+
for j in data
|
|
215
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""邮件生成器模块
|
|
2
|
+
|
|
3
|
+
核心业务逻辑:组合配置、提示词构建、LLM 调用,生成最终的冷邮件。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass, asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from cold_msg.core.config import ColdMsgConfig, JobPosition, EmailTemplate, load_config, load_jobs_from_file
|
|
13
|
+
from cold_msg.core.prompt import build_prompt
|
|
14
|
+
from cold_msg.core.llm import call_llm
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ColdEmailResult:
|
|
21
|
+
"""冷邮件生成结果"""
|
|
22
|
+
match_job_title: bool = False
|
|
23
|
+
recommend_title: str = ""
|
|
24
|
+
email_content: str = ""
|
|
25
|
+
raw_response: dict | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
d = asdict(self)
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
def to_json(self, indent: int = 2) -> str:
|
|
32
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_llm_response(cls, data: dict) -> "ColdEmailResult":
|
|
36
|
+
"""从 LLM 返回的字典构建结果"""
|
|
37
|
+
return cls(
|
|
38
|
+
match_job_title=data.get("match_job_title", False),
|
|
39
|
+
recommend_title=data.get("recommend_title", ""),
|
|
40
|
+
email_content=data.get("email_content", ""),
|
|
41
|
+
raw_response=data,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_cold_email(
|
|
46
|
+
resume_text: str,
|
|
47
|
+
cfg: Optional[ColdMsgConfig] = None,
|
|
48
|
+
jobs: Optional[list[JobPosition]] = None,
|
|
49
|
+
email_template: Optional[EmailTemplate] = None,
|
|
50
|
+
custom_prompt: Optional[str] = None,
|
|
51
|
+
model: Optional[str] = None,
|
|
52
|
+
) -> ColdEmailResult:
|
|
53
|
+
"""生成冷邮件
|
|
54
|
+
|
|
55
|
+
这是 cold-msg 的核心入口函数,既可被 CLI 调用,也可被 Web 调用,
|
|
56
|
+
也可以作为 Python 库直接调用。
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
resume_text: 候选人简历文本
|
|
60
|
+
cfg: 配置实例,为 None 时自动加载
|
|
61
|
+
jobs: 职位列表,为 None 时使用配置中的职位列表
|
|
62
|
+
email_template: 邮件模板,为 None 时使用配置中的模板
|
|
63
|
+
custom_prompt: 自定义提示词
|
|
64
|
+
model: 指定 LLM 模型
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
ColdEmailResult 实例
|
|
68
|
+
"""
|
|
69
|
+
if cfg is None:
|
|
70
|
+
cfg = load_config()
|
|
71
|
+
|
|
72
|
+
if jobs is None:
|
|
73
|
+
jobs = cfg.jobs
|
|
74
|
+
|
|
75
|
+
if email_template is None:
|
|
76
|
+
email_template = cfg.email_template
|
|
77
|
+
|
|
78
|
+
if not resume_text or not resume_text.strip():
|
|
79
|
+
logger.warning("简历文本为空")
|
|
80
|
+
return ColdEmailResult()
|
|
81
|
+
|
|
82
|
+
if not jobs:
|
|
83
|
+
logger.warning("职位列表为空,无法生成邮件")
|
|
84
|
+
return ColdEmailResult()
|
|
85
|
+
|
|
86
|
+
prompt = build_prompt(
|
|
87
|
+
resume_text=resume_text,
|
|
88
|
+
jobs=jobs,
|
|
89
|
+
email_template=email_template,
|
|
90
|
+
custom_prompt=custom_prompt,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result_dict = call_llm(prompt, cfg, model=model)
|
|
94
|
+
|
|
95
|
+
if not result_dict:
|
|
96
|
+
return ColdEmailResult()
|
|
97
|
+
|
|
98
|
+
return ColdEmailResult.from_llm_response(result_dict)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def generate_cold_email_from_file(
|
|
102
|
+
resume_file: Path,
|
|
103
|
+
output_dir: Optional[Path] = None,
|
|
104
|
+
cfg: Optional[ColdMsgConfig] = None,
|
|
105
|
+
jobs: Optional[list[JobPosition]] = None,
|
|
106
|
+
model: Optional[str] = None,
|
|
107
|
+
) -> ColdEmailResult:
|
|
108
|
+
"""从简历文件生成冷邮件并保存结果
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
resume_file: 简历文本文件路径
|
|
112
|
+
output_dir: 输出目录,为 None 时不保存文件
|
|
113
|
+
cfg: 配置实例
|
|
114
|
+
jobs: 职位列表
|
|
115
|
+
model: 指定 LLM 模型
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
ColdEmailResult 实例
|
|
119
|
+
"""
|
|
120
|
+
resume_text = resume_file.read_text(encoding="utf-8")
|
|
121
|
+
result = generate_cold_email(resume_text, cfg=cfg, jobs=jobs, model=model)
|
|
122
|
+
|
|
123
|
+
if output_dir and result.match_job_title:
|
|
124
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
output_file = output_dir / f"{resume_file.stem}.json"
|
|
126
|
+
output_file.write_text(result.to_json(), encoding="utf-8")
|
|
127
|
+
logger.info("结果已保存: %s", output_file)
|
|
128
|
+
|
|
129
|
+
return result
|