Classifies notes from an inbox folder and files them, with a human approval step. Zero third-party dependencies. - type mirrors folder (200 projects / 300 areas / 400 Personal Family) - TypeSafe JEV for semantic classification (~$0.0001 per note) - watcher writes a proposal INTO the note; the owner ticks a checkbox - systemd user service on .27, 10s poll - 15/15 functional tests
251 lines
7.7 KiB
Python
251 lines
7.7 KiB
Python
"""
|
|
TypeSafe JEV client — stdlib only.
|
|
|
|
Deliberately dependency-free (urllib, not the official SDK) so the sorter
|
|
deploys onto hosts without pip.
|
|
|
|
JEV answers three primitive question types:
|
|
choice → picks one label from your criteria, with calibrated confidence
|
|
noul → probability that a statement is true (0..1)
|
|
score → expected value on an ordered rubric
|
|
|
|
API: POST https://api.typesafe.ai/v1/systemone
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
DEFAULT_BASE_URL = "https://api.typesafe.ai/v1"
|
|
DEFAULT_MODEL = "jev-latest"
|
|
|
|
# JEV pricing: $0.042 per 1M input tokens; output tokens are free.
|
|
USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
|
|
|
|
# Fallback locations for the API key. On NixOS the key usually lives in
|
|
# environment.d, which is loaded for systemd user sessions but NOT for
|
|
# non-interactive SSH shells — so we read it directly.
|
|
SECRETS_GLOBS = (
|
|
"~/.config/environment.d/*.conf",
|
|
"~/.config/obsidian-sorter/secrets.conf",
|
|
)
|
|
|
|
|
|
def _key_from_secrets() -> str | None:
|
|
"""Find TYPESAFE_API_KEY in the environment.d secrets files."""
|
|
for pattern in SECRETS_GLOBS:
|
|
for path in sorted(glob.glob(os.path.expanduser(pattern))):
|
|
try:
|
|
text = open(path, encoding="utf-8").read()
|
|
except OSError:
|
|
continue
|
|
m = re.search(
|
|
r"^\s*(?:export\s+)?TYPESAFE_API_KEY\s*=\s*(.+?)\s*$",
|
|
text, re.MULTILINE,
|
|
)
|
|
if m:
|
|
value = m.group(1).strip().strip('"').strip("'")
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
class JevError(RuntimeError):
|
|
"""Raised when a JEV request fails."""
|
|
|
|
|
|
# ------------------------------------------------------------ question builders
|
|
|
|
def choice(instructions: str, criteria: dict[str, str | None]) -> dict[str, Any]:
|
|
"""A question that selects one label from `criteria`."""
|
|
return {"type": "choice", "instructions": instructions, "criteria": criteria}
|
|
|
|
|
|
def noul(instructions: str, true: str | None = None, false: str | None = None) -> dict[str, Any]:
|
|
"""A yes/no question."""
|
|
q: dict[str, Any] = {"type": "noul", "instructions": instructions}
|
|
if true is not None or false is not None:
|
|
q["criteria"] = {"true": true, "false": false}
|
|
return q
|
|
|
|
|
|
def score(instructions: str, criteria: list[str]) -> dict[str, Any]:
|
|
"""An ordered rating; `criteria` is one description per level from 0."""
|
|
return {"type": "score", "instructions": instructions, "criteria": criteria}
|
|
|
|
|
|
# --------------------------------------------------------------------- results
|
|
|
|
@dataclass
|
|
class Answer:
|
|
"""One answer returned by JEV."""
|
|
|
|
name: str
|
|
kind: str # "choice" | "noul" | "score"
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def label(self) -> str | None:
|
|
return self.raw.get("choice")
|
|
|
|
@property
|
|
def confidence(self) -> float:
|
|
"""Confidence for choice/score; probability for noul."""
|
|
if self.kind == "noul":
|
|
return float(self.raw.get("noul", 0.5))
|
|
return float(self.raw.get("confidence", 0.0))
|
|
|
|
@property
|
|
def probabilities(self) -> dict[str, float]:
|
|
return self.raw.get("probabilities", {}) or {}
|
|
|
|
@property
|
|
def value(self) -> float | None:
|
|
if self.kind == "score":
|
|
return float(self.raw.get("score", 0.0))
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class JevResult:
|
|
"""A complete JEV response."""
|
|
|
|
answers: dict[str, Answer]
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
model: str = ""
|
|
error: str | None = None
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.error is None
|
|
|
|
@property
|
|
def cost_usd(self) -> float:
|
|
return self.input_tokens * USD_PER_INPUT_TOKEN
|
|
|
|
def answer(self, name: str) -> Answer | None:
|
|
return self.answers.get(name)
|
|
|
|
|
|
# ---------------------------------------------------------------------- client
|
|
|
|
class JevClient:
|
|
"""Thin JEV HTTP client."""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str | None = None,
|
|
model: str = DEFAULT_MODEL,
|
|
base_url: str = DEFAULT_BASE_URL,
|
|
timeout: float = 90.0,
|
|
):
|
|
key = (
|
|
api_key
|
|
or os.environ.get("TYPESAFE_API_KEY", "")
|
|
or _key_from_secrets()
|
|
or ""
|
|
)
|
|
if not key:
|
|
raise JevError(
|
|
"No API key. Set TYPESAFE_API_KEY in the environment, add it to "
|
|
"~/.config/environment.d/*.conf, or pass api_key=."
|
|
)
|
|
self.api_key = key
|
|
self.model = model
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
|
|
def ask(
|
|
self,
|
|
state: Any,
|
|
questions: dict[str, dict[str, Any]],
|
|
) -> JevResult:
|
|
"""Send one system-one request. Never raises for API errors — returns a JevResult with .error set."""
|
|
if not questions:
|
|
raise JevError("At least one question is required.")
|
|
|
|
payload = {"state": state, "model": self.model, "questions": questions}
|
|
body = json.dumps(payload).encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
f"{self.base_url}/systemone",
|
|
data=body,
|
|
method="POST",
|
|
headers={
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
"User-Agent": "obsidian-sorter/0.1",
|
|
},
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as e:
|
|
detail = ""
|
|
try:
|
|
detail = e.read().decode("utf-8")[:500]
|
|
except Exception:
|
|
pass
|
|
return JevResult(answers={}, error=f"HTTP {e.code}: {detail or e.reason}")
|
|
except urllib.error.URLError as e:
|
|
return JevResult(answers={}, error=f"network error: {e.reason}")
|
|
except (json.JSONDecodeError, TimeoutError) as e:
|
|
return JevResult(answers={}, error=f"{type(e).__name__}: {e}")
|
|
|
|
answers: dict[str, Answer] = {}
|
|
for name, raw in (data.get("answers") or {}).items():
|
|
kind = raw.get("type", "choice")
|
|
answers[name] = Answer(name=name, kind=kind, raw=raw)
|
|
|
|
usage = data.get("usage") or {}
|
|
return JevResult(
|
|
answers=answers,
|
|
input_tokens=int(usage.get("input_tokens") or 0),
|
|
output_tokens=int(usage.get("output_tokens") or 0),
|
|
model=data.get("model", self.model),
|
|
)
|
|
|
|
|
|
class CostTracker:
|
|
"""Accumulates token usage and enforces a daily call cap."""
|
|
|
|
def __init__(self, max_calls_per_run: int = 500):
|
|
self.calls = 0
|
|
self.input_tokens = 0
|
|
self.output_tokens = 0
|
|
self.max_calls_per_run = max_calls_per_run
|
|
self.errors = 0
|
|
|
|
def record(self, result: JevResult) -> None:
|
|
self.calls += 1
|
|
self.input_tokens += result.input_tokens
|
|
self.output_tokens += result.output_tokens
|
|
if not result.ok:
|
|
self.errors += 1
|
|
|
|
@property
|
|
def cost_usd(self) -> float:
|
|
return self.input_tokens * USD_PER_INPUT_TOKEN
|
|
|
|
def budget_exceeded(self) -> bool:
|
|
return self.calls >= self.max_calls_per_run
|
|
|
|
def summary(self) -> dict[str, Any]:
|
|
return {
|
|
"calls": self.calls,
|
|
"input_tokens": self.input_tokens,
|
|
"output_tokens": self.output_tokens,
|
|
"cost_usd": round(self.cost_usd, 6),
|
|
"errors": self.errors,
|
|
}
|