asktheboard 0.2.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.
@@ -0,0 +1,67 @@
1
+ """ask-the-board (OSS) -- a board of expert personas whose every decision is a
2
+ pre-registered, time-anchored, reality-graded bet.
3
+
4
+ BYOK (bring your own API key): you supply your own LLM key, you pay your own
5
+ inference, the engine costs nothing to run at any scale. The durable asset is the
6
+ accumulating, externally attested track record -- the board that keeps score,
7
+ before the fact.
8
+ """
9
+
10
+ from .model import (
11
+ BoardMinute,
12
+ IntegrityError,
13
+ Prediction,
14
+ Resolution,
15
+ SeatCall,
16
+ brier,
17
+ )
18
+ from .ledger import Ledger, SeatScore
19
+ from .llm import LLMClient, NoProviderConfigured, require_client
20
+ from .convene import ConveneError, Seat, convene
21
+ from .http_client import HTTPLLMClient
22
+ from .decision_types import (
23
+ CATALOG,
24
+ DecisionType,
25
+ UnknownDecisionType,
26
+ resolution_date_for,
27
+ )
28
+ from .roster import (
29
+ UnknownPanel,
30
+ UnknownSeat,
31
+ panel,
32
+ panel_names,
33
+ seat,
34
+ seat_slugs,
35
+ seats,
36
+ )
37
+
38
+ __all__ = [
39
+ "BoardMinute",
40
+ "Prediction",
41
+ "Resolution",
42
+ "SeatCall",
43
+ "IntegrityError",
44
+ "brier",
45
+ "Ledger",
46
+ "SeatScore",
47
+ "LLMClient",
48
+ "NoProviderConfigured",
49
+ "require_client",
50
+ "Seat",
51
+ "convene",
52
+ "ConveneError",
53
+ "HTTPLLMClient",
54
+ "DecisionType",
55
+ "CATALOG",
56
+ "UnknownDecisionType",
57
+ "resolution_date_for",
58
+ "seat",
59
+ "seats",
60
+ "panel",
61
+ "seat_slugs",
62
+ "panel_names",
63
+ "UnknownSeat",
64
+ "UnknownPanel",
65
+ ]
66
+
67
+ __version__ = "0.2.0"
asktheboard/adr.py ADDED
@@ -0,0 +1,83 @@
1
+ """Render a BoardMinute as a git-committable Architecture Decision Record.
2
+
3
+ The board-minute IS the artifact: a markdown ADR a user commits to their own
4
+ repo. Git history then provides the external attestation of the anchor timestamp
5
+ -- the thing a funded competitor cannot retroactively manufacture.
6
+ """
7
+
8
+ from .model import BoardMinute
9
+
10
+
11
+ def _pct(p: float) -> str:
12
+ return f"{round(p * 100)}%"
13
+
14
+
15
+ def render_adr(minute: BoardMinute) -> str:
16
+ m = minute
17
+ if m.resolution is None:
18
+ status = f"Pre-registered (resolves {m.prediction.resolution_date.isoformat()})"
19
+ else:
20
+ verdict = "VINDICATED" if m.vindicated else "REFUTED"
21
+ status = f"Resolved {m.resolution.resolved_at.date().isoformat()} -- {verdict}"
22
+
23
+ lines: list[str] = []
24
+ lines.append(f"# ADR-{m.id}: {m.question}")
25
+ lines.append("")
26
+ lines.append(f"- **Status:** {status}")
27
+ lines.append(f"- **Anchored:** {m.created_at.isoformat()}")
28
+ lines.append(f"- **Resolution date:** {m.prediction.resolution_date.isoformat()}")
29
+ lines.append("")
30
+ lines.append("## Context (stated prior)")
31
+ lines.append("")
32
+ lines.append(m.prior.strip() or "_none recorded_")
33
+ lines.append("")
34
+ lines.append("## Decision")
35
+ lines.append("")
36
+ lines.append(m.decision.strip() or "_none recorded_")
37
+ lines.append("")
38
+ lines.append("## Pre-registered prediction")
39
+ lines.append("")
40
+ lines.append(f"> {m.prediction.statement.strip()}")
41
+ lines.append("")
42
+ lines.append(f"- **Board confidence:** {_pct(m.prediction.board_probability)} that this resolves TRUE")
43
+ lines.append(f"- **Resolves:** {m.prediction.resolution_date.isoformat()}")
44
+ lines.append("")
45
+ lines.append("## Board seats (dissent vector)")
46
+ lines.append("")
47
+ if m.seats:
48
+ resolved = m.resolution is not None
49
+ header = "| Seat | Stance | P(true) | "
50
+ sep = "|---|---|---|"
51
+ if resolved:
52
+ header += "Brier | "
53
+ sep += "---|"
54
+ header += "Rationale |"
55
+ sep += "---|"
56
+ lines.append(header)
57
+ lines.append(sep)
58
+ briers = m.seat_briers()
59
+ for s in m.seats:
60
+ row = f"| {s.seat} | {s.stance} | {_pct(s.probability)} | "
61
+ if resolved:
62
+ row += f"{briers[s.seat]:.3f} | "
63
+ row += f"{s.rationale.strip().replace(chr(10), ' ')} |"
64
+ lines.append(row)
65
+ else:
66
+ lines.append("_no seats recorded_")
67
+ lines.append("")
68
+
69
+ if m.resolution is not None:
70
+ o = "TRUE" if m.resolution.realized_outcome else "FALSE"
71
+ lines.append("## Resolution")
72
+ lines.append("")
73
+ lines.append(f"- **Realized outcome:** {o}")
74
+ lines.append(f"- **Board Brier:** {m.board_brier():.3f} (lower is better)")
75
+ winners = m.contrarian_winners()
76
+ if winners:
77
+ lines.append(f"- **Contrarian wins:** {', '.join(winners)} "
78
+ f"(dissented and beat the consensus)")
79
+ if m.resolution.note.strip():
80
+ lines.append(f"- **Note:** {m.resolution.note.strip()}")
81
+ lines.append("")
82
+
83
+ return "\n".join(lines).rstrip() + "\n"
asktheboard/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """Local CLI: convene a board (BYOK), persist board-minutes, grade, and score seats.
2
+
3
+ `create`/`resolve`/`score` need no network or key -- the foresight ledger runs
4
+ entirely on local files. `convene` is the BYOK fan-out: it reads YOUR key
5
+ (OPENAI_API_KEY) and runs the live LLM calls that *produce* a minute.
6
+
7
+ Usage:
8
+ python -m asktheboard.cli convene --spec convene.json --model <m> [--base-url ...]
9
+ python -m asktheboard.cli create --spec minute.json [--dir board-minutes]
10
+ python -m asktheboard.cli resolve --id <id> --outcome true|false [--note ...]
11
+ python -m asktheboard.cli score [--dir board-minutes]
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import math
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .adr import render_adr
21
+ from .convene import Seat, convene
22
+ from .http_client import HTTPLLMClient
23
+ from .ledger import Ledger
24
+ from .model import BoardMinute
25
+ from .roster import panel as roster_panel
26
+ from .roster import panel_names, seat_slugs, seats as roster_seats
27
+
28
+
29
+ def _store(dirpath: str) -> Path:
30
+ p = Path(dirpath)
31
+ p.mkdir(parents=True, exist_ok=True)
32
+ return p
33
+
34
+
35
+ def _load_all(store: Path) -> list[BoardMinute]:
36
+ out = []
37
+ for f in sorted(store.glob("*.json")):
38
+ out.append(BoardMinute.from_dict(json.loads(f.read_text(encoding="utf-8"))))
39
+ return out
40
+
41
+
42
+ def _save(store: Path, minute: BoardMinute) -> Path:
43
+ jpath = store / f"{minute.id}.json"
44
+ jpath.write_text(json.dumps(minute.to_dict(), indent=2), encoding="utf-8")
45
+ apath = store / f"{minute.id}.md"
46
+ apath.write_text(render_adr(minute), encoding="utf-8")
47
+ return apath
48
+
49
+
50
+ def _resolve_seats(args: argparse.Namespace, spec: dict) -> list[Seat]:
51
+ """Seats from --seats > --panel > the spec's own seat list."""
52
+ if args.seats:
53
+ return roster_seats([s.strip() for s in args.seats.split(",") if s.strip()])
54
+ if args.panel:
55
+ return roster_panel(args.panel)
56
+ spec_seats = spec.get("seats")
57
+ if not spec_seats:
58
+ raise SystemExit(
59
+ "no seats: pass --seats <slugs> or --panel <name>, "
60
+ 'or put a "seats" list in the spec'
61
+ )
62
+ return [Seat(name=s["name"], persona=s.get("persona", "")) for s in spec_seats]
63
+
64
+
65
+ def cmd_convene(args: argparse.Namespace) -> int:
66
+ store = _store(args.dir)
67
+ spec = json.loads(Path(args.spec).read_text(encoding="utf-8"))
68
+ seats = _resolve_seats(args, spec)
69
+ client = HTTPLLMClient(model=args.model, base_url=args.base_url)
70
+ minute = convene(
71
+ id=spec["id"],
72
+ question=spec["question"],
73
+ prior=spec.get("prior", ""),
74
+ decision=spec["decision"],
75
+ statement=spec["statement"],
76
+ seats=seats,
77
+ client=client,
78
+ decision_type=spec.get("decision_type"),
79
+ resolution_date=_date_or_none(spec.get("resolution_date")),
80
+ )
81
+ adr = _save(store, minute)
82
+ print(f"convened {minute.id}: board P(true)={minute.prediction.board_probability:.2f}, "
83
+ f"{len(minute.seats)} seats, resolves "
84
+ f"{minute.prediction.resolution_date.isoformat()}")
85
+ print(f" ADR -> {adr}")
86
+ return 0
87
+
88
+
89
+ def _date_or_none(s):
90
+ from datetime import date
91
+ return date.fromisoformat(s) if s else None
92
+
93
+
94
+ def cmd_roster(args: argparse.Namespace) -> int:
95
+ print("bundled seats:")
96
+ for slug in seat_slugs():
97
+ print(f" {slug}")
98
+ print("\npanels:")
99
+ for name in panel_names():
100
+ members = ", ".join(s.name for s in roster_panel(name))
101
+ print(f" {name:<10} {members}")
102
+ return 0
103
+
104
+
105
+ def cmd_create(args: argparse.Namespace) -> int:
106
+ store = _store(args.dir)
107
+ spec = json.loads(Path(args.spec).read_text(encoding="utf-8"))
108
+ minute = BoardMinute.from_dict(spec)
109
+ adr = _save(store, minute)
110
+ print(f"created {minute.id}: pre-registered, resolves "
111
+ f"{minute.prediction.resolution_date.isoformat()}")
112
+ print(f" ADR -> {adr}")
113
+ return 0
114
+
115
+
116
+ def cmd_resolve(args: argparse.Namespace) -> int:
117
+ store = _store(args.dir)
118
+ jpath = store / f"{args.id}.json"
119
+ if not jpath.exists():
120
+ print(f"no such minute: {args.id}", file=sys.stderr)
121
+ return 1
122
+ minute = BoardMinute.from_dict(json.loads(jpath.read_text(encoding="utf-8")))
123
+ outcome = args.outcome.lower() in ("true", "t", "yes", "y", "1")
124
+ minute.resolve(outcome, note=args.note or "")
125
+ _save(store, minute)
126
+ verdict = "VINDICATED" if minute.vindicated else "REFUTED"
127
+ print(f"resolved {minute.id}: outcome={'TRUE' if outcome else 'FALSE'} -- {verdict}")
128
+ print(f" board Brier {minute.board_brier():.3f}")
129
+ winners = minute.contrarian_winners()
130
+ if winners:
131
+ print(f" contrarian wins: {', '.join(winners)}")
132
+ return 0
133
+
134
+
135
+ def cmd_score(args: argparse.Namespace) -> int:
136
+ store = _store(args.dir)
137
+ ledger = Ledger()
138
+ ledger.extend(_load_all(store))
139
+ ranked = ledger.ranked_seats()
140
+ if not ranked:
141
+ print("no resolved minutes yet -- nothing to score")
142
+ return 0
143
+ print(f"{'seat':<16} {'n':>3} {'mean_brier':>11} {'wins':>5} {'losses':>7}")
144
+ print("-" * 46)
145
+ for sc in ranked:
146
+ mb = "n/a" if math.isnan(sc.mean_brier) else f"{sc.mean_brier:.3f}"
147
+ print(f"{sc.seat:<16} {sc.resolved_count:>3} {mb:>11} "
148
+ f"{sc.contrarian_wins:>5} {sc.contrarian_losses:>7}")
149
+ return 0
150
+
151
+
152
+ def build_parser() -> argparse.ArgumentParser:
153
+ p = argparse.ArgumentParser(prog="asktheboard", description=__doc__)
154
+ sub = p.add_subparsers(dest="cmd", required=True)
155
+
156
+ v = sub.add_parser("convene", help="BYOK: run the live LLM fan-out to produce a minute")
157
+ v.add_argument("--spec", required=True, help="path to the convene JSON spec")
158
+ v.add_argument("--model", required=True, help="model id (e.g. gpt-4o-mini)")
159
+ v.add_argument("--base-url", default="https://api.openai.com/v1",
160
+ help="OpenAI-compatible API base URL")
161
+ v.add_argument("--seats", default="",
162
+ help="comma-separated roster slugs (e.g. architect,skeptic); "
163
+ "overrides the spec's seats")
164
+ v.add_argument("--panel", default="",
165
+ help="a named panel from the bundled roster (e.g. tech); "
166
+ "used if --seats is absent")
167
+ v.add_argument("--dir", default="board-minutes", help="store directory")
168
+ v.set_defaults(func=cmd_convene)
169
+
170
+ rb = sub.add_parser("roster", help="list the bundled seats and panels")
171
+ rb.set_defaults(func=cmd_roster)
172
+
173
+ c = sub.add_parser("create", help="pre-register a board-minute from a JSON spec")
174
+ c.add_argument("--spec", required=True, help="path to the minute JSON spec")
175
+ c.add_argument("--dir", default="board-minutes", help="store directory")
176
+ c.set_defaults(func=cmd_create)
177
+
178
+ r = sub.add_parser("resolve", help="grade a minute against reality")
179
+ r.add_argument("--id", required=True)
180
+ r.add_argument("--outcome", required=True, help="true|false")
181
+ r.add_argument("--note", default="")
182
+ r.add_argument("--dir", default="board-minutes")
183
+ r.set_defaults(func=cmd_resolve)
184
+
185
+ s = sub.add_parser("score", help="per-seat calibration over resolved minutes")
186
+ s.add_argument("--dir", default="board-minutes")
187
+ s.set_defaults(func=cmd_score)
188
+
189
+ return p
190
+
191
+
192
+ def main(argv: list[str] | None = None) -> int:
193
+ args = build_parser().parse_args(argv)
194
+ return args.func(args)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ raise SystemExit(main())
asktheboard/convene.py ADDED
@@ -0,0 +1,141 @@
1
+ """Live convening -- fan a decision across board seats and assemble a BoardMinute.
2
+
3
+ This is the W1 fan-out: each seat answers *through the caller's LLMClient* (BYOK),
4
+ returns its stance + probability + rationale on the pre-registered prediction, and
5
+ the board's consensus probability is the mean of the seats' calls. The result is a
6
+ fully-formed, graded-ready `BoardMinute` -- the integrity rules (no backfilling, no
7
+ early grading, frozen anchor) are enforced by `model.py` on construction, so a
8
+ convening cannot produce a dishonest minute.
9
+
10
+ The engine still makes zero calls of its own: every token of inference runs on the
11
+ client you pass. Swap in `HTTPLLMClient` for production, a fake for tests.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass
19
+ from datetime import date, datetime
20
+ from typing import Optional, Sequence
21
+
22
+ from .decision_types import resolution_date_for
23
+ from .llm import LLMClient, require_client
24
+ from .model import BoardMinute, Prediction, SeatCall
25
+
26
+
27
+ class ConveneError(RuntimeError):
28
+ """Raised when a seat's answer cannot be parsed into a valid SeatCall."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Seat:
33
+ """A board seat: a name plus the persona/expertise that shapes its system prompt."""
34
+
35
+ name: str
36
+ persona: str
37
+
38
+
39
+ _SEAT_INSTRUCTION = (
40
+ "A decision is on the table and a falsifiable prediction has been registered.\n"
41
+ "Question: {question}\n"
42
+ "Board's decision: {decision}\n"
43
+ 'Pre-registered prediction (resolves {resolution_date}): "{statement}"\n\n'
44
+ "Give YOUR independent call. Reply with ONLY a JSON object, no prose:\n"
45
+ '{{"stance": "affirm" | "dissent", "probability": <number 0..1>, '
46
+ '"rationale": "<one sentence>"}}\n'
47
+ '- "stance": "affirm" if you back the board\'s decision, "dissent" if you break from it.\n'
48
+ '- "probability": your own P(the prediction resolves TRUE), as a number in [0, 1].\n'
49
+ "- Be willing to dissent: a contrarian who is right is worth more than a chorus."
50
+ )
51
+
52
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
53
+
54
+
55
+ def _parse_seat_call(seat: str, raw: str) -> SeatCall:
56
+ """Parse one seat's raw completion into a validated SeatCall."""
57
+ text = raw.strip()
58
+ # Strip a ```json ... ``` fence if the model added one.
59
+ if text.startswith("```"):
60
+ text = text.strip("`")
61
+ text = text[4:] if text.lower().startswith("json") else text
62
+ try:
63
+ obj = json.loads(text)
64
+ except json.JSONDecodeError:
65
+ m = _JSON_RE.search(raw)
66
+ if not m:
67
+ raise ConveneError(f"seat {seat!r}: no JSON object in answer: {raw!r}")
68
+ try:
69
+ obj = json.loads(m.group(0))
70
+ except json.JSONDecodeError as e:
71
+ raise ConveneError(f"seat {seat!r}: unparseable JSON: {raw!r}") from e
72
+ if not isinstance(obj, dict):
73
+ raise ConveneError(f"seat {seat!r}: expected a JSON object, got {obj!r}")
74
+ try:
75
+ stance = str(obj["stance"]).strip().lower()
76
+ probability = float(obj["probability"])
77
+ except (KeyError, TypeError, ValueError) as e:
78
+ raise ConveneError(f"seat {seat!r}: missing/invalid stance|probability: {obj!r}") from e
79
+ rationale = str(obj.get("rationale", "")).strip()
80
+ try:
81
+ return SeatCall(seat=seat, stance=stance, probability=probability, rationale=rationale)
82
+ except ValueError as e: # SeatCall enforces stance/probability domains
83
+ raise ConveneError(f"seat {seat!r}: {e}") from e
84
+
85
+
86
+ def convene(
87
+ *,
88
+ id: str,
89
+ question: str,
90
+ prior: str,
91
+ decision: str,
92
+ statement: str,
93
+ seats: Sequence[Seat],
94
+ client: Optional[LLMClient],
95
+ resolution_date: Optional[date] = None,
96
+ decision_type: Optional[str] = None,
97
+ created_at: Optional[datetime] = None,
98
+ ) -> BoardMinute:
99
+ """Convene the board on a decision and return a pre-registered BoardMinute.
100
+
101
+ Provide the resolution horizon either directly (`resolution_date`) or by naming
102
+ a `decision_type` whose default horizon is applied from the anchor. Every seat
103
+ answers through `client` (BYOK); the board probability is the mean of their
104
+ calls. Raises NoProviderConfigured if `client` is None, ConveneError on an
105
+ unparseable seat answer, and IntegrityError if the horizon is in the past.
106
+ """
107
+ client = require_client(client)
108
+ if not seats:
109
+ raise ConveneError("convene requires at least one seat")
110
+ anchor = created_at or datetime.now()
111
+ if resolution_date is None:
112
+ if decision_type is None:
113
+ raise ConveneError("pass either resolution_date or decision_type")
114
+ resolution_date = resolution_date_for(decision_type, anchor.date())
115
+
116
+ calls: list[SeatCall] = []
117
+ for seat in seats:
118
+ prompt = _SEAT_INSTRUCTION.format(
119
+ question=question,
120
+ decision=decision,
121
+ statement=statement,
122
+ resolution_date=resolution_date.isoformat(),
123
+ )
124
+ raw = client.complete(prompt, system=f"You are {seat.name}. {seat.persona}")
125
+ calls.append(_parse_seat_call(seat.name, raw))
126
+
127
+ board_probability = sum(c.probability for c in calls) / len(calls)
128
+ prediction = Prediction(
129
+ statement=statement,
130
+ resolution_date=resolution_date,
131
+ board_probability=board_probability,
132
+ )
133
+ return BoardMinute(
134
+ id=id,
135
+ question=question,
136
+ prior=prior,
137
+ decision=decision,
138
+ prediction=prediction,
139
+ seats=calls,
140
+ created_at=anchor,
141
+ )
@@ -0,0 +1,87 @@
1
+ """Decision-type catalog -- default resolution horizons that keep the loop honest.
2
+
3
+ A board-minute is only foresight if its prediction has a date by which reality can
4
+ grade it. Picking that date by hand every time is friction; worse, it lets an
5
+ operator quietly stretch the horizon until any call looks right. The catalog fixes
6
+ a sensible default horizon per decision class so the common case is one lookup and
7
+ the dishonest case (a 5-year horizon on a library swap) stands out.
8
+
9
+ Ordered short-latency first on purpose: the fastest-resolving decisions are how a
10
+ fresh board accumulates a track record before anyone will trust a slow one. Seed
11
+ the scoreboard with `library` calls; let the `architecture` bets ripen.
12
+
13
+ No clock magic here -- `resolution_date()` takes the anchor explicitly so it stays
14
+ pure and testable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from datetime import date, timedelta
21
+
22
+
23
+ class UnknownDecisionType(KeyError):
24
+ """Raised when a decision-type key is not in the catalog."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DecisionType:
29
+ """A class of decision with the default horizon over which it resolves."""
30
+
31
+ key: str
32
+ label: str
33
+ horizon_days: int
34
+ description: str
35
+
36
+ def resolution_date(self, anchor: date) -> date:
37
+ """The default date reality can grade a call of this type, given its anchor."""
38
+ return anchor + timedelta(days=self.horizon_days)
39
+
40
+
41
+ # Short-latency first -> a new board earns signal fast on these, then ripens the
42
+ # slower bets. Exactly the three the plan names; extend deliberately, not by reflex.
43
+ _TYPES = (
44
+ DecisionType(
45
+ "library",
46
+ "Library / dependency choice",
47
+ 90,
48
+ "Adopt, swap, or drop a library or dependency. Resolves within a quarter: "
49
+ "you know inside 90 days whether it held up in production.",
50
+ ),
51
+ DecisionType(
52
+ "migration",
53
+ "Migration / infrastructure change",
54
+ 180,
55
+ "Move a datastore, platform, or pipeline. Half-year horizon: migrations "
56
+ "reveal their true cost over a couple of release cycles.",
57
+ ),
58
+ DecisionType(
59
+ "architecture",
60
+ "Architecture / design bet",
61
+ 365,
62
+ "A structural design choice you will live with. Yearly horizon: the bill "
63
+ "for an architecture decision lands over the following year.",
64
+ ),
65
+ )
66
+
67
+ CATALOG: dict[str, DecisionType] = {t.key: t for t in _TYPES}
68
+
69
+
70
+ def get(key: str) -> DecisionType:
71
+ """Look up a decision type by key, raising UnknownDecisionType if absent."""
72
+ try:
73
+ return CATALOG[key]
74
+ except KeyError:
75
+ raise UnknownDecisionType(
76
+ f"unknown decision type {key!r}; known: {', '.join(CATALOG)}"
77
+ ) from None
78
+
79
+
80
+ def keys() -> list[str]:
81
+ """Catalog keys, short-latency first (insertion order)."""
82
+ return list(CATALOG)
83
+
84
+
85
+ def resolution_date_for(key: str, anchor: date) -> date:
86
+ """Default resolution date for a decision of type `key` anchored at `anchor`."""
87
+ return get(key).resolution_date(anchor)
@@ -0,0 +1,67 @@
1
+ """A minimal, dependency-free OpenAI-compatible LLMClient (BYOK).
2
+
3
+ Stdlib `urllib` only -- no `openai`, no `requests` -- so the open-source core stays
4
+ zero-dependency and the BYOK promise is literal: you bring the key, you bring the
5
+ endpoint, the engine bundles neither. Works against any OpenAI-compatible
6
+ `/chat/completions` API (OpenAI, OpenRouter, Together, a local server) by setting
7
+ `base_url` + `model`.
8
+
9
+ This satisfies the `LLMClient` Protocol structurally; nothing imports a vendor SDK.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+
20
+ class HTTPLLMClient:
21
+ """OpenAI-compatible chat client. Reads your key; pays your inference."""
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ model: str,
27
+ api_key: str | None = None,
28
+ base_url: str = "https://api.openai.com/v1",
29
+ timeout: float = 60.0,
30
+ temperature: float = 0.7,
31
+ ) -> None:
32
+ self.model = model
33
+ self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
34
+ if not self.api_key:
35
+ raise ValueError(
36
+ "no API key: pass api_key= or set OPENAI_API_KEY. ask-the-board is "
37
+ "bring-your-own-key -- it ships no provider and no credentials."
38
+ )
39
+ self.base_url = base_url.rstrip("/")
40
+ self.timeout = timeout
41
+ self.temperature = temperature
42
+
43
+ def complete(self, prompt: str, *, system: str = "") -> str:
44
+ """POST a chat completion and return the assistant's text."""
45
+ messages = []
46
+ if system:
47
+ messages.append({"role": "system", "content": system})
48
+ messages.append({"role": "user", "content": prompt})
49
+ payload = json.dumps(
50
+ {"model": self.model, "messages": messages, "temperature": self.temperature}
51
+ ).encode("utf-8")
52
+ req = urllib.request.Request(
53
+ f"{self.base_url}/chat/completions",
54
+ data=payload,
55
+ headers={
56
+ "Authorization": f"Bearer {self.api_key}",
57
+ "Content-Type": "application/json",
58
+ },
59
+ method="POST",
60
+ )
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
63
+ data = json.loads(resp.read().decode("utf-8"))
64
+ except urllib.error.HTTPError as e: # surface the provider's error body
65
+ body = e.read().decode("utf-8", "replace")
66
+ raise RuntimeError(f"LLM request failed ({e.code}): {body}") from e
67
+ return data["choices"][0]["message"]["content"]