eyewright 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.
- eyewright/__init__.py +6 -0
- eyewright/cli.py +69 -0
- eyewright/decide.py +132 -0
- eyewright/gate.py +177 -0
- eyewright/generative.py +117 -0
- eyewright/mcp_server.py +45 -0
- eyewright/server.py +68 -0
- eyewright-0.3.0.dist-info/METADATA +7 -0
- eyewright-0.3.0.dist-info/RECORD +11 -0
- eyewright-0.3.0.dist-info/WHEEL +4 -0
- eyewright-0.3.0.dist-info/entry_points.txt +2 -0
eyewright/__init__.py
ADDED
eyewright/cli.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""eyewright CLI: gate | ask | serve | mcp."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .decide import ask
|
|
9
|
+
from .gate import decide_gate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_cfg(path):
|
|
13
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
ap = argparse.ArgumentParser(prog="eyewright", description="eyewright - local decision core")
|
|
18
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
19
|
+
|
|
20
|
+
g = sub.add_parser("gate", help="three-eye gate on a claim")
|
|
21
|
+
g.add_argument("--state", required=True)
|
|
22
|
+
g.add_argument("--question", required=True)
|
|
23
|
+
g.add_argument("--config", default="examples/gate.json")
|
|
24
|
+
|
|
25
|
+
a = sub.add_parser("ask", help="typed decision (yes/no, choice, or scale)")
|
|
26
|
+
a.add_argument("--state", required=True)
|
|
27
|
+
a.add_argument("--question", required=True)
|
|
28
|
+
a.add_argument("--choices", help="JSON object: option key -> description (choice)")
|
|
29
|
+
a.add_argument("--levels", help="JSON array of level descriptions (score)")
|
|
30
|
+
a.add_argument("--engine", default="typed", choices=["typed", "generative", "auto"],
|
|
31
|
+
help="typed (decision models; default), generative (local LLM), or auto (typed, fall back)")
|
|
32
|
+
a.add_argument("--config", default="examples/gate.json")
|
|
33
|
+
|
|
34
|
+
s = sub.add_parser("serve", help="HTTP service (/v1/gate, /v1/ask)")
|
|
35
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
36
|
+
s.add_argument("--port", type=int, default=8090)
|
|
37
|
+
s.add_argument("--config", default="examples/gate.json")
|
|
38
|
+
|
|
39
|
+
m = sub.add_parser("mcp", help="MCP stdio server (needs the mcp extra)")
|
|
40
|
+
m.add_argument("--config", default="examples/gate.json")
|
|
41
|
+
|
|
42
|
+
args = ap.parse_args(argv)
|
|
43
|
+
cfg = _load_cfg(args.config)
|
|
44
|
+
|
|
45
|
+
if args.cmd == "gate":
|
|
46
|
+
p, trace = decide_gate(cfg, args.state, args.question)
|
|
47
|
+
print(json.dumps({"p_done": round(p, 4), "decision": trace.get("decision"), "trace": trace}, indent=2))
|
|
48
|
+
return 0
|
|
49
|
+
if args.cmd == "ask":
|
|
50
|
+
choices = json.loads(args.choices) if args.choices else None
|
|
51
|
+
levels = json.loads(args.levels) if args.levels else None
|
|
52
|
+
engine = None if args.engine == "typed" else args.engine
|
|
53
|
+
print(json.dumps(ask(cfg, args.state, args.question, choices=choices, levels=levels, engine=engine), indent=2))
|
|
54
|
+
return 0
|
|
55
|
+
if args.cmd == "serve":
|
|
56
|
+
from .server import serve
|
|
57
|
+
|
|
58
|
+
serve(cfg, args.host, args.port)
|
|
59
|
+
return 0
|
|
60
|
+
if args.cmd == "mcp":
|
|
61
|
+
from .mcp_server import run
|
|
62
|
+
|
|
63
|
+
run(cfg)
|
|
64
|
+
return 0
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
sys.exit(main())
|
eyewright/decide.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""eyewright.ask - one call, any typed decision.
|
|
2
|
+
|
|
3
|
+
eyewright's second face: not just the three-eye "done" gate, but a single
|
|
4
|
+
callable for typed decisions:
|
|
5
|
+
|
|
6
|
+
- noul (no choices): the three-eye gate policy on a yes/no question
|
|
7
|
+
- choice (choices dict): the primary decision model picks the option
|
|
8
|
+
- score (levels list): the primary decision model rates on the scale
|
|
9
|
+
|
|
10
|
+
Uniform answer: {"kind", "answer", "confidence", "probabilities"?, "trace"}.
|
|
11
|
+
|
|
12
|
+
Any program can call this in-process (``from eyewright import ask``); ``eyewright
|
|
13
|
+
serve`` exposes it over HTTP and ``eyewright mcp`` exposes it as MCP tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from .gate import decide_gate, load_key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _post_json(url, payload, timeout=1800):
|
|
23
|
+
data = json.dumps(payload).encode()
|
|
24
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
26
|
+
return json.loads(r.read().decode())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def call_engine(entry, state, questions):
|
|
30
|
+
"""One systemone call against a configured engine (an `entry` from the gate config)."""
|
|
31
|
+
t = entry.get("type")
|
|
32
|
+
if t == "systemone-http":
|
|
33
|
+
payload = {"state": state, "questions": questions}
|
|
34
|
+
if entry.get("model"):
|
|
35
|
+
payload["model"] = entry["model"]
|
|
36
|
+
req = urllib.request.Request(
|
|
37
|
+
entry["url"], data=json.dumps(payload).encode(),
|
|
38
|
+
headers={"Content-Type": "application/json"},
|
|
39
|
+
)
|
|
40
|
+
key = load_key(entry.get("key_file") or "")
|
|
41
|
+
if key:
|
|
42
|
+
req.add_header("Authorization", "Bearer " + key)
|
|
43
|
+
with urllib.request.urlopen(req, timeout=1800) as r:
|
|
44
|
+
return json.loads(r.read().decode())
|
|
45
|
+
if t == "systemone-ollama":
|
|
46
|
+
payload = {"model": entry["model"], "state": state, "questions": questions}
|
|
47
|
+
return _post_json("http://localhost:11434/v1/systemone", payload)
|
|
48
|
+
raise ValueError(f"unknown engine type {t!r}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ask(cfg, state, question, choices=None, levels=None, engine=None, qid="q"):
|
|
52
|
+
"""One decision. `choices` -> choice question; `levels` -> score; else yes/no (noul).
|
|
53
|
+
|
|
54
|
+
engine:
|
|
55
|
+
None / "typed" -> the decision models: noul runs the three-eye gate;
|
|
56
|
+
choice/score use the primary engine.
|
|
57
|
+
"generative" -> the local generative adjudicator (cfg["generative"]) answers
|
|
58
|
+
with a strict JSON contract; its confidence is the model's
|
|
59
|
+
own estimate (``"calibrated": false``).
|
|
60
|
+
"auto" -> typed first; fall back to the generative engine when the
|
|
61
|
+
typed path errors or has no signal (needs a generative block).
|
|
62
|
+
"""
|
|
63
|
+
if choices:
|
|
64
|
+
questions = {qid: {"type": "choice", "instructions": question, "criteria": choices}}
|
|
65
|
+
kind = "choice"
|
|
66
|
+
elif levels:
|
|
67
|
+
questions = {qid: {"type": "score", "instructions": question, "criteria": levels}}
|
|
68
|
+
kind = "score"
|
|
69
|
+
else:
|
|
70
|
+
questions = {qid: {"type": "noul", "instructions": question}}
|
|
71
|
+
kind = "noul"
|
|
72
|
+
|
|
73
|
+
if engine == "generative":
|
|
74
|
+
from .generative import ask_generative
|
|
75
|
+
|
|
76
|
+
return ask_generative(cfg, state, question, choices=choices, levels=levels, kind=kind)
|
|
77
|
+
|
|
78
|
+
if kind == "noul":
|
|
79
|
+
p, trace = decide_gate(cfg, state, question)
|
|
80
|
+
out = {
|
|
81
|
+
"kind": "noul",
|
|
82
|
+
"answer": "yes" if p >= 0.5 else "no",
|
|
83
|
+
"confidence": round(max(p, 1 - p), 4),
|
|
84
|
+
"p_true": round(p, 4),
|
|
85
|
+
"trace": trace,
|
|
86
|
+
}
|
|
87
|
+
if engine == "auto" and trace.get("decision") == "none" and cfg.get("generative"):
|
|
88
|
+
from .generative import ask_generative
|
|
89
|
+
|
|
90
|
+
fb = ask_generative(cfg, state, question, choices=choices, levels=levels, kind=kind)
|
|
91
|
+
fb["fallback"] = "typed->generative"
|
|
92
|
+
return fb
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
entry = cfg.get("primary")
|
|
96
|
+
if not entry:
|
|
97
|
+
raise ValueError("config has no primary engine")
|
|
98
|
+
try:
|
|
99
|
+
resp = call_engine(entry, state, questions)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
if engine == "auto" and cfg.get("generative"):
|
|
102
|
+
from .generative import ask_generative
|
|
103
|
+
|
|
104
|
+
fb = ask_generative(cfg, state, question, choices=choices, levels=levels, kind=kind)
|
|
105
|
+
fb["fallback"] = "typed->generative"
|
|
106
|
+
return fb
|
|
107
|
+
return {"kind": kind, "answer": None, "confidence": None, "error": str(e)[:200]}
|
|
108
|
+
ans = (resp.get("answers") or {}).get(qid) or {}
|
|
109
|
+
probs = ans.get("probabilities") or {}
|
|
110
|
+
if kind == "choice":
|
|
111
|
+
answer = ans.get("choice") or (max(probs, key=probs.get) if probs else None)
|
|
112
|
+
conf = ans.get("confidence")
|
|
113
|
+
if conf is None and answer is not None:
|
|
114
|
+
conf = probs.get(answer, 0.0)
|
|
115
|
+
else:
|
|
116
|
+
answer = ans.get("score")
|
|
117
|
+
conf = ans.get("confidence")
|
|
118
|
+
out = {
|
|
119
|
+
"kind": kind,
|
|
120
|
+
"answer": answer,
|
|
121
|
+
"confidence": round(float(conf), 4) if conf is not None else None,
|
|
122
|
+
"probabilities": probs,
|
|
123
|
+
"model": resp.get("model"),
|
|
124
|
+
"trace": {"engine": entry.get("name") or entry.get("model") or entry.get("type")},
|
|
125
|
+
}
|
|
126
|
+
if engine == "auto" and out.get("answer") is None and cfg.get("generative"):
|
|
127
|
+
from .generative import ask_generative
|
|
128
|
+
|
|
129
|
+
fb = ask_generative(cfg, state, question, choices=choices, levels=levels, kind=kind)
|
|
130
|
+
fb["fallback"] = "typed->generative"
|
|
131
|
+
return fb
|
|
132
|
+
return out
|
eyewright/gate.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""eyewright: the three-eye decision gate over cheap local decision models.
|
|
2
|
+
|
|
3
|
+
Canonical policy implementation. Consumed by the orbit repo
|
|
4
|
+
(``from eyewright.gate import decide_gate``) and validated by ``bench/``
|
|
5
|
+
(THREE-EYE.md, GATE-ANALYSIS.md).
|
|
6
|
+
|
|
7
|
+
Policy per question:
|
|
8
|
+
- primary (third eye) = laya-typed-decisions, usually via systemone-http
|
|
9
|
+
- p >= accept -> accept; p < consult -> review-low
|
|
10
|
+
- uncertain band [consult, accept) -> both consults answer
|
|
11
|
+
* >= 2 confident (max(p, 1-p) >= high) and same direction -> consensus
|
|
12
|
+
* exactly 1 confident, another consult within delta of main -> review-conflict
|
|
13
|
+
* otherwise -> review-weak / review-no-signal
|
|
14
|
+
|
|
15
|
+
Decisions: accept | consensus-accept | consensus-reject | review-low |
|
|
16
|
+
review-conflict | review-weak | review-no-signal | none. Returns (p_done, trace).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _post_json(url, payload, timeout=1800):
|
|
26
|
+
data = json.dumps(payload).encode()
|
|
27
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
28
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
29
|
+
return json.loads(r.read().decode())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_key(path):
|
|
33
|
+
"""Read an API key file; relative paths resolve against the cwd."""
|
|
34
|
+
if not path:
|
|
35
|
+
return ""
|
|
36
|
+
p = Path(path)
|
|
37
|
+
if not p.is_absolute():
|
|
38
|
+
p = Path.cwd() / p
|
|
39
|
+
return p.read_text(encoding="utf-8").strip() if p.exists() else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def systemone_ollama(stage, state, question):
|
|
43
|
+
payload = {
|
|
44
|
+
"model": stage["model"],
|
|
45
|
+
"state": state,
|
|
46
|
+
"questions": {"done": {"type": "noul", "instructions": question}},
|
|
47
|
+
}
|
|
48
|
+
resp = _post_json("http://localhost:11434/v1/systemone", payload)
|
|
49
|
+
return float(resp["answers"]["done"]["noul"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def systemone_http(url, key, state, question, model=None):
|
|
53
|
+
payload = {
|
|
54
|
+
"state": state,
|
|
55
|
+
"questions": {"done": {"type": "noul", "instructions": question}},
|
|
56
|
+
}
|
|
57
|
+
if model:
|
|
58
|
+
payload["model"] = model
|
|
59
|
+
req = urllib.request.Request(
|
|
60
|
+
url, data=json.dumps(payload).encode(),
|
|
61
|
+
headers={"Content-Type": "application/json"},
|
|
62
|
+
)
|
|
63
|
+
if key:
|
|
64
|
+
req.add_header("Authorization", "Bearer " + key)
|
|
65
|
+
with urllib.request.urlopen(req, timeout=1800) as r:
|
|
66
|
+
resp = json.loads(r.read().decode())
|
|
67
|
+
return float(resp["answers"]["done"]["noul"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def openrouter_decisions(entry, state, question):
|
|
71
|
+
"""Native decisions API (Typesafe Jev on OpenRouter).
|
|
72
|
+
|
|
73
|
+
Same `state`/`questions` shape as systemone, so Jev plugs into the committee
|
|
74
|
+
as a drop-in eye. Set ``"zdr": true`` on the entry to route only to
|
|
75
|
+
zero-data-retention providers; the API key comes from the environment
|
|
76
|
+
(``key_env``, default ``OPENROUTER_API_KEY``).
|
|
77
|
+
"""
|
|
78
|
+
url = entry.get("url") or "https://openrouter.ai/api/alpha/decisions"
|
|
79
|
+
payload = {
|
|
80
|
+
"model": entry["model"],
|
|
81
|
+
"state": state,
|
|
82
|
+
"questions": {"done": {"type": "noul", "instructions": question}},
|
|
83
|
+
}
|
|
84
|
+
if entry.get("zdr"):
|
|
85
|
+
payload["provider"] = {"zdr": True}
|
|
86
|
+
headers = {"Content-Type": "application/json"}
|
|
87
|
+
key = os.environ.get(entry.get("key_env") or "OPENROUTER_API_KEY", "")
|
|
88
|
+
if key:
|
|
89
|
+
headers["Authorization"] = "Bearer " + key
|
|
90
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers)
|
|
91
|
+
with urllib.request.urlopen(req, timeout=180) as r:
|
|
92
|
+
resp = json.loads(r.read().decode())
|
|
93
|
+
return float((resp.get("answers") or {}).get("done", {}).get("noul"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decide_gate(cfg, state, question):
|
|
97
|
+
"""Three-eye gate. Primary first; in the uncertain band, consult the others.
|
|
98
|
+
Trust a consensus of >=2 confident consults; otherwise review (retry/gather evidence).
|
|
99
|
+
|
|
100
|
+
Decisions: accept | consensus | review-low | review-conflict | review-weak |
|
|
101
|
+
review-no-signal | none. Returns (p_done, trace).
|
|
102
|
+
"""
|
|
103
|
+
accept = float(cfg.get("accept_threshold", 0.75))
|
|
104
|
+
consult = float(cfg.get("consult_threshold", 0.55))
|
|
105
|
+
high = float(cfg.get("high_confidence", 0.9))
|
|
106
|
+
delta = float(cfg.get("match_delta", 0.05))
|
|
107
|
+
trace = {"consults": {}}
|
|
108
|
+
|
|
109
|
+
def ask(entry):
|
|
110
|
+
t = entry.get("type")
|
|
111
|
+
if t == "systemone-http":
|
|
112
|
+
key = load_key(entry.get("key_file") or "")
|
|
113
|
+
return systemone_http(entry["url"], key, state, question, entry.get("model"))
|
|
114
|
+
if t == "systemone-ollama":
|
|
115
|
+
return systemone_ollama(entry, state, question)
|
|
116
|
+
if t == "openrouter-decisions":
|
|
117
|
+
return openrouter_decisions(entry, state, question)
|
|
118
|
+
raise ValueError("unknown consult type")
|
|
119
|
+
|
|
120
|
+
p1 = None
|
|
121
|
+
try:
|
|
122
|
+
p1 = ask(cfg.get("primary") or {})
|
|
123
|
+
except Exception as e:
|
|
124
|
+
trace["primary_error"] = str(e)[:140]
|
|
125
|
+
if p1 is None:
|
|
126
|
+
cons = cfg.get("consults") or []
|
|
127
|
+
if cons:
|
|
128
|
+
try:
|
|
129
|
+
p1 = ask(cons[0])
|
|
130
|
+
trace["primary_source"] = cons[0].get("name", "consult0")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
trace["primary_error2"] = str(e)[:140]
|
|
133
|
+
if p1 is None:
|
|
134
|
+
trace["decision"] = "none"
|
|
135
|
+
return 0.0, trace
|
|
136
|
+
trace["primary"] = round(p1, 4)
|
|
137
|
+
|
|
138
|
+
if p1 >= accept:
|
|
139
|
+
trace["decision"] = "accept"
|
|
140
|
+
return p1, trace
|
|
141
|
+
if p1 < consult:
|
|
142
|
+
trace["decision"] = "review-low"
|
|
143
|
+
return p1, trace
|
|
144
|
+
|
|
145
|
+
ps = []
|
|
146
|
+
for c in (cfg.get("consults") or []):
|
|
147
|
+
name = c.get("name") or c.get("model") or "consult"
|
|
148
|
+
if trace.get("primary_source") == name:
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
p = ask(c)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
p = None
|
|
154
|
+
trace["consults"][name + "_error"] = str(e)[:80]
|
|
155
|
+
trace["consults"][name] = None if p is None else round(p, 4)
|
|
156
|
+
if p is not None:
|
|
157
|
+
ps.append((name, p))
|
|
158
|
+
|
|
159
|
+
confident = [(n, p) for n, p in ps if max(p, 1 - p) >= high]
|
|
160
|
+
if len(confident) >= 2:
|
|
161
|
+
directions = {p >= 0.5 for _, p in confident}
|
|
162
|
+
if len(directions) == 1:
|
|
163
|
+
avg = sum(p for _, p in confident) / len(confident)
|
|
164
|
+
trace["decision"] = "consensus-accept" if avg >= 0.5 else "consensus-reject"
|
|
165
|
+
return avg, trace
|
|
166
|
+
trace["decision"] = "review-conflict"
|
|
167
|
+
return p1, trace
|
|
168
|
+
if len(confident) == 1:
|
|
169
|
+
cn, _ = confident[0]
|
|
170
|
+
others = [(n, p) for n, p in ps if n != cn]
|
|
171
|
+
if any(abs(p - p1) <= delta for _, p in others):
|
|
172
|
+
trace["decision"] = "review-conflict"
|
|
173
|
+
else:
|
|
174
|
+
trace["decision"] = "review-weak"
|
|
175
|
+
return p1, trace
|
|
176
|
+
trace["decision"] = "review-no-signal"
|
|
177
|
+
return p1, trace
|
eyewright/generative.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""eyewright generative engine - a general-model adjudicator behind ``ask``.
|
|
2
|
+
|
|
3
|
+
The typed engines (the laya/tev1 committee) answer the System One shape inside
|
|
4
|
+
their training distribution. This engine covers questions outside it: a local
|
|
5
|
+
generative model is asked with a strict JSON contract
|
|
6
|
+
|
|
7
|
+
{"answer": <"yes"|"no"|option key|level index>, "confidence": 0.0-1.0, "reason": "..."}
|
|
8
|
+
|
|
9
|
+
Confidence is the model's own estimate -> **not calibrated yet**. Ticket path D
|
|
10
|
+
(decide-engine shootout) covers post-hoc calibration; until then the result
|
|
11
|
+
carries ``"calibrated": false`` so callers can treat it accordingly.
|
|
12
|
+
|
|
13
|
+
Engine config (inside the gate config):
|
|
14
|
+
|
|
15
|
+
"generative": {"type": "ollama", "model": "gemma4:12b",
|
|
16
|
+
"options": {"temperature": 0, "num_predict": 200},
|
|
17
|
+
"think": false}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from .decide import _post_json
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ollama_chat(model, messages, options=None, fmt="json", think=None, timeout=1800):
|
|
27
|
+
payload = {
|
|
28
|
+
"model": model,
|
|
29
|
+
"messages": messages,
|
|
30
|
+
"stream": False,
|
|
31
|
+
"format": fmt,
|
|
32
|
+
"options": {"temperature": 0, **(options or {})},
|
|
33
|
+
}
|
|
34
|
+
if think is not None:
|
|
35
|
+
payload["think"] = bool(think)
|
|
36
|
+
resp = _post_json("http://localhost:11434/api/chat", payload, timeout=timeout)
|
|
37
|
+
return (resp.get("message") or {}).get("content", "") or ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_json(text):
|
|
41
|
+
m = re.search(r"\{.*\}", text, re.S)
|
|
42
|
+
if not m:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(m.group(0))
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _prompt(state, question, choices, levels):
|
|
51
|
+
if choices:
|
|
52
|
+
opts = "\n".join(f"- {k}: {v}" for k, v in choices.items())
|
|
53
|
+
answer_rule = "exactly one of these option keys: " + ", ".join(choices)
|
|
54
|
+
elif levels:
|
|
55
|
+
opts = "\n".join(f"- {i}: {v}" for i, v in enumerate(levels))
|
|
56
|
+
answer_rule = "the 0-based index of the best level"
|
|
57
|
+
else:
|
|
58
|
+
opts = "(none)"
|
|
59
|
+
answer_rule = '"yes" or "no"'
|
|
60
|
+
return (
|
|
61
|
+
"You are a decision engine. Answer with a single JSON object and nothing else:\n"
|
|
62
|
+
'{"answer": <answer>, "confidence": <0.0-1.0>, "reason": "<one short sentence>"}\n\n'
|
|
63
|
+
f'Rules: "answer" must be {answer_rule}. "confidence" is your estimated\n'
|
|
64
|
+
"probability that the answer is correct. Base the answer only on the state.\n\n"
|
|
65
|
+
f"STATE:\n{state}\n\nQUESTION:\n{question}\n\nOPTIONS:\n{opts}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ask_generative(cfg, state, question, choices=None, levels=None, kind=None):
|
|
70
|
+
"""One decision from the configured generative model (raw, uncalibrated)."""
|
|
71
|
+
g = cfg.get("generative") or {}
|
|
72
|
+
if not g:
|
|
73
|
+
raise ValueError("config has no 'generative' block")
|
|
74
|
+
kind = kind or ("choice" if choices else "score" if levels else "noul")
|
|
75
|
+
model = g.get("model") or "gemma4:12b"
|
|
76
|
+
content = _ollama_chat(
|
|
77
|
+
model,
|
|
78
|
+
[{"role": "user", "content": _prompt(state, question, choices, levels)}],
|
|
79
|
+
options=g.get("options"),
|
|
80
|
+
fmt=g.get("format", "json"),
|
|
81
|
+
think=g.get("think"),
|
|
82
|
+
)
|
|
83
|
+
obj = _extract_json(content) or {}
|
|
84
|
+
answer = obj.get("answer")
|
|
85
|
+
conf = obj.get("confidence")
|
|
86
|
+
|
|
87
|
+
if kind == "noul" and isinstance(answer, str):
|
|
88
|
+
a = answer.strip().lower()
|
|
89
|
+
if a.startswith("y"):
|
|
90
|
+
answer = "yes"
|
|
91
|
+
elif a.startswith("n"):
|
|
92
|
+
answer = "no"
|
|
93
|
+
if kind == "choice" and choices:
|
|
94
|
+
if isinstance(answer, str):
|
|
95
|
+
match = next((k for k in choices if k.lower() == answer.strip().lower()), None)
|
|
96
|
+
if match is None:
|
|
97
|
+
match = next((k for k in choices if k.lower() in answer.lower()), None)
|
|
98
|
+
answer = match
|
|
99
|
+
if kind == "score":
|
|
100
|
+
try:
|
|
101
|
+
answer = int(answer)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
pass
|
|
104
|
+
try:
|
|
105
|
+
conf = round(float(conf), 4) if conf is not None else None
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
conf = None
|
|
108
|
+
return {
|
|
109
|
+
"kind": kind,
|
|
110
|
+
"answer": answer,
|
|
111
|
+
"confidence": conf,
|
|
112
|
+
"reason": obj.get("reason"),
|
|
113
|
+
"model": model,
|
|
114
|
+
"calibrated": False,
|
|
115
|
+
"engine": "generative",
|
|
116
|
+
"raw": content[:600],
|
|
117
|
+
}
|
eyewright/mcp_server.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""eyewright mcp - eyewright as MCP tools for any AI harness.
|
|
2
|
+
|
|
3
|
+
Tools:
|
|
4
|
+
eyewright_gate(state, question) -> three-eye gate: p_done + decision + trace
|
|
5
|
+
eyewright_ask(state, question, choices?) -> typed decision (yes/no, or pick a choice)
|
|
6
|
+
|
|
7
|
+
Run as a stdio MCP server: ``eyewright mcp`` (needs the ``mcp`` extra:
|
|
8
|
+
``uv run --extra mcp eyewright mcp``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from mcp.server.mcpserver import MCPServer
|
|
12
|
+
|
|
13
|
+
from .decide import ask
|
|
14
|
+
from .gate import decide_gate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build(cfg):
|
|
18
|
+
mcp = MCPServer("eyewright")
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
def eyewright_gate(state: str, question: str) -> dict:
|
|
22
|
+
"""Gate a claim against the eyewright committee.
|
|
23
|
+
|
|
24
|
+
Returns p_done, the policy decision (accept / consensus-* / review-*),
|
|
25
|
+
and the full consult trace.
|
|
26
|
+
"""
|
|
27
|
+
p, trace = decide_gate(cfg, state, question)
|
|
28
|
+
return {"p_done": round(p, 4), "decision": trace.get("decision"), "trace": trace}
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def eyewright_ask(state: str, question: str, choices: dict | None = None, engine: str | None = None) -> dict:
|
|
32
|
+
"""Ask eyewright a typed decision.
|
|
33
|
+
|
|
34
|
+
With `choices` (option -> description) it picks an option; without, it is a
|
|
35
|
+
yes/no judgment. `engine`: None/typed (decision models), "generative" (local
|
|
36
|
+
LLM adjudicator, uncalibrated), or "auto" (typed, fall back to generative).
|
|
37
|
+
Returns answer, confidence, and the trace.
|
|
38
|
+
"""
|
|
39
|
+
return ask(cfg, state, question, choices=choices, engine=engine)
|
|
40
|
+
|
|
41
|
+
return mcp
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(cfg):
|
|
45
|
+
build(cfg).run()
|
eyewright/server.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""eyewright serve - one local HTTP endpoint for decisions.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /health
|
|
5
|
+
POST /v1/gate {"state": str, "question": str} -> {p_done, decision, trace}
|
|
6
|
+
POST /v1/ask {"state": str, "question": str, "choices"?: {...}} -> answer dict
|
|
7
|
+
|
|
8
|
+
Loopback-only by default (no auth on 127.0.0.1). Config is a JSON file with the
|
|
9
|
+
gate config (primary + consults); see examples/gate.json.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .decide import ask
|
|
17
|
+
from .gate import decide_gate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_handler(cfg):
|
|
21
|
+
class Handler(BaseHTTPRequestHandler):
|
|
22
|
+
server_version = f"eyewright/{__version__}"
|
|
23
|
+
|
|
24
|
+
def _send(self, code, obj):
|
|
25
|
+
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
26
|
+
self.send_response(code)
|
|
27
|
+
self.send_header("Content-Type", "application/json")
|
|
28
|
+
self.send_header("Content-Length", str(len(body)))
|
|
29
|
+
self.end_headers()
|
|
30
|
+
self.wfile.write(body)
|
|
31
|
+
|
|
32
|
+
def do_GET(self):
|
|
33
|
+
if self.path in ("/health", "/"):
|
|
34
|
+
self._send(200, {"status": "ok", "service": "eyewright", "version": __version__})
|
|
35
|
+
else:
|
|
36
|
+
self._send(404, {"error": "not found"})
|
|
37
|
+
|
|
38
|
+
def do_POST(self):
|
|
39
|
+
try:
|
|
40
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
41
|
+
body = json.loads(self.rfile.read(n).decode() or "{}")
|
|
42
|
+
except Exception as e:
|
|
43
|
+
self._send(400, {"error": f"bad json: {e}"})
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
if self.path == "/v1/gate":
|
|
47
|
+
p, trace = decide_gate(cfg, body.get("state", ""), body.get("question", ""))
|
|
48
|
+
self._send(200, {"p_done": round(p, 4), "decision": trace.get("decision"), "trace": trace})
|
|
49
|
+
elif self.path == "/v1/ask":
|
|
50
|
+
out = ask(cfg, body.get("state", ""), body.get("question", ""),
|
|
51
|
+
choices=body.get("choices"), levels=body.get("levels"),
|
|
52
|
+
engine=body.get("engine"))
|
|
53
|
+
self._send(200, out)
|
|
54
|
+
else:
|
|
55
|
+
self._send(404, {"error": "not found"})
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self._send(500, {"error": str(e)[:300]})
|
|
58
|
+
|
|
59
|
+
def log_message(self, fmt, *args):
|
|
60
|
+
print("[eyewright] " + fmt % args)
|
|
61
|
+
|
|
62
|
+
return Handler
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def serve(cfg, host="127.0.0.1", port=8090):
|
|
66
|
+
httpd = ThreadingHTTPServer((host, port), make_handler(cfg))
|
|
67
|
+
print(f"eyewright serving on http://{host}:{port} (gate: /v1/gate, ask: /v1/ask)")
|
|
68
|
+
httpd.serve_forever()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
eyewright/__init__.py,sha256=sh3Fc5Namc5qXprcmMnyPVxylTz6Ncp5gRwB_4LAQ-Y,221
|
|
2
|
+
eyewright/cli.py,sha256=D3mV9EHmk2Ua8NYB5RUW8QMBl9TL9USHCeNTIIp4o0I,2557
|
|
3
|
+
eyewright/decide.py,sha256=EcXlj_7EMBlrZHv54wa8kp8D_O5X056n964bs9I4cJ0,5419
|
|
4
|
+
eyewright/gate.py,sha256=Bfxm4v9v2ML0E-a8zRDk-jYYrdFZCJiZ8R57u8r_kLM,6572
|
|
5
|
+
eyewright/generative.py,sha256=3JcTla35LftIRM60rFkvIRA3AW37TmfBVMLWVRXGmkM,4172
|
|
6
|
+
eyewright/mcp_server.py,sha256=LM-wKOWfk8NP7AKbJvS4_xAYwiH3szR38Cn_NIeTOEA,1531
|
|
7
|
+
eyewright/server.py,sha256=us6MSj-N6EtsR-_8URk8XgkTSk7s_zHMzAbwi_48jzk,2664
|
|
8
|
+
eyewright-0.3.0.dist-info/METADATA,sha256=jiBkZi3W9NJAawlW8MPQMgPxX3qy75sB_j-L5t8RyY4,219
|
|
9
|
+
eyewright-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
eyewright-0.3.0.dist-info/entry_points.txt,sha256=JC9Ggj4qmC3mJXdbJn26NKekvbwUNgm_DqHzP9Dpde4,49
|
|
11
|
+
eyewright-0.3.0.dist-info/RECORD,,
|