File size: 11,794 Bytes
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
 
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
 
 
 
 
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
"""
配置管理模块
支持三层配置来源(优先级从低到高):
  1. .env 文件(默认值)
  2. ~/.wellfound/config.json(界面保存的配置,持久化)
  3. 运行时内存(界面实时修改)

导入方式:
  import src.config as cfg          # 推荐:每次访问当前值
  cfg.get_llm_api_key()           # 总是返回最新值
"""

import json
import os
import logging
from pathlib import Path
from typing import Optional

from dotenv import load_dotenv

logger = logging.getLogger(__name__)

# ---- 持久化文件路径 ----
CONFIG_DIR = Path.home() / ".wellfound"
CONFIG_FILE = CONFIG_DIR / "config.json"

# ---- 模块级变量(可被运行时更新)----
_llm_provider = "deepseek"
_openai_api_key = ""
_openai_model = "gpt-4o-mini"
_deepseek_api_key = ""
_deepseek_model = "deepseek-chat"

_google_sheet_id = ""
_google_sheet_name = "West"
# 所有区域工作表名称(按优先级顺序查询)
_google_sheet_names = ["West", "South", "Northeast", "Midwest"]
_google_service_account_json = ""

_sheet_company_col = "C"
_sheet_email_type_col = "AA"
_sheet_email_body_col = "AB"
_sheet_email_date_col = "AC"
_sheet_timestamp_col = "AD"

_fuzzy_match_threshold = 85
_fuzzy_ambiguous_gap = 3.0
_max_concurrent = 5
_email_body_max_chars = 500

# 列索引缓存(配置变更时重建)
_indices: dict = {}
_config_loaded = False


def _col_letter_to_index(col: str) -> int:
    col = col.upper().strip()
    result = 0
    for ch in col:
        result = result * 26 + (ord(ch) - ord("A") + 1)
    return result - 1


def _rebuild_indices():
    global _indices
    _indices = {
        "company": _col_letter_to_index(_sheet_company_col),
        "email_type": _col_letter_to_index(_sheet_email_type_col),
        "email_body": _col_letter_to_index(_sheet_email_body_col),
        "email_date": _col_letter_to_index(_sheet_email_date_col),
        "timestamp": _col_letter_to_index(_sheet_timestamp_col),
    }


# ---- 初始化:加载配置 ----
def _load_env():
    """用 .env 文件值填充模块变量(仅在首次调用时作为默认值)"""
    load_dotenv()
    
    # 优先从环境变量读取(HF Spaces Secrets 或本地 .env)
    # 如果环境变量不存在,则保持当前值(可能来自 config.json)
    env_provider = os.getenv("LLM_PROVIDER")
    if env_provider:
        global _llm_provider
        _llm_provider = env_provider
    
    env_openai_key = os.getenv("OPENAI_API_KEY")
    if env_openai_key:
        global _openai_api_key
        _openai_api_key = env_openai_key
    
    env_openai_model = os.getenv("OPENAI_MODEL")
    if env_openai_model:
        global _openai_model
        _openai_model = env_openai_model
    
    env_deepseek_key = os.getenv("DEEPSEEK_API_KEY")
    if env_deepseek_key:
        global _deepseek_api_key
        _deepseek_api_key = env_deepseek_key
    
    env_deepseek_model = os.getenv("DEEPSEEK_MODEL")
    if env_deepseek_model:
        global _deepseek_model
        _deepseek_model = env_deepseek_model
    
    env_sheet_id = os.getenv("GOOGLE_SHEET_ID")
    if env_sheet_id:
        global _google_sheet_id
        _google_sheet_id = env_sheet_id
    
    env_sheet_name = os.getenv("GOOGLE_SHEET_NAME")
    if env_sheet_name:
        global _google_sheet_name
        _google_sheet_name = env_sheet_name
    
    env_sa_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON")
    if env_sa_json:
        global _google_service_account_json
        _google_service_account_json = env_sa_json
    
    # 可选配置
    env_company_col = os.getenv("SHEET_COMPANY_COL")
    if env_company_col:
        global _sheet_company_col
        _sheet_company_col = env_company_col
    
    env_type_col = os.getenv("SHEET_EMAIL_TYPE_COL")
    if env_type_col:
        global _sheet_email_type_col
        _sheet_email_type_col = env_type_col
    
    env_body_col = os.getenv("SHEET_EMAIL_BODY_COL")
    if env_body_col:
        global _sheet_email_body_col
        _sheet_email_body_col = env_body_col
    
    env_date_col = os.getenv("SHEET_EMAIL_DATE_COL")
    if env_date_col:
        global _sheet_email_date_col
        _sheet_email_date_col = env_date_col
    
    env_ts_col = os.getenv("SHEET_TIMESTAMP_COL")
    if env_ts_col:
        global _sheet_timestamp_col
        _sheet_timestamp_col = env_ts_col
    
    env_threshold = os.getenv("FUZZY_MATCH_THRESHOLD")
    if env_threshold:
        global _fuzzy_match_threshold
        _fuzzy_match_threshold = int(env_threshold)
    
    env_gap = os.getenv("FUZZY_AMBIGUOUS_GAP")
    if env_gap:
        global _fuzzy_ambiguous_gap
        _fuzzy_ambiguous_gap = float(env_gap)
    
    _rebuild_indices()


