karani 0.3.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.
- karani/__init__.py +8 -0
- karani/__main__.py +7 -0
- karani/artifacts/__init__.py +12 -0
- karani/artifacts/store.py +111 -0
- karani/autopilot/__init__.py +12 -0
- karani/autopilot/runner.py +108 -0
- karani/cli.py +1048 -0
- karani/config/__init__.py +14 -0
- karani/config/loader.py +136 -0
- karani/config/schema.py +145 -0
- karani/config/wizard.py +122 -0
- karani/drafting/__init__.py +17 -0
- karani/drafting/followup.py +125 -0
- karani/drafting/humanize.py +169 -0
- karani/drafting/keywords.py +77 -0
- karani/drafting/models.py +37 -0
- karani/drafting/pipeline.py +132 -0
- karani/drafting/prep.py +207 -0
- karani/drafting/prompts.py +120 -0
- karani/drafting/resume_tailor.py +142 -0
- karani/drafting/runner.py +80 -0
- karani/drafting/writers.py +87 -0
- karani/ingestion/__init__.py +30 -0
- karani/ingestion/aijobs.py +98 -0
- karani/ingestion/ashby.py +101 -0
- karani/ingestion/base.py +118 -0
- karani/ingestion/config.py +169 -0
- karani/ingestion/digest.py +270 -0
- karani/ingestion/discovery.py +124 -0
- karani/ingestion/filters.py +286 -0
- karani/ingestion/greenhouse.py +62 -0
- karani/ingestion/himalayas.py +122 -0
- karani/ingestion/lever.py +61 -0
- karani/ingestion/models.py +184 -0
- karani/ingestion/orchestrator.py +163 -0
- karani/ingestion/profile.py +59 -0
- karani/ingestion/remoteok.py +120 -0
- karani/ingestion/remotive.py +116 -0
- karani/ingestion/resume.py +55 -0
- karani/ingestion/roles.py +155 -0
- karani/ingestion/sources.py +4 -0
- karani/ingestion/storage.py +1383 -0
- karani/ingestion/targets.py +115 -0
- karani/ingestion/weworkremotely.py +86 -0
- karani/ingestion/workable.py +135 -0
- karani/intel/__init__.py +13 -0
- karani/intel/service.py +176 -0
- karani/mcp_server/__init__.py +6 -0
- karani/mcp_server/__main__.py +21 -0
- karani/mcp_server/server.py +657 -0
- karani/memory/__init__.py +9 -0
- karani/memory/manager.py +276 -0
- karani/notionsync/__init__.py +14 -0
- karani/notionsync/client.py +85 -0
- karani/notionsync/sync.py +189 -0
- karani/orchestration/__init__.py +13 -0
- karani/orchestration/__main__.py +69 -0
- karani/orchestration/graph.py +194 -0
- karani/qualification/__init__.py +24 -0
- karani/qualification/agent.py +181 -0
- karani/qualification/anthropic.py +56 -0
- karani/qualification/client.py +116 -0
- karani/qualification/factory.py +50 -0
- karani/qualification/local.py +50 -0
- karani/qualification/models.py +48 -0
- karani/qualification/openrouter.py +208 -0
- karani/qualification/prompts.py +216 -0
- karani/qualification/runner.py +124 -0
- karani/qualification/tools.py +277 -0
- karani/resources/docker-compose.yml +68 -0
- karani/resources/karani.daily.plist.template +22 -0
- karani/resources/karani.example.toml +45 -0
- karani/resources/karani.hourly.plist.template +19 -0
- karani/slackbridge/__init__.py +16 -0
- karani/slackbridge/__main__.py +24 -0
- karani/slackbridge/blocks.py +127 -0
- karani/slackbridge/client.py +67 -0
- karani/slackbridge/commands.py +329 -0
- karani/slackbridge/interactions.py +79 -0
- karani/slackbridge/listener.py +151 -0
- karani-0.3.0.dist-info/METADATA +448 -0
- karani-0.3.0.dist-info/RECORD +85 -0
- karani-0.3.0.dist-info/WHEEL +4 -0
- karani-0.3.0.dist-info/entry_points.txt +2 -0
- karani-0.3.0.dist-info/licenses/LICENSE +21 -0
karani/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""karani — a semi-autonomous, self-hosted job-application pipeline.
|
|
2
|
+
|
|
3
|
+
Hunts postings hourly, qualifies them against your resume, drafts full
|
|
4
|
+
application packs, and delivers them for human review. It never submits.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
karani/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Per-job application artifacts in S3-compatible object storage.
|
|
2
|
+
|
|
3
|
+
One prefix per job (resume, cover letter, pack, prep, follow-up), with
|
|
4
|
+
presigned links surfaced on the Slack review card — click, tweak, submit.
|
|
5
|
+
Optional and best-effort: unconfigured or unreachable MinIO degrades to
|
|
6
|
+
files-on-disk, never fails a draft. See docs/adrs/0014.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .store import ArtifactStore
|
|
11
|
+
|
|
12
|
+
__all__ = ["ArtifactStore"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""MinIO/S3 artifact store — guarded import, env-configured, best-effort.
|
|
2
|
+
|
|
3
|
+
Defaults target karani's own compose MinIO (localhost:9010; the DataQRL
|
|
4
|
+
stack owns 9000). Point MINIO_ENDPOINT at any S3-compatible service.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENDPOINT = "localhost:9010"
|
|
18
|
+
DEFAULT_BUCKET = "karani-applications"
|
|
19
|
+
PRESIGN_DAYS = int(os.getenv("MINIO_PRESIGN_DAYS", "7"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _slug(text: str) -> str:
|
|
23
|
+
return re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") or "co"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArtifactStore:
|
|
27
|
+
"""Sync minio SDK wrapped for async use. Construct via `create()`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client, bucket: str):
|
|
30
|
+
self._client = client
|
|
31
|
+
self._bucket = bucket
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def configured() -> bool:
|
|
35
|
+
return bool(os.getenv("MINIO_ACCESS_KEY")
|
|
36
|
+
and os.getenv("MINIO_SECRET_KEY"))
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
async def create(cls) -> "ArtifactStore | None":
|
|
40
|
+
"""Connect and ensure the bucket; None when unconfigured/unavailable."""
|
|
41
|
+
if not cls.configured():
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
from minio import Minio # optional: uv sync --extra artifacts
|
|
45
|
+
except ImportError:
|
|
46
|
+
log.warning("minio SDK not installed; artifacts stay on disk "
|
|
47
|
+
"(uv sync --extra artifacts)")
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
from karani.config import get_config
|
|
51
|
+
from karani.config.loader import resolve
|
|
52
|
+
acfg = get_config().artifacts
|
|
53
|
+
client = Minio(
|
|
54
|
+
resolve("MINIO_ENDPOINT", acfg.endpoint, DEFAULT_ENDPOINT),
|
|
55
|
+
access_key=os.getenv("MINIO_ACCESS_KEY"),
|
|
56
|
+
secret_key=os.getenv("MINIO_SECRET_KEY"),
|
|
57
|
+
secure=os.getenv("MINIO_SECURE", "false").lower() == "true",
|
|
58
|
+
)
|
|
59
|
+
bucket = resolve("MINIO_BUCKET", acfg.bucket, DEFAULT_BUCKET)
|
|
60
|
+
|
|
61
|
+
def _ensure():
|
|
62
|
+
if not client.bucket_exists(bucket):
|
|
63
|
+
client.make_bucket(bucket)
|
|
64
|
+
await asyncio.to_thread(_ensure)
|
|
65
|
+
return cls(client, bucket)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
log.warning("artifact store unavailable (%s); files stay on "
|
|
68
|
+
"disk", exc)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def key(job_row: dict, name: str) -> str:
|
|
73
|
+
jid = job_row.get("id", 0)
|
|
74
|
+
company = _slug(job_row.get("company_display")
|
|
75
|
+
or job_row.get("company") or "")
|
|
76
|
+
return f"{jid:05d}-{company}/{name}"
|
|
77
|
+
|
|
78
|
+
async def put_text(self, key: str, content: str) -> str:
|
|
79
|
+
data = content.encode("utf-8")
|
|
80
|
+
|
|
81
|
+
def _put():
|
|
82
|
+
self._client.put_object(
|
|
83
|
+
self._bucket, key, io.BytesIO(data), len(data),
|
|
84
|
+
content_type="text/markdown; charset=utf-8",
|
|
85
|
+
)
|
|
86
|
+
await asyncio.to_thread(_put)
|
|
87
|
+
return key
|
|
88
|
+
|
|
89
|
+
async def presign(self, key: str) -> str:
|
|
90
|
+
def _sign():
|
|
91
|
+
return self._client.presigned_get_object(
|
|
92
|
+
self._bucket, key, expires=timedelta(days=PRESIGN_DAYS),
|
|
93
|
+
)
|
|
94
|
+
return await asyncio.to_thread(_sign)
|
|
95
|
+
|
|
96
|
+
async def store_pack(self, job_row: dict, files: dict[str, str]) -> dict:
|
|
97
|
+
"""Upload {name: content} for one job; returns {name: {key, url}}.
|
|
98
|
+
|
|
99
|
+
Best-effort per file — one failed upload never sinks the pack.
|
|
100
|
+
"""
|
|
101
|
+
out: dict[str, dict] = {}
|
|
102
|
+
for name, content in files.items():
|
|
103
|
+
if not content:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
key = self.key(job_row, name)
|
|
107
|
+
await self.put_text(key, content)
|
|
108
|
+
out[name] = {"key": key, "url": await self.presign(key)}
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
log.warning("artifact upload failed for %s: %s", name, exc)
|
|
111
|
+
return out
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Autopilot — the continuous hunt (ADR 0012).
|
|
2
|
+
|
|
3
|
+
Drafts application packs for top-fit qualified roles without waiting for
|
|
4
|
+
a verdict, and delivers them to Slack with review buttons. Guardrails:
|
|
5
|
+
fit floor (AUTOPILOT_MIN_FIT), per-run draft cap (AUTOPILOT_MAX_DRAFTS),
|
|
6
|
+
and the hard non-goal — karani never submits an application.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .runner import run_autopilot
|
|
11
|
+
|
|
12
|
+
__all__ = ["run_autopilot"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Autopilot pass: candidates -> draft pack -> Slack review card.
|
|
2
|
+
|
|
3
|
+
Runs inside the scheduled daily chain. Per-job failures are contained —
|
|
4
|
+
one bad draft or Slack hiccup skips that job and continues; the next
|
|
5
|
+
pass retries anything still eligible (drafted jobs move to `drafting`,
|
|
6
|
+
so they leave the candidate pool).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from karani.ingestion.storage import Storage
|
|
14
|
+
from karani.notionsync import maybe_sync_job
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
def _caps() -> tuple[int, int, int]:
|
|
19
|
+
"""(min_fit, max_drafts, daily_cap) — env > karani.toml > defaults.
|
|
20
|
+
The daily cap is what makes hourly scheduling safe: 24 runs/day
|
|
21
|
+
share one budget, they don't multiply it."""
|
|
22
|
+
from karani.config import get_config
|
|
23
|
+
from karani.config.loader import resolve
|
|
24
|
+
ac = get_config().autopilot
|
|
25
|
+
return (int(resolve("AUTOPILOT_MIN_FIT", ac.min_fit, 85)),
|
|
26
|
+
int(resolve("AUTOPILOT_MAX_DRAFTS", ac.max_drafts, 3)),
|
|
27
|
+
int(resolve("AUTOPILOT_MAX_DRAFTS_PER_DAY",
|
|
28
|
+
ac.max_drafts_per_day, 5)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AutopilotStats:
|
|
33
|
+
candidates: int = 0
|
|
34
|
+
drafted: int = 0
|
|
35
|
+
delivered: int = 0
|
|
36
|
+
budget_left: int = 0
|
|
37
|
+
errors: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run_autopilot(
|
|
41
|
+
storage: Storage, *,
|
|
42
|
+
slack, channel: str,
|
|
43
|
+
make_qualifier=None, load_resume=None,
|
|
44
|
+
min_fit: int | None = None,
|
|
45
|
+
max_drafts: int | None = None,
|
|
46
|
+
daily_cap: int | None = None,
|
|
47
|
+
) -> AutopilotStats:
|
|
48
|
+
"""Draft + deliver packs for the top candidates, bounded twice:
|
|
49
|
+
`max_drafts` per run AND `daily_cap` per UTC day across all runs."""
|
|
50
|
+
from karani.drafting import build_application_pack
|
|
51
|
+
from karani.slackbridge.blocks import pack_blocks
|
|
52
|
+
|
|
53
|
+
cfg_min_fit, cfg_max, cfg_daily = _caps()
|
|
54
|
+
min_fit = cfg_min_fit if min_fit is None else min_fit
|
|
55
|
+
max_drafts = cfg_max if max_drafts is None else max_drafts
|
|
56
|
+
daily_cap = cfg_daily if daily_cap is None else daily_cap
|
|
57
|
+
|
|
58
|
+
stats = AutopilotStats()
|
|
59
|
+
if max_drafts <= 0:
|
|
60
|
+
log.info("autopilot disabled (AUTOPILOT_MAX_DRAFTS<=0)")
|
|
61
|
+
return stats
|
|
62
|
+
|
|
63
|
+
spent_today = await storage.drafts_today()
|
|
64
|
+
budget = min(max_drafts, max(0, daily_cap - spent_today))
|
|
65
|
+
stats.budget_left = max(0, daily_cap - spent_today)
|
|
66
|
+
if budget <= 0:
|
|
67
|
+
log.info("autopilot daily budget spent (%d/%d today)",
|
|
68
|
+
spent_today, daily_cap)
|
|
69
|
+
return stats
|
|
70
|
+
|
|
71
|
+
rows = await storage.autopilot_candidates(min_fit=min_fit,
|
|
72
|
+
limit=budget)
|
|
73
|
+
stats.candidates = len(rows)
|
|
74
|
+
if not rows:
|
|
75
|
+
return stats
|
|
76
|
+
|
|
77
|
+
resume = load_resume()
|
|
78
|
+
client = make_qualifier() if make_qualifier else None
|
|
79
|
+
for row in rows:
|
|
80
|
+
job_id = row["id"]
|
|
81
|
+
try:
|
|
82
|
+
pack = await build_application_pack(
|
|
83
|
+
client, storage, job_row=row,
|
|
84
|
+
resume_markdown=resume.raw_markdown,
|
|
85
|
+
)
|
|
86
|
+
if pack.failed:
|
|
87
|
+
# Malformed LLM output: never deliver a broken pack, and
|
|
88
|
+
# leave the job in the candidate pool so the next pass
|
|
89
|
+
# retries it (build_application_pack records nothing on
|
|
90
|
+
# failure).
|
|
91
|
+
stats.errors.append(f"job_id={job_id}: draft failed "
|
|
92
|
+
f"(malformed LLM output); will retry")
|
|
93
|
+
continue
|
|
94
|
+
stats.drafted += 1
|
|
95
|
+
await slack.post_message(
|
|
96
|
+
channel,
|
|
97
|
+
f"Application pack ready for review: "
|
|
98
|
+
f"{row.get('company_display')} — {row.get('title')}",
|
|
99
|
+
blocks=pack_blocks(row, pack.pkg,
|
|
100
|
+
artifacts=pack.artifacts,
|
|
101
|
+
voice=pack.voice),
|
|
102
|
+
)
|
|
103
|
+
stats.delivered += 1
|
|
104
|
+
await maybe_sync_job(storage, job_id)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
stats.errors.append(f"job_id={job_id}: {exc}")
|
|
107
|
+
log.exception("autopilot failed for job %s", job_id)
|
|
108
|
+
return stats
|