hermes-interviewer 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.
- hermes_interviewer/__init__.py +11 -0
- hermes_interviewer/core/__init__.py +12 -0
- hermes_interviewer/core/config.py +69 -0
- hermes_interviewer/core/embed.py +132 -0
- hermes_interviewer/core/evaluator.py +215 -0
- hermes_interviewer/core/indexer.py +262 -0
- hermes_interviewer/core/interviewer.py +238 -0
- hermes_interviewer/core/llm.py +217 -0
- hermes_interviewer/core/llm_config.py +163 -0
- hermes_interviewer/core/ollama_client.py +111 -0
- hermes_interviewer/core/resume.py +571 -0
- hermes_interviewer/core/scope.py +65 -0
- hermes_interviewer/core/vector_store.py +85 -0
- hermes_interviewer/interviewer.py +161 -0
- hermes_interviewer-0.1.0.dist-info/METADATA +171 -0
- hermes_interviewer-0.1.0.dist-info/RECORD +17 -0
- hermes_interviewer-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
project_name: str = "LocalAI-Interviewer"
|
|
11
|
+
base_url: str = "http://localhost:8000"
|
|
12
|
+
api_prefix: str = "/api/v1"
|
|
13
|
+
|
|
14
|
+
data_dir: Path = Path(__file__).resolve().parent.parent.parent / "data"
|
|
15
|
+
db_path: Path = data_dir / "db" / "interview.db"
|
|
16
|
+
chroma_dir: Path = data_dir / "chroma_data"
|
|
17
|
+
upload_dir: Path = data_dir / "uploads"
|
|
18
|
+
# 生成简历时是否自动另存一份到系统桌面(本地工具默认开;测试用 APP_RESUME_SAVE_DESKTOP=0 关闭)
|
|
19
|
+
resume_save_desktop: bool = True
|
|
20
|
+
|
|
21
|
+
ollama_host: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
22
|
+
llm_model: str = "qwen2.5:7b"
|
|
23
|
+
embedding_model: str = "bge-m3"
|
|
24
|
+
embedding_dim: int = 1024
|
|
25
|
+
|
|
26
|
+
# 嵌入提供方:ollama(本地 Ollama)/ huggingface(本地 sentence-transformers)。默认 ollama,可在设置页运行时切换。
|
|
27
|
+
embedding_provider: str = "ollama"
|
|
28
|
+
huggingface_model: str = "BAAI/bge-m3"
|
|
29
|
+
|
|
30
|
+
# 文本生成提供方:ollama(本地)/ deepseek(API)。默认 ollama,可在设置页运行时切换。
|
|
31
|
+
llm_provider: str = "ollama"
|
|
32
|
+
deepseek_api_key: str = ""
|
|
33
|
+
deepseek_base_url: str = "https://api.deepseek.com"
|
|
34
|
+
deepseek_model: str = "deepseek-v4-flash"
|
|
35
|
+
deepseek_max_tokens: int = 4096
|
|
36
|
+
# 该网关默认会先输出一长段“思考”再作答,出题/评分/品鉴延迟高。
|
|
37
|
+
# 默认关闭思考以大幅提速(如需深度推理可设 APP_DEEPSEEK_DISABLE_THINKING=false)
|
|
38
|
+
deepseek_disable_thinking: bool = True
|
|
39
|
+
|
|
40
|
+
llm_timeout: float = 120.0
|
|
41
|
+
embed_timeout: float = 300.0
|
|
42
|
+
max_history_rounds: int = 3
|
|
43
|
+
default_max_rounds: int = 8
|
|
44
|
+
session_expire_hours: int = 2
|
|
45
|
+
|
|
46
|
+
chunk_size: int = 1500
|
|
47
|
+
chunk_overlap: int = 200
|
|
48
|
+
|
|
49
|
+
eval_weights_depth: float = 0.4
|
|
50
|
+
eval_weights_logic: float = 0.3
|
|
51
|
+
eval_weights_integrity: float = 0.3
|
|
52
|
+
|
|
53
|
+
allowed_extensions: set[str] = {
|
|
54
|
+
".java", ".py", ".js", ".ts", ".go", ".md", ".yaml", ".yml", ".sql",
|
|
55
|
+
".kt", ".scala", ".rs", ".c", ".cpp", ".h", ".cs", ".rb", ".php",
|
|
56
|
+
}
|
|
57
|
+
ignored_dirs: set[str] = {
|
|
58
|
+
".git", "node_modules", "__pycache__", "target", "build", "dist",
|
|
59
|
+
".idea", ".vscode", "venv", ".venv", "env", ".env",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
model_config = {"env_prefix": "APP_", "env_file": ".env", "extra": "ignore"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
settings = Settings()
|
|
66
|
+
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
settings.chroma_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""统一嵌入客户端:支持 Ollama 和 HuggingFace (sentence-transformers) 两种后端。
|
|
2
|
+
|
|
3
|
+
- 运行时通过 llm_config 的 embedding_provider 字段切换。
|
|
4
|
+
- HuggingFace 模式使用 sentence-transformers 加载模型,首次加载较慢,后续从缓存读取。
|
|
5
|
+
- Ollama 模式复用原有 /api/embed 接口。
|
|
6
|
+
- 所有调用方(indexing_service, resume_service 等)统一使用本模块的 embed() 函数。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import settings
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_hf_model = None # lazy-loaded SentenceTransformer instance
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_embedding_provider() -> str:
|
|
24
|
+
try:
|
|
25
|
+
from .llm_config import get_llm_settings
|
|
26
|
+
return get_llm_settings().embedding_provider
|
|
27
|
+
except Exception:
|
|
28
|
+
return "ollama"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_hf_model_name() -> str:
|
|
32
|
+
try:
|
|
33
|
+
from .llm_config import get_llm_settings
|
|
34
|
+
return get_llm_settings().huggingface_model
|
|
35
|
+
except Exception:
|
|
36
|
+
return "BAAI/bge-m3"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def embed(texts: list[str]) -> list[list[float]]:
|
|
40
|
+
"""统一嵌入接口,根据 embedding_provider 自动路由。"""
|
|
41
|
+
provider = _get_embedding_provider()
|
|
42
|
+
if provider == "huggingface":
|
|
43
|
+
return await _embed_huggingface(texts)
|
|
44
|
+
return await _embed_ollama(texts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _embed_ollama(texts: list[str]) -> list[list[float]]:
|
|
48
|
+
"""通过 Ollama /api/embed 生成嵌入向量。"""
|
|
49
|
+
model = settings.embedding_model
|
|
50
|
+
url = f"{settings.ollama_host}/api/embed"
|
|
51
|
+
payload = {"model": model, "input": texts}
|
|
52
|
+
timeout = settings.embed_timeout
|
|
53
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
54
|
+
resp = await client.post(url, json=payload)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
data = resp.json()
|
|
57
|
+
embeddings = data.get("embeddings", [])
|
|
58
|
+
if not embeddings:
|
|
59
|
+
logger.warning("Ollama embed 返回空结果,model=%s, input_len=%d", model, len(texts))
|
|
60
|
+
return embeddings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _embed_huggingface(texts: list[str]) -> list[list[float]]:
|
|
64
|
+
"""通过本地 HuggingFace sentence-transformers 模型生成嵌入向量。"""
|
|
65
|
+
global _hf_model
|
|
66
|
+
import asyncio
|
|
67
|
+
import functools
|
|
68
|
+
|
|
69
|
+
model_name = _get_hf_model_name()
|
|
70
|
+
|
|
71
|
+
if _hf_model is None:
|
|
72
|
+
logger.info("Loading HuggingFace embedding model: %s (首次加载较慢)...", model_name)
|
|
73
|
+
try:
|
|
74
|
+
_hf_model = await asyncio.to_thread(_load_hf_model, model_name)
|
|
75
|
+
logger.info("HuggingFace embedding model loaded: %s", model_name)
|
|
76
|
+
except Exception:
|
|
77
|
+
logger.exception("Failed to load HuggingFace embedding model: %s", model_name)
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = await asyncio.to_thread(_hf_model.encode, texts, normalize_embeddings=True)
|
|
82
|
+
embeddings = [vec.tolist() for vec in result]
|
|
83
|
+
return embeddings
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.exception("HuggingFace embedding failed, model=%s", model_name)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_hf_model(model_name: str):
|
|
90
|
+
"""在线程中加载 sentence-transformers 模型。"""
|
|
91
|
+
from sentence_transformers import SentenceTransformer
|
|
92
|
+
return SentenceTransformer(model_name, trust_remote_code=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def health_check() -> dict[str, Any]:
|
|
96
|
+
"""检测当前嵌入后端是否可用。"""
|
|
97
|
+
provider = _get_embedding_provider()
|
|
98
|
+
if provider == "huggingface":
|
|
99
|
+
return await _health_huggingface()
|
|
100
|
+
return await _health_ollama()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _health_ollama() -> dict[str, Any]:
|
|
104
|
+
try:
|
|
105
|
+
url = f"{settings.ollama_host}/api/tags"
|
|
106
|
+
async with httpx.AsyncClient(timeout=5) as client:
|
|
107
|
+
resp = await client.get(url)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
return {"ok": True, "provider": "ollama", "message": "Ollama 可连接"}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return {"ok": False, "provider": "ollama", "message": f"Ollama 不可达: {e}"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _health_huggingface() -> dict[str, Any]:
|
|
115
|
+
global _hf_model
|
|
116
|
+
import asyncio
|
|
117
|
+
|
|
118
|
+
model_name = _get_hf_model_name()
|
|
119
|
+
try:
|
|
120
|
+
if _hf_model is None:
|
|
121
|
+
_hf_model = await asyncio.to_thread(_load_hf_model, model_name)
|
|
122
|
+
# quick encode test
|
|
123
|
+
await asyncio.to_thread(_hf_model.encode, ["test"], normalize_embeddings=True)
|
|
124
|
+
return {"ok": True, "provider": "huggingface", "model": model_name, "message": f"HuggingFace {model_name} 就绪"}
|
|
125
|
+
except Exception as e:
|
|
126
|
+
return {"ok": False, "provider": "huggingface", "model": model_name, "message": f"HuggingFace 模型不可用: {e}"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def reload_model() -> None:
|
|
130
|
+
"""强制重新加载 HuggingFace 模型(切换模型后调用)。"""
|
|
131
|
+
global _hf_model
|
|
132
|
+
_hf_model = None
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""评估系统"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .config import settings
|
|
10
|
+
from .llm import llm_generate
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# 评估维度权重
|
|
15
|
+
DEFAULT_WEIGHTS = {
|
|
16
|
+
"depth": 0.4,
|
|
17
|
+
"logic": 0.3,
|
|
18
|
+
"integrity": 0.3,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def evaluate_answer(
|
|
23
|
+
question: str,
|
|
24
|
+
answer: str,
|
|
25
|
+
context: str = "",
|
|
26
|
+
difficulty: str = "medium",
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
评估候选人回答
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
question: 面试问题
|
|
33
|
+
answer: 候选人回答
|
|
34
|
+
context: 参考上下文(代码片段等)
|
|
35
|
+
difficulty: 难度(easy/medium/hard/hell)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
评估结果
|
|
39
|
+
"""
|
|
40
|
+
system = """你是一位资深技术面试官。请从三个维度评估候选人的回答:
|
|
41
|
+
1. 技术深度(depth):是否涉及底层原理、源码级理解、性能考量
|
|
42
|
+
2. 逻辑清晰度(logic):回答是否条理清晰、有理有据
|
|
43
|
+
3. 解决方案完整性(integrity):是否覆盖边界条件、异常处理、可扩展性
|
|
44
|
+
|
|
45
|
+
请严格按以下JSON格式返回评分(不要包含任何其他文字):
|
|
46
|
+
{"depth": <1-10>, "logic": <1-10>, "integrity": <1-10>, "comment": "<50字以内的评价>"}"""
|
|
47
|
+
|
|
48
|
+
prompt = f"""面试问题:{question}
|
|
49
|
+
|
|
50
|
+
候选人回答:
|
|
51
|
+
{answer}
|
|
52
|
+
|
|
53
|
+
参考上下文:
|
|
54
|
+
{context}
|
|
55
|
+
|
|
56
|
+
请评估该回答质量。"""
|
|
57
|
+
|
|
58
|
+
response = await llm_generate(prompt, system)
|
|
59
|
+
result = _parse_json(response)
|
|
60
|
+
|
|
61
|
+
if result:
|
|
62
|
+
# 归一化分数
|
|
63
|
+
depth = _to_score(result.get("depth", 5))
|
|
64
|
+
logic = _to_score(result.get("logic", 5))
|
|
65
|
+
integrity = _to_score(result.get("integrity", 5))
|
|
66
|
+
|
|
67
|
+
# 计算加权平均
|
|
68
|
+
weights = settings.eval_weights_depth, settings.eval_weights_logic, settings.eval_weights_integrity
|
|
69
|
+
overall = depth * weights[0] + logic * weights[1] + integrity * weights[2]
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"depth": depth,
|
|
73
|
+
"logic": logic,
|
|
74
|
+
"integrity": integrity,
|
|
75
|
+
"overall": round(overall, 1),
|
|
76
|
+
"comment": result.get("comment", ""),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"depth": 5.0,
|
|
81
|
+
"logic": 5.0,
|
|
82
|
+
"integrity": 5.0,
|
|
83
|
+
"overall": 5.0,
|
|
84
|
+
"comment": "评估失败",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def evaluate_intro(introduction: str) -> dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
评估自我介绍
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
introduction: 候选人自我介绍
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
评估结果
|
|
97
|
+
"""
|
|
98
|
+
system = """你是一位资深技术面试官。候选人刚刚做完自我介绍,请从以下三个维度点评:
|
|
99
|
+
1. 表达清晰度(clarity):条理是否清楚、重点是否突出
|
|
100
|
+
2. 内容充实度(substance):是否讲清了背景、项目经历、技术栈
|
|
101
|
+
3. 岗位匹配度(fit):介绍内容是否贴合目标岗位
|
|
102
|
+
|
|
103
|
+
请严格按以下JSON格式返回评分(不要包含任何其他文字):
|
|
104
|
+
{"clarity": <1-10>, "substance": <1-10>, "fit": <1-10>, "comment": "<评语>"}"""
|
|
105
|
+
|
|
106
|
+
prompt = f"""候选人自我介绍:
|
|
107
|
+
{introduction}
|
|
108
|
+
|
|
109
|
+
请评估该自我介绍质量。"""
|
|
110
|
+
|
|
111
|
+
response = await llm_generate(prompt, system)
|
|
112
|
+
result = _parse_json(response)
|
|
113
|
+
|
|
114
|
+
if result:
|
|
115
|
+
return {
|
|
116
|
+
"clarity": _to_score(result.get("clarity", 5)),
|
|
117
|
+
"substance": _to_score(result.get("substance", 5)),
|
|
118
|
+
"fit": _to_score(result.get("fit", 5)),
|
|
119
|
+
"comment": result.get("comment", ""),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"clarity": 5.0,
|
|
124
|
+
"substance": 5.0,
|
|
125
|
+
"fit": 5.0,
|
|
126
|
+
"comment": "评估失败",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def generate_report(
|
|
131
|
+
history: list[dict],
|
|
132
|
+
scores: list[dict],
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""
|
|
135
|
+
生成面试报告
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
history: 面试对话历史
|
|
139
|
+
scores: 各轮评分
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
评估报告
|
|
143
|
+
"""
|
|
144
|
+
system = """你是面试评估专家。请根据面试对话历史生成一份评估报告。
|
|
145
|
+
返回JSON格式:
|
|
146
|
+
{
|
|
147
|
+
"overall_score": <总分1-10>,
|
|
148
|
+
"strengths": ["优势1", "优势2"],
|
|
149
|
+
"weaknesses": ["不足1", "不足2"],
|
|
150
|
+
"suggestions": ["建议1", "建议2"],
|
|
151
|
+
"summary": "综合评价"
|
|
152
|
+
}"""
|
|
153
|
+
|
|
154
|
+
# 计算平均分
|
|
155
|
+
avg_scores = {}
|
|
156
|
+
if scores:
|
|
157
|
+
for key in ["depth", "logic", "integrity"]:
|
|
158
|
+
values = [s.get(key, 5) for s in scores if key in s]
|
|
159
|
+
avg_scores[key] = sum(values) / len(values) if values else 5.0
|
|
160
|
+
|
|
161
|
+
history_text = "\n".join([
|
|
162
|
+
f"{'面试官' if h['role'] == 'assistant' else '候选人'}: {h['content']}"
|
|
163
|
+
for h in history
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
prompt = f"""面试历史:
|
|
167
|
+
{history_text}
|
|
168
|
+
|
|
169
|
+
平均得分:{json.dumps(avg_scores, ensure_ascii=False)}
|
|
170
|
+
|
|
171
|
+
请生成评估报告。"""
|
|
172
|
+
|
|
173
|
+
response = await llm_generate(prompt, system)
|
|
174
|
+
result = _parse_json(response)
|
|
175
|
+
|
|
176
|
+
if result:
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"overall_score": 5.0,
|
|
181
|
+
"strengths": [],
|
|
182
|
+
"weaknesses": [],
|
|
183
|
+
"suggestions": [],
|
|
184
|
+
"summary": "报告生成失败"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_json(text: str) -> Optional[dict]:
|
|
189
|
+
"""从文本中解析JSON"""
|
|
190
|
+
if not text:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
return json.loads(text)
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
start = text.find("{")
|
|
199
|
+
end = text.rfind("}")
|
|
200
|
+
if start != -1 and end > start:
|
|
201
|
+
try:
|
|
202
|
+
return json.loads(text[start:end + 1])
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _to_score(value: Any, default: float = 5.0) -> float:
|
|
210
|
+
"""转换为1-10的分数"""
|
|
211
|
+
try:
|
|
212
|
+
score = float(value)
|
|
213
|
+
except (TypeError, ValueError):
|
|
214
|
+
return default
|
|
215
|
+
return max(1.0, min(10.0, score))
|