def _apply_dict(d: dict):
    """用字典更新模块变量"""
    global _llm_provider, _openai_api_key, _openai_model
    global _deepseek_api_key, _deepseek_model
    global _google_sheet_id, _google_sheet_name, _google_service_account_json
    global _sheet_company_col, _sheet_email_type_col, _sheet_email_body_col
    global _sheet_email_date_col, _sheet_timestamp_col
    global _fuzzy_match_threshold, _fuzzy_ambiguous_gap
    global _max_concurrent, _email_body_max_chars

    _llm_provider = d.get("llm_provider", _llm_provider)
    _openai_api_key = d.get("openai_api_key", _openai_api_key)
    _openai_model = d.get("openai_model", _openai_model)
    _deepseek_api_key = d.get("deepseek_api_key", _deepseek_api_key)
    _deepseek_model = d.get("deepseek_model", _deepseek_model)
    _google_sheet_id = d.get("google_sheet_id", _google_sheet_id)
    _google_sheet_name = d.get("google_sheet_name", _google_sheet_name)
    _google_service_account_json = d.get("google_service_account_json", _google_service_account_json)
    _sheet_company_col = d.get("sheet_company_col", _sheet_company_col)
    _sheet_email_type_col = d.get("sheet_email_type_col", _sheet_email_type_col)
    _sheet_email_body_col = d.get("sheet_email_body_col", _sheet_email_body_col)
    _sheet_email_date_col = d.get("sheet_email_date_col", _sheet_email_date_col)
    _sheet_timestamp_col = d.get("sheet_timestamp_col", _sheet_timestamp_col)
    _fuzzy_match_threshold = d.get("fuzzy_match_threshold", _fuzzy_match_threshold)
    _fuzzy_ambiguous_gap = d.get("fuzzy_ambiguous_gap", _fuzzy_ambiguous_gap)
    _max_concurrent = d.get("max_concurrent", _max_concurrent)
    _email_body_max_chars = d.get("email_body_max_chars", _email_body_max_chars)
    _rebuild_indices()


def load_config() -> dict:
    """
    加载配置(优先级:config.json > 环境变量 > 内置默认值)
    在应用启动时调用一次即可。
    Returns: 当前配置字典
    """
    global _config_loaded
    
    # 先加载环境变量(作为基础值)
    _load_env()
    
    # 如果在 HF Spaces 环境中,不加载 config.json(优先使用 Secrets)
    # 但可以提供一个选项:如果 HF_SPACES_LOAD_CONFIG=true,则仍然加载
    if os.getenv("SPACE_ID") or os.getenv("HF_SPACE"):
        if not os.getenv("HF_SPACES_LOAD_CONFIG"):
            logger.info("🌐 检测到 HF Spaces 环境,优先使用环境变量(Secrets)")
            _config_loaded = True
            return get_config_dict()
    
    # 本地环境或显式要求:尝试从 config.json 加载
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                file_cfg = json.load(f)
            _apply_dict(file_cfg)
            logger.info(f"✅ 已从 {CONFIG_FILE} 加载持久化配置")
        except Exception as e:
            logger.warning(f"读取配置文件失败: {e},使用环境变量 / 默认值")
    
    _config_loaded = True
    return get_config_dict()


