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.
- asktheboard/__init__.py +67 -0
- asktheboard/adr.py +83 -0
- asktheboard/cli.py +198 -0
- asktheboard/convene.py +141 -0
- asktheboard/decision_types.py +87 -0
- asktheboard/http_client.py +67 -0
- asktheboard/ledger.py +73 -0
- asktheboard/llm.py +35 -0
- asktheboard/model.py +265 -0
- asktheboard/roster.py +127 -0
- asktheboard-0.2.0.dist-info/METADATA +256 -0
- asktheboard-0.2.0.dist-info/RECORD +16 -0
- asktheboard-0.2.0.dist-info/WHEEL +5 -0
- asktheboard-0.2.0.dist-info/entry_points.txt +2 -0
- asktheboard-0.2.0.dist-info/licenses/LICENSE +21 -0
- asktheboard-0.2.0.dist-info/top_level.txt +1 -0
asktheboard/__init__.py
ADDED
|
@@ -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"]
|