hirekit 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.
- hirekit/__init__.py +3 -0
- hirekit/cli/__init__.py +1 -0
- hirekit/cli/app.py +442 -0
- hirekit/core/__init__.py +1 -0
- hirekit/core/cache.py +68 -0
- hirekit/core/config.py +63 -0
- hirekit/core/parallel.py +47 -0
- hirekit/core/scoring.py +22 -0
- hirekit/engine/__init__.py +1 -0
- hirekit/engine/company_analyzer.py +336 -0
- hirekit/engine/cover_letter.py +517 -0
- hirekit/engine/interview_prep.py +249 -0
- hirekit/engine/jd_matcher.py +378 -0
- hirekit/engine/resume_advisor.py +300 -0
- hirekit/engine/scorer.py +63 -0
- hirekit/llm/__init__.py +1 -0
- hirekit/llm/base.py +61 -0
- hirekit/output/__init__.py +1 -0
- hirekit/sources/__init__.py +1 -0
- hirekit/sources/base.py +93 -0
- hirekit/sources/global_/__init__.py +1 -0
- hirekit/sources/global_/brave_search.py +116 -0
- hirekit/sources/global_/credible_news.py +157 -0
- hirekit/sources/global_/exa_search.py +102 -0
- hirekit/sources/global_/github.py +138 -0
- hirekit/sources/global_/google_news.py +107 -0
- hirekit/sources/kr/__init__.py +1 -0
- hirekit/sources/kr/dart.py +330 -0
- hirekit/sources/kr/naver_news.py +84 -0
- hirekit/sources/kr/naver_search.py +121 -0
- hirekit/sources/us/__init__.py +1 -0
- hirekit-0.1.0.dist-info/METADATA +332 -0
- hirekit-0.1.0.dist-info/RECORD +36 -0
- hirekit-0.1.0.dist-info/WHEEL +4 -0
- hirekit-0.1.0.dist-info/entry_points.txt +17 -0
- hirekit-0.1.0.dist-info/licenses/LICENSE +21 -0
hirekit/__init__.py
ADDED
hirekit/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands for HireKit."""
|
hirekit/cli/app.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""HireKit CLI — main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from hirekit import __version__
|
|
14
|
+
from hirekit.core.config import DEFAULT_CONFIG_DIR, ensure_config_dir, load_config
|
|
15
|
+
|
|
16
|
+
# Auto-load .env from project dir and ~/.hirekit/.env
|
|
17
|
+
load_dotenv()
|
|
18
|
+
load_dotenv(DEFAULT_CONFIG_DIR / ".env")
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="hirekit",
|
|
22
|
+
help="AI-powered company analysis and interview preparation CLI.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def version_callback(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
console.print(f"[bold]HireKit[/bold] v{__version__}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
version: bool | None = typer.Option(
|
|
37
|
+
None, "--version", "-v", callback=version_callback, is_eager=True
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""HireKit — Research companies. Match jobs. Ace interviews."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def analyze(
|
|
45
|
+
company: str = typer.Argument(help="Company name (e.g., '카카오', 'toss')"),
|
|
46
|
+
region: str = typer.Option("kr", "--region", "-r", help="Region: kr, us, global"),
|
|
47
|
+
output: str = typer.Option("markdown", "--output", "-o", help="markdown, json, terminal"),
|
|
48
|
+
no_llm: bool = typer.Option(False, "--no-llm", help="Skip LLM, template-only"),
|
|
49
|
+
tier: int = typer.Option(1, "--tier", "-t", help="Depth: 1 (full), 2 (key), 3 (min)"),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Analyze a company — collect data from multiple sources and generate a report."""
|
|
52
|
+
config = load_config()
|
|
53
|
+
|
|
54
|
+
console.print(Panel(
|
|
55
|
+
f"[bold]Analyzing:[/bold] {company}\n"
|
|
56
|
+
f"[bold]Region:[/bold] {region} [bold]Tier:[/bold] {tier} "
|
|
57
|
+
f"[bold]LLM:[/bold] {'off' if no_llm else config.llm.provider}",
|
|
58
|
+
title="[bold blue]HireKit Analysis[/bold blue]",
|
|
59
|
+
border_style="blue",
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
# Import here to avoid circular imports and speed up CLI startup
|
|
63
|
+
from hirekit.engine.company_analyzer import CompanyAnalyzer
|
|
64
|
+
|
|
65
|
+
analyzer = CompanyAnalyzer(config=config, use_llm=not no_llm)
|
|
66
|
+
|
|
67
|
+
with console.status("[bold green]Collecting data from sources..."):
|
|
68
|
+
report = analyzer.analyze(company=company, region=region, tier=tier)
|
|
69
|
+
|
|
70
|
+
if output == "terminal":
|
|
71
|
+
console.print(report.to_rich())
|
|
72
|
+
elif output == "json":
|
|
73
|
+
import json
|
|
74
|
+
console.print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
|
|
75
|
+
else:
|
|
76
|
+
out_path = Path(config.output.directory) / f"{company}_analysis.md"
|
|
77
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
out_path.write_text(report.to_markdown(), encoding="utf-8")
|
|
79
|
+
console.print(f"\n[green]Report saved:[/green] {out_path}")
|
|
80
|
+
|
|
81
|
+
# Show scorecard summary
|
|
82
|
+
if report.scorecard:
|
|
83
|
+
table = Table(title=f"{company} Scorecard")
|
|
84
|
+
table.add_column("Dimension", style="cyan")
|
|
85
|
+
table.add_column("Weight", justify="right")
|
|
86
|
+
table.add_column("Score", justify="right")
|
|
87
|
+
table.add_column("Evidence")
|
|
88
|
+
|
|
89
|
+
for d in report.scorecard.dimensions:
|
|
90
|
+
score_str = f"{d.score:.1f}/5" if d.score > 0 else "-"
|
|
91
|
+
table.add_row(d.label, f"{d.weight:.0%}", score_str, d.evidence[:60])
|
|
92
|
+
|
|
93
|
+
table.add_row(
|
|
94
|
+
"[bold]Total[/bold]", "", f"[bold]{report.scorecard.total_score:.0f}/100[/bold]",
|
|
95
|
+
f"Grade [bold]{report.scorecard.grade}[/bold]",
|
|
96
|
+
)
|
|
97
|
+
console.print(table)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def configure() -> None:
|
|
102
|
+
"""Set up HireKit configuration (API keys, preferences)."""
|
|
103
|
+
config_dir = ensure_config_dir()
|
|
104
|
+
config_file = config_dir / "config.toml"
|
|
105
|
+
|
|
106
|
+
if config_file.exists():
|
|
107
|
+
console.print(f"[yellow]Config already exists:[/yellow] {config_file}")
|
|
108
|
+
console.print("Edit it directly or delete to re-initialize.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Create default config
|
|
112
|
+
default_config = """\
|
|
113
|
+
[analysis]
|
|
114
|
+
default_region = "kr"
|
|
115
|
+
cache_ttl_hours = 168
|
|
116
|
+
parallel_workers = 5
|
|
117
|
+
|
|
118
|
+
[llm]
|
|
119
|
+
provider = "none" # openai, anthropic, ollama, none
|
|
120
|
+
model = "gpt-4o-mini"
|
|
121
|
+
temperature = 0.3
|
|
122
|
+
|
|
123
|
+
[sources]
|
|
124
|
+
enabled = ["dart", "github", "naver_news"]
|
|
125
|
+
|
|
126
|
+
[output]
|
|
127
|
+
format = "markdown"
|
|
128
|
+
directory = "./reports"
|
|
129
|
+
"""
|
|
130
|
+
config_file.write_text(default_config, encoding="utf-8")
|
|
131
|
+
console.print(f"[green]Config created:[/green] {config_file}")
|
|
132
|
+
|
|
133
|
+
# Create .env template
|
|
134
|
+
env_file = config_dir / ".env"
|
|
135
|
+
if not env_file.exists():
|
|
136
|
+
env_file.write_text(
|
|
137
|
+
"# HireKit API Keys\n"
|
|
138
|
+
"# DART_API_KEY=your_dart_api_key_here\n"
|
|
139
|
+
"# OPENAI_API_KEY=your_openai_key_here\n"
|
|
140
|
+
"# ANTHROPIC_API_KEY=your_anthropic_key_here\n",
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
console.print(f"[green]Env template:[/green] {env_file}")
|
|
144
|
+
|
|
145
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
146
|
+
console.print(" 1. Get a DART API key: https://opendart.fss.or.kr/")
|
|
147
|
+
console.print(" 2. Edit ~/.hirekit/.env with your API keys")
|
|
148
|
+
console.print(" 3. Run: [cyan]hirekit analyze 카카오[/cyan]")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def sources() -> None:
|
|
153
|
+
"""List available data sources and their status."""
|
|
154
|
+
from hirekit.sources.base import SourceRegistry
|
|
155
|
+
|
|
156
|
+
SourceRegistry.discover_plugins()
|
|
157
|
+
all_sources = SourceRegistry.get_all()
|
|
158
|
+
|
|
159
|
+
table = Table(title="Data Sources")
|
|
160
|
+
table.add_column("Name", style="cyan")
|
|
161
|
+
table.add_column("Region")
|
|
162
|
+
table.add_column("Sections")
|
|
163
|
+
table.add_column("API Key")
|
|
164
|
+
table.add_column("Status")
|
|
165
|
+
|
|
166
|
+
for name, source_cls in sorted(all_sources.items()):
|
|
167
|
+
source = source_cls()
|
|
168
|
+
available = source.is_available()
|
|
169
|
+
table.add_row(
|
|
170
|
+
name,
|
|
171
|
+
source.region.upper(),
|
|
172
|
+
", ".join(source.sections),
|
|
173
|
+
source.api_key_env_var or "-",
|
|
174
|
+
"[green]Ready[/green]" if available else "[red]Not configured[/red]",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
console.print(table)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def match(
|
|
182
|
+
jd_source: str = typer.Argument(help="JD URL or text file path"),
|
|
183
|
+
profile: str = typer.Option("", "--profile", "-p", help="Career profile YAML"),
|
|
184
|
+
output: str = typer.Option("markdown", "--output", "-o", help="markdown, terminal"),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Match a job description against your career profile."""
|
|
187
|
+
from hirekit.engine.jd_matcher import JDMatcher
|
|
188
|
+
|
|
189
|
+
config = load_config()
|
|
190
|
+
llm = _get_llm(config)
|
|
191
|
+
matcher = JDMatcher(llm=llm)
|
|
192
|
+
|
|
193
|
+
# Load profile if provided
|
|
194
|
+
user_profile = _load_profile(profile)
|
|
195
|
+
|
|
196
|
+
# Check if jd_source is a file
|
|
197
|
+
jd_path = Path(jd_source)
|
|
198
|
+
if jd_path.exists():
|
|
199
|
+
jd_source = jd_path.read_text(encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
with console.status("[bold green]Analyzing job description..."):
|
|
202
|
+
analysis = matcher.analyze(jd_source=jd_source, profile=user_profile)
|
|
203
|
+
|
|
204
|
+
if output == "terminal":
|
|
205
|
+
console.print(Panel(
|
|
206
|
+
f"[bold]{analysis.title}[/bold] at {analysis.company}\n"
|
|
207
|
+
f"Match: [bold]{analysis.match_score:.0f}/100[/bold] "
|
|
208
|
+
f"(Grade {analysis.match_grade})",
|
|
209
|
+
title="[bold blue]JD Match[/bold blue]",
|
|
210
|
+
border_style="blue",
|
|
211
|
+
))
|
|
212
|
+
if analysis.gaps:
|
|
213
|
+
console.print("\n[red]Gaps:[/red]")
|
|
214
|
+
for g in analysis.gaps[:5]:
|
|
215
|
+
console.print(f" - {g}")
|
|
216
|
+
if analysis.strengths:
|
|
217
|
+
console.print("\n[green]Strengths:[/green]")
|
|
218
|
+
for s in analysis.strengths[:5]:
|
|
219
|
+
console.print(f" - {s}")
|
|
220
|
+
else:
|
|
221
|
+
out_path = Path(config.output.directory) / "jd_match.md"
|
|
222
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
out_path.write_text(analysis.to_markdown(), encoding="utf-8")
|
|
224
|
+
console.print(f"[green]Report saved:[/green] {out_path}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def interview(
|
|
229
|
+
company: str = typer.Argument(help="Company name"),
|
|
230
|
+
position: str = typer.Option("", "--position", "-p", help="Target position"),
|
|
231
|
+
profile: str = typer.Option("", "--profile", help="Career profile YAML"),
|
|
232
|
+
output: str = typer.Option("markdown", "--output", "-o", help="markdown, terminal"),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Generate interview preparation guide for a company."""
|
|
235
|
+
from hirekit.engine.interview_prep import InterviewPrep
|
|
236
|
+
|
|
237
|
+
config = load_config()
|
|
238
|
+
llm = _get_llm(config)
|
|
239
|
+
prep = InterviewPrep(llm=llm)
|
|
240
|
+
|
|
241
|
+
user_profile = _load_profile(profile)
|
|
242
|
+
|
|
243
|
+
with console.status(f"[bold green]Preparing interview guide for {company}..."):
|
|
244
|
+
guide = prep.prepare(
|
|
245
|
+
company=company,
|
|
246
|
+
position=position,
|
|
247
|
+
profile=user_profile,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if output == "terminal":
|
|
251
|
+
console.print(Panel(
|
|
252
|
+
f"[bold]{company}[/bold] — {position or 'General'}\n"
|
|
253
|
+
f"Questions: {len(guide.common_questions)} common, "
|
|
254
|
+
f"{len(guide.technical_questions)} technical, "
|
|
255
|
+
f"{len(guide.behavioral_questions)} behavioral\n"
|
|
256
|
+
f"Reverse questions: {len(guide.reverse_questions)}",
|
|
257
|
+
title="[bold blue]Interview Prep[/bold blue]",
|
|
258
|
+
border_style="blue",
|
|
259
|
+
))
|
|
260
|
+
for q in guide.common_questions:
|
|
261
|
+
console.print(f"\n[cyan]Q:[/cyan] {q['question']}")
|
|
262
|
+
console.print(f" [dim]{q.get('answer', '')}[/dim]")
|
|
263
|
+
else:
|
|
264
|
+
out_path = Path(config.output.directory) / f"{company}_interview.md"
|
|
265
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
out_path.write_text(guide.to_markdown(), encoding="utf-8")
|
|
267
|
+
console.print(f"[green]Report saved:[/green] {out_path}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def coverletter(
|
|
272
|
+
company: str = typer.Argument(help="Company name (e.g., '카카오', 'toss')"),
|
|
273
|
+
position: str = typer.Option("", "--position", "-p", help="Target position (e.g., PM)"),
|
|
274
|
+
profile: str = typer.Option("", "--profile", help="Career profile YAML"),
|
|
275
|
+
output: str = typer.Option("markdown", "--output", "-o", help="markdown, terminal"),
|
|
276
|
+
no_llm: bool = typer.Option(False, "--no-llm", help="Skip LLM, rule-based only"),
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Generate a Korean 자기소개서 (cover letter) draft with coaching feedback."""
|
|
279
|
+
from hirekit.engine.cover_letter import CoverLetterCoach
|
|
280
|
+
|
|
281
|
+
config = load_config()
|
|
282
|
+
llm = _get_llm(config) if not no_llm else None
|
|
283
|
+
coach = CoverLetterCoach(llm=llm)
|
|
284
|
+
|
|
285
|
+
user_profile = _load_profile(profile)
|
|
286
|
+
|
|
287
|
+
with console.status(f"[bold green]{company} 자기소개서 초안 작성 중..."):
|
|
288
|
+
draft = coach.draft(
|
|
289
|
+
company=company,
|
|
290
|
+
position=position,
|
|
291
|
+
profile=user_profile,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if output == "terminal":
|
|
295
|
+
console.print(Panel(
|
|
296
|
+
f"[bold]{company}[/bold] — {position or '미지정'}\n"
|
|
297
|
+
f"종합 점수: [bold]{draft.overall_score:.0f}/100[/bold] "
|
|
298
|
+
f"(Grade {draft.grade})\n"
|
|
299
|
+
f"섹션 수: {len(draft.sections)}개",
|
|
300
|
+
title="[bold blue]자기소개서 코치[/bold blue]",
|
|
301
|
+
border_style="blue",
|
|
302
|
+
))
|
|
303
|
+
for section in draft.sections:
|
|
304
|
+
console.print(f"\n[cyan]## {section.title}[/cyan]")
|
|
305
|
+
console.print(section.content)
|
|
306
|
+
console.print(
|
|
307
|
+
f"[dim]점수: {section.score:.0f}/100 | "
|
|
308
|
+
f"글자 수: {section.word_count}자[/dim]"
|
|
309
|
+
)
|
|
310
|
+
if section.feedback:
|
|
311
|
+
console.print("[yellow]피드백:[/yellow]")
|
|
312
|
+
for fb in section.feedback:
|
|
313
|
+
console.print(f" - {fb}")
|
|
314
|
+
else:
|
|
315
|
+
out_path = Path(config.output.directory) / f"{company}_coverletter.md"
|
|
316
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
out_path.write_text(draft.to_markdown(), encoding="utf-8")
|
|
318
|
+
console.print(f"[green]자기소개서 저장:[/green] {out_path}")
|
|
319
|
+
|
|
320
|
+
table = Table(title=f"{company} 자기소개서 점수")
|
|
321
|
+
table.add_column("항목", style="cyan")
|
|
322
|
+
table.add_column("글자 수", justify="right")
|
|
323
|
+
table.add_column("점수", justify="right")
|
|
324
|
+
table.add_column("등급", justify="center")
|
|
325
|
+
|
|
326
|
+
for s in draft.sections:
|
|
327
|
+
table.add_row(s.title, str(s.word_count), f"{s.score:.0f}/100", s.grade)
|
|
328
|
+
|
|
329
|
+
table.add_row(
|
|
330
|
+
"[bold]종합[/bold]", "", f"[bold]{draft.overall_score:.0f}/100[/bold]",
|
|
331
|
+
f"[bold]{draft.grade}[/bold]",
|
|
332
|
+
)
|
|
333
|
+
console.print(table)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.command()
|
|
337
|
+
def resume(
|
|
338
|
+
file: str = typer.Argument(help="Resume file path (md, txt, pdf)"),
|
|
339
|
+
jd: str = typer.Option("", "--jd", help="JD URL or text for targeted review"),
|
|
340
|
+
profile: str = typer.Option("", "--profile", "-p", help="Career profile YAML"),
|
|
341
|
+
output: str = typer.Option("markdown", "--output", "-o", help="markdown, terminal"),
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Review and provide feedback on your resume."""
|
|
344
|
+
from hirekit.engine.resume_advisor import ResumeAdvisor
|
|
345
|
+
|
|
346
|
+
config = load_config()
|
|
347
|
+
llm = _get_llm(config)
|
|
348
|
+
advisor = ResumeAdvisor(llm=llm)
|
|
349
|
+
|
|
350
|
+
user_profile = _load_profile(profile)
|
|
351
|
+
|
|
352
|
+
# Resolve JD text
|
|
353
|
+
jd_text = ""
|
|
354
|
+
if jd:
|
|
355
|
+
jd_path = Path(jd)
|
|
356
|
+
if jd_path.exists():
|
|
357
|
+
jd_text = jd_path.read_text(encoding="utf-8")
|
|
358
|
+
elif jd.startswith("http"):
|
|
359
|
+
import httpx
|
|
360
|
+
try:
|
|
361
|
+
resp = httpx.get(jd, timeout=10, follow_redirects=True)
|
|
362
|
+
from bs4 import BeautifulSoup
|
|
363
|
+
soup = BeautifulSoup(resp.text, "lxml")
|
|
364
|
+
jd_text = soup.get_text(separator="\n", strip=True)[:5000]
|
|
365
|
+
except Exception:
|
|
366
|
+
console.print(f"[yellow]Could not fetch JD from {jd}[/yellow]")
|
|
367
|
+
else:
|
|
368
|
+
jd_text = jd
|
|
369
|
+
|
|
370
|
+
with console.status("[bold green]Reviewing resume..."):
|
|
371
|
+
feedback = advisor.review(
|
|
372
|
+
resume_path=file, jd_text=jd_text, profile=user_profile
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if output == "terminal":
|
|
376
|
+
console.print(Panel(
|
|
377
|
+
f"[bold]Score:[/bold] {feedback.overall_score:.0f}/100 "
|
|
378
|
+
f"(Grade {feedback.grade})",
|
|
379
|
+
title="[bold blue]Resume Review[/bold blue]",
|
|
380
|
+
border_style="blue",
|
|
381
|
+
))
|
|
382
|
+
if feedback.strengths:
|
|
383
|
+
console.print("\n[green]Strengths:[/green]")
|
|
384
|
+
for s in feedback.strengths:
|
|
385
|
+
console.print(f" + {s}")
|
|
386
|
+
if feedback.improvements:
|
|
387
|
+
console.print("\n[yellow]Improvements:[/yellow]")
|
|
388
|
+
for i in feedback.improvements:
|
|
389
|
+
console.print(f" - {i}")
|
|
390
|
+
if feedback.keyword_gaps:
|
|
391
|
+
console.print("\n[red]Keyword gaps vs JD:[/red]")
|
|
392
|
+
console.print(f" {', '.join(feedback.keyword_gaps[:10])}")
|
|
393
|
+
else:
|
|
394
|
+
out_path = Path(config.output.directory) / "resume_review.md"
|
|
395
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
out_path.write_text(feedback.to_markdown(), encoding="utf-8")
|
|
397
|
+
console.print(f"[green]Report saved:[/green] {out_path}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- Helpers ---
|
|
401
|
+
|
|
402
|
+
def _get_llm(config: HireKitConfig) -> BaseLLM: # noqa: F821
|
|
403
|
+
"""Initialize LLM from config."""
|
|
404
|
+
from hirekit.llm.base import NoLLM
|
|
405
|
+
if config.llm.provider == "none":
|
|
406
|
+
return NoLLM()
|
|
407
|
+
try:
|
|
408
|
+
if config.llm.provider == "openai":
|
|
409
|
+
from hirekit.llm.openai import OpenAIAdapter
|
|
410
|
+
return OpenAIAdapter(model=config.llm.model)
|
|
411
|
+
if config.llm.provider == "anthropic":
|
|
412
|
+
from hirekit.llm.anthropic import AnthropicAdapter
|
|
413
|
+
return AnthropicAdapter(model=config.llm.model)
|
|
414
|
+
except ImportError:
|
|
415
|
+
pass
|
|
416
|
+
return NoLLM()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _load_profile(path: str) -> dict | None:
|
|
420
|
+
"""Load career profile from YAML file."""
|
|
421
|
+
if not path:
|
|
422
|
+
# Check default location
|
|
423
|
+
default = Path.home() / ".hirekit" / "profile.yaml"
|
|
424
|
+
if default.exists():
|
|
425
|
+
path = str(default)
|
|
426
|
+
else:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
p = Path(path)
|
|
430
|
+
if not p.exists():
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
import yaml
|
|
435
|
+
return yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
436
|
+
except ImportError:
|
|
437
|
+
# Fallback: basic YAML parsing without pyyaml
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
if __name__ == "__main__":
|
|
442
|
+
app()
|
hirekit/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core configuration and utilities for HireKit."""
|
hirekit/core/cache.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Disk cache with TTL using sqlite3 (zero external dependencies)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hirekit.core.config import DEFAULT_CONFIG_DIR
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Cache:
|
|
15
|
+
"""Simple key-value cache backed by SQLite."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, db_path: Path | None = None, ttl_hours: int = 168):
|
|
18
|
+
self.db_path = db_path or (DEFAULT_CONFIG_DIR / "cache.db")
|
|
19
|
+
self.ttl_seconds = ttl_hours * 3600
|
|
20
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
self._init_db()
|
|
22
|
+
|
|
23
|
+
def _init_db(self) -> None:
|
|
24
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
25
|
+
conn.execute("""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
27
|
+
key TEXT PRIMARY KEY,
|
|
28
|
+
value TEXT NOT NULL,
|
|
29
|
+
created_at REAL NOT NULL
|
|
30
|
+
)
|
|
31
|
+
""")
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> Any | None:
|
|
34
|
+
"""Get cached value. Returns None if missing or expired."""
|
|
35
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
36
|
+
row = conn.execute(
|
|
37
|
+
"SELECT value, created_at FROM cache WHERE key = ?", (key,)
|
|
38
|
+
).fetchone()
|
|
39
|
+
|
|
40
|
+
if row is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
value, created_at = row
|
|
44
|
+
if time.time() - created_at > self.ttl_seconds:
|
|
45
|
+
self.delete(key)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
return json.loads(value)
|
|
49
|
+
|
|
50
|
+
def set(self, key: str, value: Any) -> None:
|
|
51
|
+
"""Set a cache entry."""
|
|
52
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
53
|
+
conn.execute(
|
|
54
|
+
"INSERT OR REPLACE INTO cache (key, value, created_at) VALUES (?, ?, ?)",
|
|
55
|
+
(key, json.dumps(value, ensure_ascii=False), time.time()),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def delete(self, key: str) -> None:
|
|
59
|
+
"""Delete a cache entry."""
|
|
60
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
61
|
+
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
62
|
+
|
|
63
|
+
def clear_expired(self) -> int:
|
|
64
|
+
"""Remove all expired entries. Returns count of removed entries."""
|
|
65
|
+
cutoff = time.time() - self.ttl_seconds
|
|
66
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
67
|
+
cursor = conn.execute("DELETE FROM cache WHERE created_at < ?", (cutoff,))
|
|
68
|
+
return cursor.rowcount
|
hirekit/core/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Configuration loader for HireKit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".hirekit"
|
|
12
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMConfig(BaseModel):
|
|
16
|
+
provider: str = "none" # openai, anthropic, ollama, none
|
|
17
|
+
model: str = "gpt-4o-mini"
|
|
18
|
+
temperature: float = 0.3
|
|
19
|
+
max_tokens: int = 4096
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SourcesConfig(BaseModel):
|
|
23
|
+
enabled: list[str] = Field(default_factory=lambda: ["dart", "github", "naver_news"])
|
|
24
|
+
disabled: list[str] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OutputConfig(BaseModel):
|
|
28
|
+
format: str = "markdown" # markdown, json, terminal
|
|
29
|
+
directory: str = "./reports"
|
|
30
|
+
template: str = "default"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AnalysisConfig(BaseModel):
|
|
34
|
+
default_region: str = "kr"
|
|
35
|
+
cache_ttl_hours: int = 168 # 7 days
|
|
36
|
+
parallel_workers: int = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HireKitConfig(BaseModel):
|
|
40
|
+
"""Root configuration model."""
|
|
41
|
+
|
|
42
|
+
analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
|
|
43
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
44
|
+
sources: SourcesConfig = Field(default_factory=SourcesConfig)
|
|
45
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config(config_path: Path | None = None) -> HireKitConfig:
|
|
49
|
+
"""Load configuration from TOML file, falling back to defaults."""
|
|
50
|
+
path = config_path or DEFAULT_CONFIG_FILE
|
|
51
|
+
|
|
52
|
+
if path.exists():
|
|
53
|
+
with open(path, "rb") as f:
|
|
54
|
+
data: dict[str, Any] = tomllib.load(f)
|
|
55
|
+
return HireKitConfig(**data)
|
|
56
|
+
|
|
57
|
+
return HireKitConfig()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_config_dir() -> Path:
|
|
61
|
+
"""Create config directory if it doesn't exist."""
|
|
62
|
+
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
return DEFAULT_CONFIG_DIR
|
hirekit/core/parallel.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Parallel data collection executor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hirekit.sources.base import BaseSource, SourceResult
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def collect_parallel(
|
|
15
|
+
sources: list[BaseSource],
|
|
16
|
+
company: str,
|
|
17
|
+
max_workers: int = 5,
|
|
18
|
+
**kwargs: Any,
|
|
19
|
+
) -> list[SourceResult]:
|
|
20
|
+
"""Run multiple data source collectors in parallel.
|
|
21
|
+
|
|
22
|
+
Returns all successfully collected results. Failed sources are logged and skipped.
|
|
23
|
+
"""
|
|
24
|
+
all_results: list[SourceResult] = []
|
|
25
|
+
|
|
26
|
+
def _run_source(source: BaseSource) -> list[SourceResult]:
|
|
27
|
+
try:
|
|
28
|
+
return source.collect(company, **kwargs)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logger.warning("Source %s failed for %s: %s", source.name, company, e)
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
34
|
+
futures = {executor.submit(_run_source, s): s for s in sources}
|
|
35
|
+
|
|
36
|
+
for future in as_completed(futures):
|
|
37
|
+
source = futures[future]
|
|
38
|
+
try:
|
|
39
|
+
results = future.result(timeout=source.get_timeout())
|
|
40
|
+
all_results.extend(results)
|
|
41
|
+
logger.info("Source %s: %d results", source.name, len(results))
|
|
42
|
+
except TimeoutError:
|
|
43
|
+
logger.warning("Source %s timed out after %ds", source.name, source.get_timeout())
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.warning("Source %s error: %s", source.name, e)
|
|
46
|
+
|
|
47
|
+
return all_results
|
hirekit/core/scoring.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared scoring utilities — deduplicated grade calculation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def score_to_grade(score: float, thresholds: tuple[float, ...] = (80, 65, 50, 35)) -> str:
|
|
9
|
+
"""Convert a numeric score to letter grade.
|
|
10
|
+
|
|
11
|
+
Default thresholds: S >= 80, A >= 65, B >= 50, C >= 35, D < 35.
|
|
12
|
+
"""
|
|
13
|
+
grades = ("S", "A", "B", "C", "D")
|
|
14
|
+
for threshold, grade in zip(thresholds, grades):
|
|
15
|
+
if score >= threshold:
|
|
16
|
+
return grade
|
|
17
|
+
return grades[-1]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def current_year() -> int:
|
|
21
|
+
"""Get current year (avoids hardcoded year strings)."""
|
|
22
|
+
return datetime.date.today().year
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core analysis engines for HireKit."""
|