def save_config(cfg: dict) -> tuple[bool, str]:
    """
    将配置保存到 ~/.wellfound/config.json
    Args:
        cfg: 完整配置字典(来自界面输入)
    Returns:
        (success, message)
    """
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        # 写入文件
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(cfg, f, indent=2, ensure_ascii=False)
        # 同步到内存
        _apply_dict(cfg)
        logger.info(f"✅ 配置已保存到 {CONFIG_FILE}")
        return True, f"✅ 配置已保存至 {CONFIG_FILE.name}"
    except Exception as e:
        logger.error(f"保存配置失败: {e}")
        return False, f"❌ 保存失败: {str(e)}"


def apply_config(cfg: dict):
    """仅更新内存中的配置(不写文件),用于界面实时预览"""
    _apply_dict(cfg)


def get_config_dict() -> dict:
    """返回当前所有配置的字典(用于界面回填)"""
    return {
        "llm_provider": _llm_provider,
        "openai_api_key": _openai_api_key,
        "openai_model": _openai_model,
        "deepseek_api_key": _deepseek_api_key,
        "deepseek_model": _deepseek_model,
        "google_sheet_id": _google_sheet_id,
        "google_sheet_name": _google_sheet_name,
        "google_service_account_json": _google_service_account_json,
        "sheet_company_col": _sheet_company_col,
        "sheet_email_type_col": _sheet_email_type_col,
        "sheet_email_body_col": _sheet_email_body_col,
        "sheet_email_date_col": _sheet_email_date_col,
        "sheet_timestamp_col": _sheet_timestamp_col,
        "fuzzy_match_threshold": _fuzzy_match_threshold,
        "fuzzy_ambiguous_gap": _fuzzy_ambiguous_gap,
        "max_concurrent": _max_concurrent,
        "email_body_max_chars": _email_body_max_chars,
    }


# ---- 便捷读取函数(各模块调用这些函数,总是拿到最新值)----


def get_llm_provider() -> str:
    return _llm_provider


def get_llm_api_key() -> str:
    return _deepseek_api_key if _llm_provider == "deepseek" else _openai_api_key


def get_llm_model() -> str:
    return _deepseek_model if _llm_provider == "deepseek" else _openai_model


def get_llm_base_url() -> Optional[str]:
    if _llm_provider == "deepseek":
        return "https://api.deepseek.com"
    return None


def get_google_sheet_id() -> str:
    return _google_sheet_id


def get_google_sheet_name() -> str:
    return _google_sheet_name


def get_google_sheet_names() -> list[str]:
    """返回所有需要查询的区域工作表名称列表"""
    return list(_google_sheet_names)


def get_service_account_info() -> Optional[dict]:
    """解析 Service Account JSON(支持 str 或 dict)"""
    raw = _google_service_account_json
    if not raw:
        return None
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, str):
        raw = raw.strip()
        if not raw:
            return None
        # 尝试解析 JSON 字符串
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            # 尝试作为文件路径
            if os.path.exists(raw):
                with open(raw, "r", encoding="utf-8") as f:
                    return json.load(f)
            return None
    return None


def get_sheet_col_indices() -> dict:
    """返回所有列 0-based 索引"""
    return _indices


def get_fuzzy_threshold() -> int:
    return int(_fuzzy_match_threshold)


def get_fuzzy_ambiguous_gap() -> float:
    return float(_fuzzy_ambiguous_gap)


def get_max_concurrent() -> int:
    return int(_max_concurrent)


def get_email_body_max_chars() -> int:
    return int(_email_body_max_chars)


# ---- 常量 ----
HIGH_PRIORITY_TYPES = {"Forward", "not fit", "considered",
                        "connect more", "career page", "not hiring"}
LOW_PRIORITY_TYPES = {"Auto reply", "do not exist"}
ALL_CATEGORIES = [
    "Auto reply", "career page", "connect more",
    "considered", "not hiring", "do not exist", "not fit", "Forward",
]

# ---- 启动时自动加载 ----
load_config()