modeljury 0.1.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.
modeljury/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .claude import Claude
2
+ from .jury import PROMPT, Juror, Result, Vote, convene
3
+
4
+ __all__ = ["PROMPT", "Claude", "Juror", "Result", "Vote", "convene"]
modeljury/claude.py ADDED
@@ -0,0 +1,35 @@
1
+ """Claude jurors through the native Anthropic Messages API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .jury import Juror
9
+
10
+
11
+ @dataclass
12
+ class Claude(Juror):
13
+ """A Claude model, e.g. Claude("claude-opus-5-5").
14
+
15
+ Credentials come from ANTHROPIC_API_KEY or an `ant auth login` profile
16
+ unless api_key is given. Panel-wide kwargs are OpenAI parameters, so they
17
+ are not sent here; put Messages API options in params instead, e.g.
18
+ params={"output_config": {"effort": "low"}}. Requires modeljury[claude].
19
+ """
20
+
21
+ def ask(self, prompt: str, timeout: float, params: dict[str, Any]) -> str:
22
+ if self.client is None:
23
+ from anthropic import Anthropic
24
+
25
+ kwargs = {"api_key": self.api_key, "base_url": self.base_url}
26
+ self.client = Anthropic(**{k: v for k, v in kwargs.items() if v is not None})
27
+ merged = {"max_tokens": 16000, "timeout": timeout, **self.params}
28
+ resp = self.client.messages.create(
29
+ **{k: v for k, v in merged.items() if v is not None},
30
+ model=self.model,
31
+ messages=[{"role": "user", "content": prompt}],
32
+ )
33
+ if resp.stop_reason == "refusal":
34
+ raise RuntimeError(f"refused: {getattr(resp.stop_details, 'category', None)}")
35
+ return "".join(b.text for b in resp.content if b.type == "text")
modeljury/jury.py ADDED
@@ -0,0 +1,248 @@
1
+ """Convene a panel of models, count the votes, and flag decisions that need a human."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import os
8
+ import warnings
9
+ from collections import Counter
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Sequence
13
+
14
+ PROMPT = """You are one juror on a panel. Decide the question using only the evidence.
15
+
16
+ Question: {question}
17
+
18
+ Evidence:
19
+ {evidence}
20
+
21
+ Allowed options: {options}
22
+
23
+ Reply with only a JSON object:
24
+ {{"choice": "<one allowed option, exactly as written>", "confidence": <0.0-1.0>, "reason": "<one line>"}}"""
25
+
26
+
27
+ @dataclass
28
+ class Juror:
29
+ """A model behind any OpenAI-compatible chat completions endpoint.
30
+
31
+ base_url and api_key default to OPENAI_BASE_URL and OPENAI_API_KEY.
32
+ params are passed straight to chat.completions.create (temperature,
33
+ max_tokens, reasoning_effort, extra_body, ...) and override the panel's.
34
+ Set a param to None to leave it out, e.g. {"temperature": None} for
35
+ reasoning models that reject temperature.
36
+ """
37
+
38
+ model: str
39
+ base_url: str | None = None
40
+ api_key: str | None = None
41
+ name: str | None = None
42
+ params: dict[str, Any] = field(default_factory=dict)
43
+ client: Any = field(default=None, repr=False)
44
+
45
+ def __post_init__(self) -> None:
46
+ self.name = self.name or self.model
47
+
48
+ def ask(self, prompt: str, timeout: float, params: dict[str, Any]) -> str:
49
+ if self.client is None:
50
+ from openai import OpenAI
51
+
52
+ self.client = OpenAI(
53
+ base_url=self.base_url or os.environ.get("OPENAI_BASE_URL"),
54
+ api_key=self.api_key or os.environ.get("OPENAI_API_KEY", "none"),
55
+ )
56
+ merged = {"temperature": 0, "timeout": timeout, **params, **self.params}
57
+ resp = self.client.chat.completions.create(
58
+ **{k: v for k, v in merged.items() if v is not None},
59
+ model=self.model,
60
+ messages=[{"role": "user", "content": prompt}],
61
+ )
62
+ return resp.choices[0].message.content or ""
63
+
64
+
65
+ @dataclass
66
+ class Vote:
67
+ juror: str
68
+ choice: str | None
69
+ confidence: float | None = None
70
+ reason: str = ""
71
+ error: str | None = None
72
+
73
+
74
+ @dataclass
75
+ class Result:
76
+ verdict: str | None
77
+ votes: list[Vote]
78
+ agreement: float
79
+ needs_review: bool
80
+
81
+ @property
82
+ def majority(self) -> bool:
83
+ """True when the verdict won more than half of the whole panel, failed jurors included."""
84
+ won = sum(v.choice == self.verdict for v in self.votes)
85
+ return self.verdict is not None and won * 2 > len(self.votes)
86
+
87
+ @property
88
+ def dissent(self) -> list[tuple[str, str, str]]:
89
+ return [
90
+ (v.juror, v.choice, v.reason)
91
+ for v in self.votes
92
+ if v.choice is not None and v.choice != self.verdict
93
+ ]
94
+
95
+ @property
96
+ def failed(self) -> list[Vote]:
97
+ return [v for v in self.votes if v.choice is None]
98
+
99
+
100
+ def convene(
101
+ question: str,
102
+ evidence: str,
103
+ options: Sequence[str],
104
+ jurors: Sequence[Juror | str],
105
+ timeout: float = 60.0,
106
+ prompt: str = PROMPT,
107
+ **params: Any,
108
+ ) -> Result:
109
+ """Poll every juror in parallel and return the verdict with the full record.
110
+
111
+ needs_review is True unless every juror returned the same valid choice.
112
+ timeout applies to each attempt; the SDKs retry failed calls twice.
113
+
114
+ prompt is a str.format template with {question}, {evidence} and {options};
115
+ double any literal braces. Extra keyword arguments (temperature=0.3,
116
+ max_tokens=200, ...) go to every juror's chat.completions.create call;
117
+ a Juror's own params override them. temperature defaults to 0. Claude jurors
118
+ (any "claude-..." string, or Claude(...)) skip panel kwargs; see Claude.
119
+ """
120
+ if len(options) < 2:
121
+ raise ValueError("need at least two options")
122
+ if not jurors:
123
+ raise ValueError("need at least one juror")
124
+ if len(jurors) == 1:
125
+ warnings.warn(
126
+ "a single juror can't dissent, so needs_review only catches failures; use 3 or more jurors",
127
+ stacklevel=2,
128
+ )
129
+
130
+ panel = [j if isinstance(j, Juror) else _juror(j) for j in jurors]
131
+ names = _unique_names(panel)
132
+ text = prompt.format(
133
+ question=question, evidence=evidence, options=json.dumps(list(options))
134
+ )
135
+
136
+ with ThreadPoolExecutor(max_workers=len(panel)) as pool:
137
+ votes = list(
138
+ pool.map(lambda j, n: _poll(j, n, text, options, timeout, params), panel, names)
139
+ )
140
+
141
+ return tally(votes)
142
+
143
+
144
+ def _juror(model: str) -> Juror:
145
+ if model.startswith("claude-"):
146
+ from .claude import Claude
147
+
148
+ return Claude(model)
149
+ return Juror(model)
150
+
151
+
152
+ def _unique_names(panel: list[Juror]) -> list[str]:
153
+ """Juror names, with #2, #3, ... added to repeats so votes stay attributable."""
154
+ seen: Counter[str] = Counter()
155
+ names = []
156
+ for j in panel:
157
+ seen[j.name] += 1
158
+ names.append(j.name if seen[j.name] == 1 else f"{j.name}#{seen[j.name]}")
159
+ return names
160
+
161
+
162
+ def tally(votes: list[Vote]) -> Result:
163
+ counts = Counter(v.choice for v in votes if v.choice is not None)
164
+ if not counts:
165
+ return Result(verdict=None, votes=votes, agreement=0.0, needs_review=True)
166
+
167
+ ranked = counts.most_common()
168
+ top_choice, top_count = ranked[0]
169
+ hung = len(ranked) > 1 and ranked[1][1] == top_count
170
+ agreement = top_count / sum(counts.values())
171
+ failed = any(v.choice is None for v in votes)
172
+
173
+ return Result(
174
+ verdict=None if hung else top_choice,
175
+ votes=votes,
176
+ agreement=agreement,
177
+ needs_review=hung or failed or agreement < 1.0,
178
+ )
179
+
180
+
181
+ def _poll(
182
+ juror: Juror,
183
+ name: str,
184
+ prompt: str,
185
+ options: Sequence[str],
186
+ timeout: float,
187
+ params: dict[str, Any],
188
+ ) -> Vote:
189
+ try:
190
+ raw = juror.ask(prompt, timeout, params)
191
+ except Exception as e: # one bad juror must not sink the panel
192
+ error = f"{type(e).__name__}: {e}{_model_hint(juror, e)}"
193
+ return Vote(juror=name, choice=None, error=error)
194
+ return parse_vote(name, raw, options)
195
+
196
+
197
+ def _model_hint(juror: Juror, e: Exception) -> str:
198
+ """On a model-not-found error, list the endpoint's models. Best effort; "" if unknown."""
199
+ status = getattr(e, "status_code", None)
200
+ if status != 404 and not (status == 400 and "model" in str(e).lower()):
201
+ return ""
202
+ try:
203
+ ids = sorted(m.id for m in juror.client.models.list(timeout=10))
204
+ except Exception:
205
+ return ""
206
+ if not ids or juror.model in ids:
207
+ return ""
208
+ close = difflib.get_close_matches(juror.model, ids, n=3, cutoff=0.3)
209
+ if len(ids) <= 20:
210
+ return f"; available models: {', '.join(ids)}"
211
+ hint = f"; did you mean {', '.join(close)}?" if close else ""
212
+ return f"{hint} ({len(ids)} models available)"
213
+
214
+
215
+ def _last_json_object(raw: str) -> dict | None:
216
+ """The last JSON object in raw, skipping prose, code fences and <think> blocks."""
217
+ decoder = json.JSONDecoder()
218
+ found, i = None, raw.find("{")
219
+ while i != -1:
220
+ try:
221
+ found, end = decoder.raw_decode(raw, i)
222
+ except json.JSONDecodeError:
223
+ end = i + 1
224
+ i = raw.find("{", end)
225
+ return found
226
+
227
+
228
+ def parse_vote(juror: str, raw: str, options: Sequence[str]) -> Vote:
229
+ data = _last_json_object(raw)
230
+ if not isinstance(data, dict):
231
+ return Vote(juror=juror, choice=None, error=f"unparseable reply: {raw[:200]!r}")
232
+
233
+ choice = str(data.get("choice", "")).strip()
234
+ by_lower = {o.lower(): o for o in options}
235
+ if choice.lower() not in by_lower:
236
+ return Vote(juror=juror, choice=None, error=f"choice not in options: {choice!r}")
237
+
238
+ try:
239
+ confidence = float(data["confidence"])
240
+ except (KeyError, TypeError, ValueError):
241
+ confidence = None
242
+
243
+ return Vote(
244
+ juror=juror,
245
+ choice=by_lower[choice.lower()],
246
+ confidence=confidence,
247
+ reason=str(data.get("reason", "")).strip(),
248
+ )
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.5
2
+ Name: modeljury
3
+ Version: 0.1.0
4
+ Summary: Ask several models instead of one. Ship when they agree, send to a human when they don't.
5
+ Project-URL: Homepage, https://github.com/YasserManss/modeljury
6
+ Project-URL: Repository, https://github.com/YasserManss/modeljury
7
+ Project-URL: Issues, https://github.com/YasserManss/modeljury/issues
8
+ Author: YasserManss
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ensemble,evaluation,human-in-the-loop,llm,llm-as-a-judge,voting
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: openai>=1.0
25
+ Provides-Extra: claude
26
+ Requires-Dist: anthropic>=1.11.0; extra == 'claude'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # modeljury
30
+
31
+ Ask several models instead of one. Ship when they agree, send to a human when they don't.
32
+
33
+ ## Getting started
34
+
35
+ Python 3.10 or newer.
36
+
37
+ ```sh
38
+ pip install modeljury
39
+ # with Claude support
40
+ pip install "modeljury[claude]"
41
+ ```
42
+
43
+ Point it at any OpenAI-compatible endpoint (OpenAI, Ollama, vLLM, OpenRouter, ...) and
44
+ convene a panel. This one uses three models served by a local Ollama:
45
+
46
+ ```python
47
+ from modeljury import Juror, convene
48
+
49
+ local = "http://localhost:11434/v1"
50
+ result = convene(
51
+ question="Is this review spam?",
52
+ evidence="'Best product ever!!! Visit cheap-deals.example for 90% off!!!'",
53
+ options=["spam", "not spam"],
54
+ jurors=[Juror(m, base_url=local) for m in ["llama3.1", "qwen3", "mistral"]],
55
+ )
56
+
57
+ print(result.verdict, result.needs_review)
58
+ for juror, choice, reason in result.dissent:
59
+ print(f"{juror} disagreed: {choice}, {reason}")
60
+ ```
61
+
62
+ For hosted models, set `OPENAI_API_KEY` (and `OPENAI_BASE_URL` for non-OpenAI providers) or
63
+ pass `api_key=` and `base_url=` to each `Juror`. For Claude, set `ANTHROPIC_API_KEY`.
64
+
65
+ ## Usage
66
+
67
+ ```python
68
+ from modeljury import Juror, convene
69
+
70
+ result = convene(
71
+ question="Is this refund request fraudulent?",
72
+ evidence=ticket_text,
73
+ options=["fraud", "legit"],
74
+ jurors=[
75
+ Juror("gpt-4o-mini"),
76
+ Juror("llama3.1", base_url="http://localhost:11434/v1"),
77
+ Juror("mistral-small", base_url="https://api.mistral.ai/v1", api_key="..."),
78
+ ],
79
+ )
80
+
81
+ result.verdict # "legit", or None on a hung jury
82
+ result.majority # True: the verdict won more than half of the whole panel
83
+ result.needs_review # True
84
+ result.dissent # [("mistral-small", "fraud", "Shipping address changed after purchase")]
85
+ result.failed # jurors that errored or gave an invalid answer
86
+ ```
87
+
88
+ Any endpoint that speaks the OpenAI chat completions API works. A plain string juror
89
+ (`"gpt-4o-mini"`) uses `OPENAI_BASE_URL` and `OPENAI_API_KEY`.
90
+
91
+ ## Examples
92
+
93
+ These are real runs: five copies of one local 27B model, sampled at `temperature=1.0`.
94
+ The evidence is slightly trimmed here, and the quoted reasons are the model's own words.
95
+ A mixed panel of different models would disagree more often. See the warning at the end.
96
+
97
+ ### Refund with mixed signals
98
+
99
+ ```python
100
+ convene(
101
+ question="Should this refund be approved?",
102
+ evidence="Customer of 6 years, 40 prior orders, 2 prior refunds (both legitimate). "
103
+ "Claims a $1,900 laptop arrived with a cracked screen. The photo's EXIF date is "
104
+ "2 days before delivery. Courier marked it 'left at door', no signature. "
105
+ "Customer says their phone clock is wrong and offers to send the laptop back.",
106
+ options=["approve", "deny", "escalate"],
107
+ jurors=jurors,
108
+ )
109
+ ```
110
+
111
+ **escalate, 4–1, needs review.** The majority wanted the EXIF date checked. The dissent
112
+ made the case for approving:
113
+
114
+ > ✗ approve: *Strong 6-year history with only 2 prior refunds outweighs the EXIF
115
+ > inconsistency, which is plausibly explained by the customer's claim of a misconfigured
116
+ > phone clock.*
117
+
118
+ ### Bug triage across four teams
119
+
120
+ ```python
121
+ convene(
122
+ question="Which team should own this bug report?",
123
+ evidence="'After upgrading to v4.2, CSV exports from the dashboard are missing every "
124
+ "row created after 11pm. Only affects customers in Australia. Started the same day we "
125
+ "moved the export job to a new server.'",
126
+ options=["Frontend", "Data", "Infra", "Billing"],
127
+ jurors=jurors,
128
+ )
129
+ ```
130
+
131
+ **Data, 4–1, needs review.** Four jurors blamed timezone logic in the export pipeline.
132
+ The dissent was the only one that used the strongest clue:
133
+
134
+ > ✗ Infra: *The bug started immediately after moving the export job to a new server,
135
+ > and the time-based filtering error likely stems from a timezone configuration issue
136
+ > on that new infrastructure.*
137
+
138
+ A reviewer who reads only the dissent knows where to look first.
139
+
140
+ ### Merging a fast but risky cache
141
+
142
+ ```python
143
+ convene(
144
+ question="Should this pull request be merged as is?",
145
+ evidence="Adds a cache in front of the user-permissions lookup: p99 latency drops "
146
+ "from 900ms to 40ms. TTL is 10 minutes. Tests pass. A revoked admin keeps admin "
147
+ "rights for up to 10 minutes. Revocations happen about 3 times a month, and last "
148
+ "week's outage was caused by the slow lookup.",
149
+ options=["merge", "request changes", "merge with follow-up ticket"],
150
+ jurors=jurors,
151
+ )
152
+ ```
153
+
154
+ **merge with follow-up ticket, 5–0, ships.** All five weighed the latency gain against
155
+ the 10-minute window and asked for a ticket to add cache invalidation on revocation.
156
+
157
+ ### Satire or threat
158
+
159
+ ```python
160
+ convene(
161
+ question="Does this post violate the policy against threats of violence?",
162
+ evidence="Policy: remove credible threats of violence. Satire and hyperbole are allowed. "
163
+ "Post, replying to a council member's parking fee increase: 'If they raise parking "
164
+ "again I swear I'll show up at the next meeting and make sure he never votes on "
165
+ "anything again 🙂'. Author has no prior violations and often jokes about local politics.",
166
+ options=["violates", "allowed"],
167
+ jurors=jurors,
168
+ )
169
+ ```
170
+
171
+ **allowed, 5–0, ships.** This is the warning. The post names a target and a place, and
172
+ a human moderator might well escalate it. All five jurors dismissed it for the same
173
+ reason, mostly the smiley. Copies of one model share the same blind spots, so their
174
+ agreement is weaker evidence than it looks. Use jurors from different model families,
175
+ because a unanimous vote only means something if the jurors could have disagreed.
176
+
177
+ ## Model options
178
+
179
+ Extra keyword arguments to `convene()` go to every juror's `chat.completions.create` call.
180
+ A juror's own `params` override them. `temperature` defaults to `0`.
181
+
182
+ ```python
183
+ convene(..., temperature=0.3, max_tokens=200, jurors=[
184
+ Juror("gpt-4o-mini"), # temperature=0.3, max_tokens=200
185
+ Juror("o4-mini", params={"temperature": None, # None drops a param
186
+ "reasoning_effort": "low"}), # adds a model-specific option
187
+ Juror("qwen3", base_url="http://localhost:8000/v1",
188
+ params={"temperature": 0.7, "extra_body": {"top_k": 20}}), # overrides temperature
189
+ ])
190
+ ```
191
+
192
+ ## Claude
193
+
194
+ Claude uses the native Anthropic Messages API. Install the extra with `pip install modeljury[claude]`.
195
+
196
+ ```python
197
+ from modeljury import Claude
198
+
199
+ jurors = [
200
+ "gpt-4o-mini",
201
+ "claude-sonnet-5-5", # any "claude-..." string works
202
+ Claude("claude-opus-5-5", params={"output_config": {"effort": "low"}}),
203
+ ]
204
+ ```
205
+
206
+ - Credentials come from `ANTHROPIC_API_KEY` or an `ant auth login` profile, unless you pass `api_key=`.
207
+ - Panel-wide kwargs are OpenAI parameters, so Claude jurors don't receive them. Put Messages API
208
+ options in `params` instead. Current Claude models reject `temperature`, so none is sent.
209
+ - `max_tokens` defaults to 16000. Set it lower with `params={"max_tokens": ...}` if you need a cost cap.
210
+ Thinking counts toward it.
211
+ - If Claude refuses, the vote is recorded in `result.failed` and the decision goes to review.
212
+
213
+ ## Custom prompt
214
+
215
+ `prompt=` takes a `str.format` template with `{question}`, `{evidence}` and `{options}`.
216
+ Double any literal braces (`{{` and `}}`). The default is `modeljury.PROMPT`. Your template
217
+ must still ask for `{"choice": ..., "confidence": ..., "reason": ...}` JSON, because that's what the parser reads.
218
+
219
+ ## Rules
220
+
221
+ 1. Each juror picks one allowed option and gives a one-line reason.
222
+ 2. The verdict is the option with the most votes.
223
+ 3. `needs_review` is true unless every juror returned the same valid choice.
224
+ 4. A tie is a hung jury: no verdict, review needed.
225
+
226
+ `agreement` is the winning option's votes divided by valid votes. A juror that errors, refuses or
227
+ answers with an option that isn't allowed is left out of the count and listed in `result.failed`.
228
+
229
+ ## Outcomes
230
+
231
+ Three jurors, options `fraud` / `legit`:
232
+
233
+ | Votes | `verdict` | `agreement` | `majority` | `needs_review` | Why |
234
+ |---|---|---|---|---|---|
235
+ | legit, legit, legit | legit | 100% | ✅ | ❌ | Everyone agrees: ships without review |
236
+ | legit, legit, fraud | legit | 67% | ✅ | ✅ | Dissent; the reason is in `dissent` |
237
+ | legit, fraud, *failed* | `None` | 50% | ❌ | ✅ | Tie, so hung jury |
238
+ | legit, legit, *failed* | legit | 100% | ✅ | ✅ | A juror failed |
239
+ | legit, *failed*, *failed* | legit | 100% | ❌ | ✅ | Only one valid vote out of three |
240
+ | *failed* ×3 | `None` | 0% | ❌ | ✅ | No valid votes |
241
+
242
+ `verdict` is the plurality winner, so with three or more options it can win without a majority:
243
+ five votes split A, A, B, C, D give verdict A with 40% agreement and `majority` false.
244
+ `majority` counts the whole panel, failed jurors included, so it means more than half of
245
+ all jurors chose the verdict.
246
+
247
+ `needs_review` is the strict rule. For a looser one, check `majority` or `agreement` yourself:
248
+
249
+ ```python
250
+ if not result.majority: # ship any decision that more than half the panel backs
251
+ send_to_human(result)
252
+ ```
253
+
254
+ A single juror can't dissent, so its decisions only go to review when it fails. `convene()` warns if you pass only one juror.
255
+
256
+ ## Notes
257
+
258
+ - If a model name is wrong, the juror's error in `result.failed` lists the models the endpoint serves, or suggests the closest names when there are more than 20.
259
+ - Jurors with the same name (e.g. `llama3.1` on two endpoints) are recorded as `llama3.1`, `llama3.1#2`, ...
260
+ - `timeout` applies to each attempt. The OpenAI and Anthropic SDKs retry failed calls twice, so one juror can take up to three times `timeout`.
261
+
262
+ ## Develop
263
+
264
+ ```sh
265
+ uv sync
266
+ uv run pytest
267
+ ```
268
+
269
+ ## License
270
+
271
+ MIT. See [LICENSE](https://github.com/YasserManss/modeljury/blob/main/LICENSE).
@@ -0,0 +1,7 @@
1
+ modeljury/__init__.py,sha256=eJjurZcgkA_rHyj42yyr44AXw4xY7iK8SvyGRPzrtLI,152
2
+ modeljury/claude.py,sha256=z3m_ugYGc3PJqQCt0mC2O0dvpv3tMJ4vdUP_wtEPY6I,1393
3
+ modeljury/jury.py,sha256=VU9sHqhTY0Xf5osc_CVP4X1Td4ZnfpokiAIy1NpgOtA,8011
4
+ modeljury-0.1.0.dist-info/METADATA,sha256=k1k8BMMnmPF0vA6v0BFOnaKl92D4hN7Qce2pxVXj7GM,10963
5
+ modeljury-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ modeljury-0.1.0.dist-info/licenses/LICENSE,sha256=t4NshXpywUmS-Sjcjk6z1t_GA_UC8DtTdfydJBtFW7s,1079
7
+ modeljury-0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 modeljury contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.