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 ADDED
@@ -0,0 +1,3 @@
1
+ """HireKit — AI-powered company analysis and interview preparation CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()
@@ -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
@@ -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
@@ -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."""