Repository navigation
Route judgment stages to a System One decision model - #120
Conversation
Clef cannot chat, so scoring, assignment, approval votes, dedup, and autorater verdicts call /v1/systemone when DECISION_ENDPOINT_TYPE is systemone. Generative stages stay on the existing model.
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
…-judgments Resolve conflicts with upstream's bridging-score rework: - get_bridging_scores.py: keep upstream's _score_texts/get_bridging_scores split (dedup, reuse, skip reasons, --force_rerun, --min_text_length) and carry over the System One log line and the API-key check that allows a missing Gemini key when the decision path is enabled. - get_gemini_scores_lib.py: keep upstream's parse_score_response, retry cap and missing-pair warning, and re-apply only the System One branch (decision client in __init__, _score_with_decision). The PR's earlier pure-reformatting hunks are dropped where upstream rewrote those lines.
Per the Google Python style guide (2.2 Imports), reference System One types as systemone.SystemOneClient, systemone.NoulQuestion, etc., and decision.AttributeSpec, instead of importing the classes directly. The same applies to dataclasses, contextlib and pydantic in the new modules and to the new decision_test.py.
decision no longer re-exports SystemOneClient now that it imports the systemone module, so reference the class from systemone directly.
_choose_pack formatted its error with answer.choice, but no answer is in scope there, so an out-of-range choice raised NameError instead of a PARSE SystemOneError. Use the parsed choice and add a test.
Move every DECISION_* environment read into model_factory, next to the existing MODEL_ENDPOINT_TYPE handling: get_decision_model() builds the SystemOneClient (or returns None when DECISION_ENDPOINT_TYPE is unset) and get_decision_threshold() reads the cutoff. decision.py no longer calls getenv; decision_client() caches the factory's client and decision_enabled() is derived from it. Callers log client.model instead of reading DECISION_MODEL themselves. An unknown DECISION_ENDPOINT_TYPE is now a config error rather than silently disabling the path. Drop the hard-coded OLLAMA_HOST fallback in decision.py. When OLLAMA_HOST is unset, SystemOneClient's single OLLAMA_DEFAULT_BASE_URL applies; when set, it is parsed the way the Ollama CLI parses it (no scheme means http, no port means 11434), so values shared with the server such as 0.0.0.0:11434 work.
The example exported OLLAMA_HOST, DECISION_THRESHOLD and DECISION_MAX_CONCURRENT at their default values. Export only the two required variables and describe the optional ones and their defaults in prose.
The key is no longer always required for --scorer_type GEMINI: the System One path scores without it. Say so in the error printed by prepare_for_moderation and get_bridging_scores.
metasoarous
left a comment
There was a problem hiding this comment.
Thanks for the updates @audreyt! We're very excited to test this out :-)
I left some additional comments, most of which we'd just like your feedback on, or are optional to address (in particular regarding further decision model abstraction). A few things it would be helpful to address before merging:
- Remove "Other" from list of explicit categorization options
- Where possible, frame documentation and configuration option names primarily in more general terms, and explicitly reference ollama and clef only as specific examples which have been tested (see also here)
- If you agree it makes sense, switch to score for the autorater tasks
Thanks again!
| raise ValueError(f"Unknown MODEL_ENDPOINT_TYPE: {endpoint_type}") | ||
|
|
||
|
|
||
| def get_decision_model() -> systemone.SystemOneClient | None: |
There was a problem hiding this comment.
I think ideally get_decision_model would always return something of abstract type DecisionModel, which could be either a SystemOneDecisionModel or GenerativeDecisionModel. There may be some challenges in fully abstracting over those for the generative case, especially as relates to prompt structures, but probably worth doing long term.
This shouldn't be an obstacle to merging (we're happy to do the additional work to realize this, as it may be a bit involved), but wanted to at least make you aware and get your thoughts on it.
| decision_model = decision.decision_client() | ||
| if decision_model is not None: | ||
| score = float( | ||
| await decision.rubric_level(decision_model, str(prompt or "")) | ||
| ) | ||
| explanation = "" | ||
| else: | ||
| # Call GenaiModel | ||
| response = await self.model.call_gemini( | ||
| prompt=prompt, | ||
| run_name=f"eval_job_{job_id}", | ||
| response_mime_type="application/json", | ||
| ) | ||
|
|
||
| result_text = response.get("text", "") | ||
| score = 0.0 | ||
| explanation = "" | ||
| try: | ||
| # The model is instructed to return JSON. | ||
| json_result = json.loads(result_text) | ||
| score = float(json_result.get("score", 0)) | ||
| explanation = json_result.get("explanation", "") | ||
| except (json.JSONDecodeError, ValueError) as e: | ||
| raise ValueError( | ||
| f"Failed to parse JSON response: {result_text}. Error: {e}" | ||
| ) |
There was a problem hiding this comment.
Again, not a required changed, but pointing out a case where ideally we'd just have one abstract decision model to instantiate, and call in a uniform manner rather than having to bake in a bunch of conditional logic like this.
In reference to comment here: https://github.com/Jigsaw-Code/sensemaking-tools/pull/120/changes#r4342652420
| raise SystemOneError(FailureKind.HTTP, str(exc.reason)) from exc | ||
|
|
||
|
|
||
| def ollama_base_url(host: str) -> str: |
There was a problem hiding this comment.
Is any of this actually specific to ollama, or could it be generalized to other system one decision model hosts (e.g. Jev)?
| if voting_mode == VotingMode.APPROVAL and decision_model is not None: | ||
| logging.info( | ||
| "Approval votes will use System One model %s. Ranking stays on the" | ||
| " generative model.", | ||
| decision_model.model, | ||
| ) |
There was a problem hiding this comment.
I think this is fine for now, but thinking ahead, we probably could implement this as a recursive series of system one decision model calls that pick "the next highest ranked item" until all items are ranked.
| instructions: str | None = None, | ||
| ) -> int: | ||
| """Picks the 0-4 autorater level. Clef does not write the explanation.""" | ||
| question = systemone.ChoiceQuestion( |
There was a problem hiding this comment.
Should this be a ScoreQuestion instead? If I'm not mistaken, we're not actually using it anywhere yet, but this is the one part of the codebase where I think it makes sense.
| ##### Running judgments on Ollama Clef | ||
|
|
||
| Clef is a System One decision model. It does not implement chat, so `MODEL_ENDPOINT_TYPE=openai_api_compatible` cannot point at it. When the variables below are set, judgment stages call `POST /v1/systemone` on a local Ollama 0.35.1+ server. Generative stages stay on Gemini or the OpenAI-compatible model: topic and opinion names, quote extraction, report text, proposition writing, translation, and jury ranking. |
There was a problem hiding this comment.
I think it's fine to mention Ollama and Clef as specific examples, but ideally the opening framing would lead with more general descriptions around system one decision models.
There was a problem hiding this comment.
Reframed in c06d9b8. The section now leads with System One decision models, with Ollama Clef as the default and the tested hosts as examples.
| for quote in statement.quotes or []: | ||
| if quote.topic.name != topic.name: | ||
| continue | ||
| chosen = await decision.assign_labels( |
There was a problem hiding this comment.
Let's explicitly omit "Other" as an option, and only assign if no other options match.
There was a problem hiding this comment.
Done in fa7f842. Other is no longer asked, and is assigned only when no other option clears the threshold.
|
|
||
| import pydantic | ||
|
|
||
| MAX_QUESTIONS: Final = 64 |
There was a problem hiding this comment.
This is fine for now, but I think most of the other system one decision models support up to 256 max questions. May eventually want to consider how we would configure this.
There was a problem hiding this comment.
DECISION_MAX_QUESTIONS now sets this (default 64, Ollama's limit).
| f"{statement}" | ||
| ), | ||
| criteria={ | ||
| f"o{opt_index}": option for opt_index, option in enumerate(options) |
There was a problem hiding this comment.
I think it may be more idiomatic to explicitly use {Agree, Disagree, Pass} as options (as well as {"Strongly Agree", "Strongly Disagree"} for the Likert options). I don't know if it actually affects model performance with Clef, but I imagine that it might impact some system one decision model implementations.
There was a problem hiding this comment.
Left as o0, o1 keys for now, as scale labels such as "Strongly Agree" contain spaces and choice keys cannot.
Other is no longer asked as a yes/no label. It is assigned only when no other topic or opinion clears the threshold. The 0-4 autorater rubric is now a score question (levels lowest first), rounded half up to a whole level. On the System One opinion path a level below the upstream autorater_min_score, not a hardcoded 4, finalizes the quote as Other.
Hosts share the state-plus-questions request shape and differ in path and auth, so the client now takes an endpoint, an optional bearer key, and a question limit: - DECISION_MODEL_HOST replaces OLLAMA_HOST. A bare host is still read like the Ollama CLI reads it; a URL without a path gets /v1/systemone; a URL with a path is used as given. - DECISION_MODEL_API_KEY is sent as a bearer token. - keep_alive is Ollama-only (Jev returns 400 and Workers AI 422 for it), so it is sent only when no key is set. - Workers AI's result envelope is unwrapped. - DECISION_MAX_QUESTIONS sets the per-request limit (default 64). The README now leads with System One decision models in general, with local Ollama Clef as the default and the hosted endpoints tested live: Clef on Workers AI, TypeSafe Jev, and Inception Mercury Decide.
A local Ollama older than 0.35.1 answers /v1/systemone with a bare '404 page not found'. The error now names the URL, the Ollama version that serves decisions, and DECISION_MODEL_HOST for other hosts.
When no learned opinion clears the threshold the quote is already Other, and a failing rubric would only finalize it as Other again. A local run (gemma4 generation, Clef judgments, 24 responses) rated 20 such quotes and logged each as 'scored N; using Other'. With the skip the same run gives identical assignments and no wasted ratings.
Running the pipeline against local Clef and pplx-decider on Ollama 0.40.11,
pplx-decider-flash, and the hosted endpoints surfaced:
- Equivalence asked every pair in one request. With a short fixed state and
many pair questions, Clef's yes-probabilities drift upward: 16 or more
pairs per request linked unrelated propositions (0.52-0.57) that it
rejects when asked four at a time (at most 0.26), and union-find chained
them into one cluster. Pairs are now asked four per request, concurrently.
Jev, Mercury, and both pplx deciders do not drift; label assignment with
16 labels does not either.
- keep_alive was sent whenever no key was set, but pplx-decider-flash's
keyless server rejects it ("Unknown field 'keep_alive'"). It now defaults
to 30m only for the default local Ollama; DECISION_MODEL_KEEP_ALIVE sets it
for other Ollama hosts.
- One upstream 502 from a hosted endpoint aborted the stage. Rate limits,
server errors and dropped connections are now retried twice with backoff.
Rounding the probability-weighted score made the System One autorater reject correct opinion assignments: probability on the lower levels drags the mean below a clear top answer. On 16 correct and 16 wrong assignments (local and Workers AI Clef), the rounded mean accepted 7 correct, the most likely level 13, and the original choice question 14; none accepted a wrong one. Keeping the score question, as the review asked, and reading its most likely level recovers nearly all of the choice question's recall.
urlopen lets a connection that the host drops before answering escape as http.client.RemoteDisconnected rather than URLError, so it skipped the retry. Connection failures now become a status-less SystemOneError, which is retried. Adds docstrings to the helpers and tests introduced in this upgrade, checks that keep_alive is absent rather than null, and stops pinning rubric wording in a test.
Adds one-line docstrings to the PR's private wire models, parse helpers, test fixtures, nested fake transports and responses, and the older test methods, so every function and class in the PR's files carries one, as AGENTS.md asks. Documentation only.
An AST scan of the PR's model files found five private helpers with multi-line signatures that a line-based check missed: _body and _nbytes in systemone.py, _choice_label, _choose_pack and _clusters in decision.py. Documentation only.
|
Thanks @metasoarous! Pushed, with upstream main merged in. Point by point:
Running the whole pipeline locally also surfaced two more fixes. Equivalence now asks four pairs per request, as Clef linked unrelated propositions once 16 or more pairs shared one request; Jev, Mercury and the pplx deciders did not show this (177a1ec). Transient failures are retried, and a 404 from an Ollama older than 0.35.1 now says what to change (5a79e2c, 2ec265a). The System One opinion path also honors the new upstream The |
metasoarous
left a comment
There was a problem hiding this comment.
Great! Thanks for these changes!
Summary
System One decision models (Clef, Jev, Mercury Decide and others) answer typed questions with probabilities in one pass. They cannot chat or generate, so
MODEL_ENDPOINT_TYPE=openai_api_compatiblecannot point at them. This adds a System One client and routes the judgment stages to it whenDECISION_ENDPOINT_TYPE=systemone.Judgment stages that move:
Generative stages stay on Gemini or the OpenAI-compatible model: topic and opinion names, quote extraction, report text, proposition writing, translation and jury ranking.
Off by default. Enable with:
With only these set, requests go to
POST /v1/systemoneon a local Ollama 0.35.1+ server. Optional settings, all read inmodel_factory.get_decision_model():DECISION_MODEL_HOST: a bare host such asgpu-boxis read the way the Ollama CLI readsOLLAMA_HOST. A URL without a path gets/v1/systemone. A URL with a path is used as given.DECISION_MODEL_API_KEY: bearer key for a hosted endpoint.DECISION_MODEL_KEEP_ALIVE: defaults to30mfor the default local Ollama. With a host set it is sent only when given, as other servers reject the field.DECISION_MAX_QUESTIONS(default 64),DECISION_THRESHOLD(default 0.5),DECISION_MAX_CONCURRENT(default 1, as a local runner has one slot).Behavior notes:
autorater_min_scorefinalizes the quote as Other without retry, as the decision is deterministic.Test plan
pplx-decider:27b-q4_K_Mon Ollama 0.40.11, and pplx-decider-flash on its own server, for assignment, autorater, approval votes, equivalence and dedup, bridging CLI and moderation scoring