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.
Files changed (85) hide show
  1. karani/__init__.py +8 -0
  2. karani/__main__.py +7 -0
  3. karani/artifacts/__init__.py +12 -0
  4. karani/artifacts/store.py +111 -0
  5. karani/autopilot/__init__.py +12 -0
  6. karani/autopilot/runner.py +108 -0
  7. karani/cli.py +1048 -0
  8. karani/config/__init__.py +14 -0
  9. karani/config/loader.py +136 -0
  10. karani/config/schema.py +145 -0
  11. karani/config/wizard.py +122 -0
  12. karani/drafting/__init__.py +17 -0
  13. karani/drafting/followup.py +125 -0
  14. karani/drafting/humanize.py +169 -0
  15. karani/drafting/keywords.py +77 -0
  16. karani/drafting/models.py +37 -0
  17. karani/drafting/pipeline.py +132 -0
  18. karani/drafting/prep.py +207 -0
  19. karani/drafting/prompts.py +120 -0
  20. karani/drafting/resume_tailor.py +142 -0
  21. karani/drafting/runner.py +80 -0
  22. karani/drafting/writers.py +87 -0
  23. karani/ingestion/__init__.py +30 -0
  24. karani/ingestion/aijobs.py +98 -0
  25. karani/ingestion/ashby.py +101 -0
  26. karani/ingestion/base.py +118 -0
  27. karani/ingestion/config.py +169 -0
  28. karani/ingestion/digest.py +270 -0
  29. karani/ingestion/discovery.py +124 -0
  30. karani/ingestion/filters.py +286 -0
  31. karani/ingestion/greenhouse.py +62 -0
  32. karani/ingestion/himalayas.py +122 -0
  33. karani/ingestion/lever.py +61 -0
  34. karani/ingestion/models.py +184 -0
  35. karani/ingestion/orchestrator.py +163 -0
  36. karani/ingestion/profile.py +59 -0
  37. karani/ingestion/remoteok.py +120 -0
  38. karani/ingestion/remotive.py +116 -0
  39. karani/ingestion/resume.py +55 -0
  40. karani/ingestion/roles.py +155 -0
  41. karani/ingestion/sources.py +4 -0
  42. karani/ingestion/storage.py +1383 -0
  43. karani/ingestion/targets.py +115 -0
  44. karani/ingestion/weworkremotely.py +86 -0
  45. karani/ingestion/workable.py +135 -0
  46. karani/intel/__init__.py +13 -0
  47. karani/intel/service.py +176 -0
  48. karani/mcp_server/__init__.py +6 -0
  49. karani/mcp_server/__main__.py +21 -0
  50. karani/mcp_server/server.py +657 -0
  51. karani/memory/__init__.py +9 -0
  52. karani/memory/manager.py +276 -0
  53. karani/notionsync/__init__.py +14 -0
  54. karani/notionsync/client.py +85 -0
  55. karani/notionsync/sync.py +189 -0
  56. karani/orchestration/__init__.py +13 -0
  57. karani/orchestration/__main__.py +69 -0
  58. karani/orchestration/graph.py +194 -0
  59. karani/qualification/__init__.py +24 -0
  60. karani/qualification/agent.py +181 -0
  61. karani/qualification/anthropic.py +56 -0
  62. karani/qualification/client.py +116 -0
  63. karani/qualification/factory.py +50 -0
  64. karani/qualification/local.py +50 -0
  65. karani/qualification/models.py +48 -0
  66. karani/qualification/openrouter.py +208 -0
  67. karani/qualification/prompts.py +216 -0
  68. karani/qualification/runner.py +124 -0
  69. karani/qualification/tools.py +277 -0
  70. karani/resources/docker-compose.yml +68 -0
  71. karani/resources/karani.daily.plist.template +22 -0
  72. karani/resources/karani.example.toml +45 -0
  73. karani/resources/karani.hourly.plist.template +19 -0
  74. karani/slackbridge/__init__.py +16 -0
  75. karani/slackbridge/__main__.py +24 -0
  76. karani/slackbridge/blocks.py +127 -0
  77. karani/slackbridge/client.py +67 -0
  78. karani/slackbridge/commands.py +329 -0
  79. karani/slackbridge/interactions.py +79 -0
  80. karani/slackbridge/listener.py +151 -0
  81. karani-0.3.0.dist-info/METADATA +448 -0
  82. karani-0.3.0.dist-info/RECORD +85 -0
  83. karani-0.3.0.dist-info/WHEEL +4 -0
  84. karani-0.3.0.dist-info/entry_points.txt +2 -0
  85. 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,7 @@
1
+ """`python -m karani` == the `karani` CLI."""
2
+ from __future__ import annotations
3
+
4
+ from karani.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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