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 ADDED
@@ -0,0 +1,6 @@
1
+ """eyewright - local decision core: the three-eye gate + one-call typed decisions."""
2
+
3
+ from .decide import ask
4
+ from .gate import decide_gate, load_key, systemone_http, systemone_ollama # noqa: F401
5
+
6
+ __version__ = "0.3.0"
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
@@ -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
+ }
@@ -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,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: eyewright
3
+ Version: 0.3.0
4
+ Summary: eyewright - local decision core: three-eye gate + one-call typed decisions
5
+ Requires-Python: >=3.12
6
+ Provides-Extra: mcp
7
+ Requires-Dist: mcp>=2; extra == 'mcp'
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eyewright = eyewright.cli:main