portage-ucp-decision 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +104 -0
- data/lib/portage/ucp/decision/confidence_gate.rb +57 -0
- data/lib/portage/ucp/decision/errors.rb +20 -0
- data/lib/portage/ucp/decision/escalation_policy.rb +29 -0
- data/lib/portage/ucp/decision/model_backends/answer.rb +47 -0
- data/lib/portage/ucp/decision/model_backends/jev.rb +85 -0
- data/lib/portage/ucp/decision/model_backends/laya.rb +133 -0
- data/lib/portage/ucp/decision/model_backends.rb +31 -0
- data/lib/portage/ucp/decision/offer_ranking.rb +27 -0
- data/lib/portage/ucp/decision/policy_check.rb +28 -0
- data/lib/portage/ucp/decision/version.rb +7 -0
- data/lib/portage/ucp/decision.rb +11 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 76adc7a80bc65b5fc181bc69c773cea60c9ad1f11a6bb3028add71d895321683
|
|
4
|
+
data.tar.gz: e6048995c2bbadb78389342bfebe78c25eafefb04a95a158747ba2a97f2120ba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6b95c193adcc3c78e19bf01ebdc88eb88b681b5414c081af1d84438a93c025be438e78735637a136e791dc7ca5931d6689451421b2fb7d8062c81a99fc7ed2b1
|
|
7
|
+
data.tar.gz: b7d76577b2e68e0cea4f5e8701cdcdf8fad1e799fefcb594668e7764fe6d368676e6278b2ede5115d536b34c2c2bf4d44a0e1c237286ab298c9aa2e379fdc9e0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
|
+
pre-1.0, so APIs may still shift between minor versions.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
- `OfferRanking` and `EscalationPolicy` now delegate to
|
|
10
|
+
`portage-ucp`'s `Support::OfferRanking` and `Support::Escalation`, the
|
|
11
|
+
way `PolicyCheck` already delegated to `PolicyGuard`. Their `Verdict`s
|
|
12
|
+
and signatures are unchanged. Requires `portage-ucp ~> 0.9`.
|
|
13
|
+
- **Removed surface nothing called**, before the first publish (the README's
|
|
14
|
+
"What this gem leaves out" says why for each). **Breaking** against the
|
|
15
|
+
0.1.0 skeleton:
|
|
16
|
+
- `ConfidenceGate.via_backend` asks a noul only. `type:`, `criteria:` and
|
|
17
|
+
`proceed_on:` are gone. A choice or score answer now raises
|
|
18
|
+
`BackendError` ("carried no probability") rather than gating.
|
|
19
|
+
- `ModelBackends::Answer#probabilities`.
|
|
20
|
+
- `EscalationPolicy`'s `signals: {mismatch:}` and
|
|
21
|
+
`EscalationPolicy::ESCALATING_STATUSES`.
|
|
22
|
+
- `PolicyCheck`'s `risk_signals:` and `Verdict#decision`. The `Verdict`
|
|
23
|
+
is now `allowed`, `reason`.
|
|
24
|
+
- **A backend failure could escape as a raw exception.** Jev let
|
|
25
|
+
`Faraday::Error`, `JSON::ParserError` (a non-JSON 2xx) and `KeyError` (no
|
|
26
|
+
`answers`) through, Laya let `Errno::ENOENT` (a missing
|
|
27
|
+
`LAYA_INFER_COMMAND`) and `KeyError` through, and `ConfidenceGate` raised
|
|
28
|
+
`KeyError` for an unanswered question. All are now `BackendError`, with
|
|
29
|
+
one shared `ModelBackends.parse_answers` for both backends' replies.
|
|
30
|
+
- **Neither backend had a timeout.** Jev could stall a checkout for about two
|
|
31
|
+
minutes on a hung connection, and Laya forever. Jev now gives up after 5s
|
|
32
|
+
to connect and 15s to answer. Laya kills its bridge after `timeout:`
|
|
33
|
+
(default 60s).
|
|
34
|
+
- `LAYA_PYTHON=/opt/venv/bin/python` (a path, not a bare name) was always
|
|
35
|
+
reported as "isn't on PATH", so the backend never counted as configured.
|
|
36
|
+
- `examples/laya_bridge.py` returned `type`/`confidence`/`value`, which the
|
|
37
|
+
Ruby side doesn't read, so every noul through it held the purchase. It
|
|
38
|
+
now emits the per-type keys (`noul`, `choice`, `score`).
|
|
39
|
+
- `Jev#configuration_problem` joins `Laya#configuration_problem` (now
|
|
40
|
+
public): the reason a backend can't answer yet, for a setup check.
|
|
41
|
+
|
|
42
|
+
## [0.1.0] - 2026-09-23
|
|
43
|
+
|
|
44
|
+
- First skeleton: `OfferRanking`, `EscalationPolicy`, `ConfidenceGate`, and
|
|
45
|
+
`PolicyCheck` as typed decisions, per
|
|
46
|
+
`docs/plans/system-one-decision-layer.md`.
|
|
47
|
+
- `ConfidenceGate.via_backend` gates on what the model answered, not only on
|
|
48
|
+
how sure it was. Checked live against Jev:
|
|
49
|
+
- A noul (the default type) crashed with `NoMethodError` on every call,
|
|
50
|
+
because Jev sends no `confidence` for a noul. A noul now gates on its
|
|
51
|
+
yes-probability.
|
|
52
|
+
- A choice or score proceeded on any confident answer, a confident
|
|
53
|
+
"escalate" included (Jev answered `escalate` at 0.99 for a live
|
|
54
|
+
`requires_escalation` checkout). These now need `proceed_on:` (an
|
|
55
|
+
option, or a Range of score levels), and proceed only on a match.
|
|
56
|
+
- `ModelBackends::Jev` falls back to `TYPESAFE_API_KEY`, the name TypeSafe's
|
|
57
|
+
own docs use, when `JEV_API_KEY` isn't set.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Whitbread
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# portage-ucp-decision
|
|
2
|
+
|
|
3
|
+
The System One decision layer for `portage-ucp` — see
|
|
4
|
+
`docs/plans/system-one-decision-layer.md` for why this exists.
|
|
5
|
+
|
|
6
|
+
Sits between the agent loop and the Adapter/client layer, turning judgment
|
|
7
|
+
calls that were scattered across skill instructions, `portage-cli`, and
|
|
8
|
+
`portage-ucp` into typed, inspectable decisions:
|
|
9
|
+
|
|
10
|
+
- `Portage::Ucp::Decision::OfferRanking` — which offer to pick among several.
|
|
11
|
+
A typed wrapper around `Portage::Ucp::Support::OfferRanking`.
|
|
12
|
+
- `Portage::Ucp::Decision::EscalationPolicy` — hand off vs. keep going. A
|
|
13
|
+
typed wrapper around `Portage::Ucp::Support::Escalation`.
|
|
14
|
+
- `Portage::Ucp::Decision::ConfidenceGate` — proceed unattended above a
|
|
15
|
+
threshold.
|
|
16
|
+
- `Portage::Ucp::Decision::PolicyCheck` — a typed wrapper around
|
|
17
|
+
`Portage::Ucp::PolicyGuard.check!`.
|
|
18
|
+
|
|
19
|
+
The ranking, escalation and policy rules live in `portage-ucp` core, and
|
|
20
|
+
these three wrap them as `Verdict`s. `portage-cli` calls the core modules
|
|
21
|
+
directly, so it answers the same way with or without this gem. Only
|
|
22
|
+
`ConfidenceGate` and its model backends are unique to this gem.
|
|
23
|
+
|
|
24
|
+
`ModelBackends::Laya` needs a Python bridge script (it's HuggingFace weights,
|
|
25
|
+
not a hosted API) — see `examples/laya_bridge.py` and set
|
|
26
|
+
`LAYA_BRIDGE_SCRIPT`; a missing or broken bridge raises
|
|
27
|
+
`BackendNotConfiguredError`/`BackendError` with a message naming the exact
|
|
28
|
+
problem (unset, path doesn't exist, `LAYA_PYTHON` not executable, non-zero
|
|
29
|
+
exit, timeout, unreadable reply). The bridge must answer with the same
|
|
30
|
+
`{"answers": {name => answer}}` body Jev returns, each answer keyed by its
|
|
31
|
+
type: `{"type": "noul", "noul": <P(yes)>}`, `{"type": "choice", "choice":
|
|
32
|
+
<option>, "confidence": <c>}`, `{"type": "score", "score": <level>,
|
|
33
|
+
"confidence": <c>}`. `ModelBackends::Jev` reads `JEV_API_KEY` (falling back
|
|
34
|
+
to `TYPESAFE_API_KEY`, TypeSafe's own name for it).
|
|
35
|
+
|
|
36
|
+
Both backends time out (Jev after 5s to connect and 15s to answer, Laya
|
|
37
|
+
after 60s by default, `timeout:`), and every failure — a timeout, a
|
|
38
|
+
connection error, a reply that isn't that shape — raises a
|
|
39
|
+
`Decision::Error` subclass, so one `rescue Portage::Ucp::Decision::Error`
|
|
40
|
+
covers a backend call. `#configuration_problem` returns the reason a backend
|
|
41
|
+
can't answer yet (nil when it can); `portage doctor` reports it for the
|
|
42
|
+
backend `PORTAGE_DECISION_BACKEND` selects, and says nothing when none is
|
|
43
|
+
selected.
|
|
44
|
+
|
|
45
|
+
`EscalationPolicy` branches on the literal `requires_escalation` status plus
|
|
46
|
+
an ambiguous-signal case (`signals: {warnings:}`, any warning escalates).
|
|
47
|
+
`ConfidenceGate` compares a score it's handed (`.call`) or gets one from a
|
|
48
|
+
model backend (`.via_backend`, `ModelBackends::Jev`/`ModelBackends::Laya`).
|
|
49
|
+
See the plan doc's "Open questions" section for what's still unresolved.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require "portage/ucp/decision"
|
|
55
|
+
|
|
56
|
+
Portage::Ucp::Decision::EscalationPolicy.call(checkout_status: checkout.status)
|
|
57
|
+
# => #<data Verdict escalate=true, reason=:requires_escalation>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`ConfidenceGate.via_backend` asks one yes/no ("noul") question and gates on
|
|
61
|
+
the probability the backend answered yes, so phrase the question so "yes"
|
|
62
|
+
means "safe to proceed":
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
jev = Portage::Ucp::Decision::ModelBackends::Jev.new
|
|
66
|
+
|
|
67
|
+
Portage::Ucp::Decision::ConfidenceGate.via_backend(
|
|
68
|
+
backend: jev, state: checkout.to_json, question: "safe_to_complete", threshold: 0.8,
|
|
69
|
+
instructions: "Answer yes only if this checkout is safe to complete without a person reviewing it."
|
|
70
|
+
)
|
|
71
|
+
# => #<data Verdict proceed=false, confidence=0.37, threshold=0.8>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## What this gem leaves out, and why
|
|
75
|
+
|
|
76
|
+
These were in the first skeleton and were removed before 0.1.0 was
|
|
77
|
+
published, because nothing called them:
|
|
78
|
+
|
|
79
|
+
- **Choice and score questions in `ConfidenceGate`** (`type:`, `criteria:`,
|
|
80
|
+
`proceed_on:`). A choice's or score's `confidence` says how sure the model
|
|
81
|
+
is of whichever answer it gave, so a confident "escalate" clears the
|
|
82
|
+
threshold; `proceed_on:` was needed just to stop that. A noul's
|
|
83
|
+
yes-probability is the one number that means "safe to proceed", and it's
|
|
84
|
+
the only question `portage buy` asks. To use a choice or score, call the
|
|
85
|
+
backend's `#ask` yourself and read the `Answer`.
|
|
86
|
+
- **`ModelBackends::Answer#probabilities`.** Parsed from the reply but read
|
|
87
|
+
by nothing.
|
|
88
|
+
- **`EscalationPolicy`'s `mismatch:` signal.** `warnings:` covers it: pass
|
|
89
|
+
a one-line description of the mismatch.
|
|
90
|
+
- **`EscalationPolicy::ESCALATING_STATUSES`.** It only ever held
|
|
91
|
+
`requires_escalation`, which is `Portage::Ucp::Support::Escalation::STATUS`.
|
|
92
|
+
- **`PolicyCheck`'s `risk_signals:`, and the `decision:` field on its
|
|
93
|
+
`Verdict`.** Nothing computes a risk signal yet (merchant age,
|
|
94
|
+
TLS/manifest signing, escalation rate), and `decision:` repeated
|
|
95
|
+
`allowed`/`reason`. A caller with its own signal can deny before calling
|
|
96
|
+
`PolicyCheck`.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
bundle install
|
|
102
|
+
bundle exec rspec
|
|
103
|
+
bundle exec rubocop
|
|
104
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Decision
|
|
4
|
+
# docs/plans/system-one-decision-layer.md § Responsibilities 3.
|
|
5
|
+
#
|
|
6
|
+
# Wholly new — no precedent in this repo. `Confirmer` is binary
|
|
7
|
+
# (confirm or don't); this is the graded threshold a caller sits in
|
|
8
|
+
# front of it. Where the `confidence:` score itself comes from was the
|
|
9
|
+
# doc's open question — `.via_backend` below answers it for a model
|
|
10
|
+
# backend (ModelBackends::Jev/Laya); `.call` stays the pure comparator
|
|
11
|
+
# for a caller with its own heuristic score.
|
|
12
|
+
module ConfidenceGate
|
|
13
|
+
Verdict = Data.define(:proceed, :confidence, :threshold)
|
|
14
|
+
|
|
15
|
+
# @param confidence [Float] 0.0..1.0.
|
|
16
|
+
# @param threshold [Float] 0.0..1.0 — the caller's own risk posture,
|
|
17
|
+
# not something this gate defaults or infers.
|
|
18
|
+
def self.call(confidence:, threshold:)
|
|
19
|
+
Verdict.new(proceed: confidence >= threshold, confidence: confidence, threshold: threshold)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Asks a ModelBackends backend one yes/no ("noul") question and gates
|
|
23
|
+
# on the probability it answered yes. Phrase the question so "yes"
|
|
24
|
+
# means "safe to proceed". Jev sends no separate `confidence` for a
|
|
25
|
+
# noul (docs.typesafe.ai/api — confirmed live:
|
|
26
|
+
# `{"type":"noul","noul":0.37}`), and none is needed: the
|
|
27
|
+
# yes-probability is the thing to threshold.
|
|
28
|
+
#
|
|
29
|
+
# Only a noul, on purpose. A choice's or score's `confidence` is how
|
|
30
|
+
# sure the model is of whichever answer it gave, so a confident
|
|
31
|
+
# "escalate" would clear the threshold (Jev did exactly that, at
|
|
32
|
+
# 0.99, for a live `requires_escalation` checkout). A caller that
|
|
33
|
+
# wants a choice or score can ask the backend directly with
|
|
34
|
+
# `#ask` and read the answer itself.
|
|
35
|
+
#
|
|
36
|
+
# @param backend [#ask] a ModelBackends::Jev/Laya instance (or
|
|
37
|
+
# anything answering the same `#ask(state:, questions:)` shape).
|
|
38
|
+
# @param state [String]
|
|
39
|
+
# @param question [String] the key the answer comes back under.
|
|
40
|
+
# @param instructions [String] what the backend should evaluate.
|
|
41
|
+
# @param threshold [Float]
|
|
42
|
+
# @return [Verdict]
|
|
43
|
+
def self.via_backend(backend:, state:, question:, instructions:, threshold:)
|
|
44
|
+
asked = { question => ModelBackends::Question.new(type: "noul", instructions: instructions) }
|
|
45
|
+
answer = backend.ask(state: state, questions: asked).fetch(question) do
|
|
46
|
+
raise Portage::Ucp::Decision::BackendError, "backend returned no answer for #{question.inspect}"
|
|
47
|
+
end
|
|
48
|
+
unless answer.value.is_a?(Numeric)
|
|
49
|
+
raise Portage::Ucp::Decision::BackendError, "noul answer carried no probability: #{answer.to_h}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
call(confidence: answer.value, threshold: threshold)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Decision
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised by a ModelBackend when it's asked to run without whatever it
|
|
7
|
+
# needs to actually reach a model — an API key (Jev) or a local
|
|
8
|
+
# inference command (Laya) — rather than failing further in with a
|
|
9
|
+
# confusing HTTP or ENOENT error.
|
|
10
|
+
class BackendNotConfiguredError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised by a ModelBackend when the call itself fails — a non-2xx
|
|
13
|
+
# response (Jev) or a non-zero exit status (Laya).
|
|
14
|
+
class BackendError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised by ModelBackends.resolve for a name not in ModelBackends::REGISTRY.
|
|
17
|
+
class UnknownBackendError < Error; end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Decision
|
|
6
|
+
# docs/plans/system-one-decision-layer.md § Responsibilities 2.
|
|
7
|
+
#
|
|
8
|
+
# A typed wrapper around `Portage::Ucp::Support::Escalation`, the way
|
|
9
|
+
# PolicyCheck wraps `PolicyGuard`. The rule lives in core: the literal
|
|
10
|
+
# `requires_escalation` status wins, then a mismatch the caller found.
|
|
11
|
+
# That covers the doc's "ambiguous signal" case, a merchant surfacing
|
|
12
|
+
# a mismatch that isn't literally `requires_escalation` but should
|
|
13
|
+
# still stop here. `signals: {warnings:}` takes the same
|
|
14
|
+
# `warnings: [String]` array `portage buy` puts on its report.
|
|
15
|
+
module EscalationPolicy
|
|
16
|
+
Verdict = Data.define(:escalate, :reason)
|
|
17
|
+
|
|
18
|
+
# @param checkout_status [String] a Checkout#status value.
|
|
19
|
+
# @param signals [Hash] `warnings:` an Array of human-readable
|
|
20
|
+
# mismatch strings. Any element escalates; none is required.
|
|
21
|
+
def self.call(checkout_status:, signals: {})
|
|
22
|
+
reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status,
|
|
23
|
+
warnings: signals[:warnings])
|
|
24
|
+
Verdict.new(escalate: !reason.nil?, reason: reason)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Decision
|
|
6
|
+
module ModelBackends
|
|
7
|
+
# A single typed question sent to a backend — the wire shape both
|
|
8
|
+
# Jev (api.typesafe.ai/v1/systemone) and the Laya subprocess
|
|
9
|
+
# contract share. `criteria` is a Hash for `type: "choice"`, an
|
|
10
|
+
# Array for `type: "score"`, absent for `type: "noul"` (yes/no).
|
|
11
|
+
Question = Data.define(:type, :instructions, :criteria) do
|
|
12
|
+
def initialize(type:, instructions:, criteria: nil) = super
|
|
13
|
+
|
|
14
|
+
def to_wire_h = { "type" => type, "instructions" => instructions, "criteria" => criteria }.compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A single typed answer back from a backend. `value` is the answer
|
|
18
|
+
# itself (a choice's option, a score's level, a noul's probability of
|
|
19
|
+
# yes). `confidence` is how sure the model is of a choice or score —
|
|
20
|
+
# Jev sends none for a noul, so ConfidenceGate gates a noul on
|
|
21
|
+
# `value` instead.
|
|
22
|
+
Answer = Data.define(:type, :confidence, :value) do
|
|
23
|
+
def initialize(type:, confidence:, value: nil) = super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The `{"answers": {name => {...}}}` body both backends return, as
|
|
27
|
+
# Answers. Anything that isn't that shape — invalid JSON, no
|
|
28
|
+
# `answers`, an answer that isn't an object — is a BackendError
|
|
29
|
+
# naming `source`, so a caller rescuing Decision::Error sees every
|
|
30
|
+
# bad reply rather than a stray JSON::ParserError or KeyError.
|
|
31
|
+
#
|
|
32
|
+
# @param raw [String] the backend's response body / stdout.
|
|
33
|
+
# @param source [String] who answered, for the error message.
|
|
34
|
+
# @return [Hash{String => Answer}]
|
|
35
|
+
def self.parse_answers(raw, source:)
|
|
36
|
+
JSON.parse(raw).fetch("answers").transform_values do |answer|
|
|
37
|
+
Answer.new(type: answer["type"], confidence: answer["confidence"],
|
|
38
|
+
value: answer["choice"] || answer["score"] || answer["noul"])
|
|
39
|
+
end
|
|
40
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError => e
|
|
41
|
+
raise Portage::Ucp::Decision::BackendError,
|
|
42
|
+
"#{source} returned an unreadable answer (#{e.class}: #{e.message}): #{raw.to_s[0, 300].inspect}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "faraday"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Decision
|
|
7
|
+
module ModelBackends
|
|
8
|
+
# Client for TypeSafe AI's Jev — the "System One Model" the plan doc's
|
|
9
|
+
# title borrowed its name from (docs.typesafe.ai/introduction/quickstart).
|
|
10
|
+
# A hosted decision API: POST state + typed questions, get back typed
|
|
11
|
+
# answers with a calibrated `confidence` per answer.
|
|
12
|
+
class Jev
|
|
13
|
+
BASE_URL = "https://api.typesafe.ai/v1/systemone".freeze
|
|
14
|
+
DEFAULT_MODEL = "jev-latest".freeze
|
|
15
|
+
# This gem's own name for the credential, matching this backend's
|
|
16
|
+
# name in the registry. TYPESAFE_API_KEY — the name TypeSafe's own
|
|
17
|
+
# docs and SDKs use — is read as a fallback, so a key already set up
|
|
18
|
+
# for TypeSafe works here without being copied to a second name.
|
|
19
|
+
# Seconds. A confidence check sits in front of a checkout the
|
|
20
|
+
# shopper is waiting on; Net::HTTP's defaults would stall it for up
|
|
21
|
+
# to two minutes on a hung connection.
|
|
22
|
+
OPEN_TIMEOUT = 5
|
|
23
|
+
TIMEOUT = 15
|
|
24
|
+
ENV_KEY = "JEV_API_KEY".freeze
|
|
25
|
+
FALLBACK_ENV_KEY = "TYPESAFE_API_KEY".freeze
|
|
26
|
+
|
|
27
|
+
def self.env_api_key
|
|
28
|
+
[ENV_KEY, FALLBACK_ENV_KEY].map { |key| ENV.fetch(key, nil).to_s.strip }.find { |key| !key.empty? }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(api_key: self.class.env_api_key, model: DEFAULT_MODEL, connection: nil)
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@model = model
|
|
34
|
+
@connection = connection ||
|
|
35
|
+
Faraday.new(url: BASE_URL, request: { open_timeout: OPEN_TIMEOUT, timeout: TIMEOUT })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def configured? = configuration_problem.nil?
|
|
39
|
+
|
|
40
|
+
# @return [String, nil] why this backend can't answer yet, or nil
|
|
41
|
+
# when it can — same contract as Laya#configuration_problem.
|
|
42
|
+
def configuration_problem
|
|
43
|
+
return nil unless @api_key.to_s.strip.empty?
|
|
44
|
+
|
|
45
|
+
"#{ENV_KEY} is not set (nor #{FALLBACK_ENV_KEY}) — get a key at https://console.typesafe.ai " \
|
|
46
|
+
"and set #{ENV_KEY}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param state [String] the text (or JSON-serialized context) to
|
|
50
|
+
# evaluate — a checkout, an offer list, a merchant signal.
|
|
51
|
+
# @param questions [Hash{String => Question}]
|
|
52
|
+
# @return [Hash{String => Answer}]
|
|
53
|
+
def ask(state:, questions:)
|
|
54
|
+
problem = configuration_problem
|
|
55
|
+
raise Portage::Ucp::Decision::BackendNotConfiguredError, problem if problem
|
|
56
|
+
|
|
57
|
+
response = post(state, questions)
|
|
58
|
+
raise_for_status!(response)
|
|
59
|
+
ModelBackends.parse_answers(response.body, source: "Jev")
|
|
60
|
+
rescue Faraday::Error => e
|
|
61
|
+
raise Portage::Ucp::Decision::BackendError, "Jev request failed: #{e.class}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def post(state, questions)
|
|
67
|
+
@connection.post("") do |req|
|
|
68
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
69
|
+
req.headers["Content-Type"] = "application/json"
|
|
70
|
+
req.body = JSON.generate(state: state, model: @model,
|
|
71
|
+
questions: questions.transform_values(&:to_wire_h))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def raise_for_status!(response)
|
|
76
|
+
return if response.status.between?(200, 299)
|
|
77
|
+
|
|
78
|
+
raise Portage::Ucp::Decision::BackendError,
|
|
79
|
+
"Jev request failed: #{response.status} #{response.body.to_s[0, 300]}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Decision
|
|
7
|
+
module ModelBackends
|
|
8
|
+
# Client for Laya (huggingface.co/convaiinnovations/laya) — a local,
|
|
9
|
+
# open-weights (Apache 2.0) decision-classification model, not a
|
|
10
|
+
# hosted API. It ships as ModernBERT/mmBERT weights invoked through
|
|
11
|
+
# Python (HuggingFace `transformers`, or the `laya` SDK's
|
|
12
|
+
# `laya.load()`), and this is a Ruby gem, so this backend shells out
|
|
13
|
+
# to a Python "bridge script" instead of embedding the model. The
|
|
14
|
+
# script must speak this gem's own stdin/stdout JSON contract —
|
|
15
|
+
# `{"state", "questions"}` in, `{"answers"}` out, the same shape
|
|
16
|
+
# ModelBackends::Jev uses — not any interface Laya's Python side
|
|
17
|
+
# defines itself. Writing that script is the caller's job (a
|
|
18
|
+
# starting point is at `examples/laya_bridge.py` in this gem); this
|
|
19
|
+
# class only owns the Ruby-side contract and the two failure modes
|
|
20
|
+
# around it not being there yet: no bridge configured at all, and a
|
|
21
|
+
# configured one that's missing or fails at call time.
|
|
22
|
+
class Laya
|
|
23
|
+
DEFAULT_PYTHON = "python3".freeze
|
|
24
|
+
ENV_BRIDGE_SCRIPT_KEY = "LAYA_BRIDGE_SCRIPT".freeze
|
|
25
|
+
ENV_PYTHON_KEY = "LAYA_PYTHON".freeze
|
|
26
|
+
# Escape hatch for a caller whose bridge isn't `python3 <script>` —
|
|
27
|
+
# a container entrypoint, a wrapper shell script, anything already
|
|
28
|
+
# executable on its own. Takes precedence over bridge_script/python
|
|
29
|
+
# since it's a deliberate override, not a default.
|
|
30
|
+
ENV_COMMAND_KEY = "LAYA_INFER_COMMAND".freeze
|
|
31
|
+
# Seconds. Generous, since the bridge loads model weights on every
|
|
32
|
+
# call, but bounded: a stuck bridge must hold the purchase, not
|
|
33
|
+
# hang it.
|
|
34
|
+
DEFAULT_TIMEOUT = 60
|
|
35
|
+
|
|
36
|
+
def initialize(bridge_script: ENV.fetch(ENV_BRIDGE_SCRIPT_KEY, nil),
|
|
37
|
+
python: ENV.fetch(ENV_PYTHON_KEY, DEFAULT_PYTHON),
|
|
38
|
+
command: ENV.fetch(ENV_COMMAND_KEY, nil), timeout: DEFAULT_TIMEOUT)
|
|
39
|
+
@bridge_script = bridge_script
|
|
40
|
+
@python = python
|
|
41
|
+
@custom_command = command
|
|
42
|
+
@timeout = timeout
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def configured? = configuration_problem.nil?
|
|
46
|
+
|
|
47
|
+
# @param state [String]
|
|
48
|
+
# @param questions [Hash{String => Question}]
|
|
49
|
+
# @return [Hash{String => Answer}]
|
|
50
|
+
def ask(state:, questions:)
|
|
51
|
+
problem = configuration_problem
|
|
52
|
+
raise Portage::Ucp::Decision::BackendNotConfiguredError, problem if problem
|
|
53
|
+
|
|
54
|
+
stdout, stderr, status = run(request_json(state, questions))
|
|
55
|
+
raise Portage::Ucp::Decision::BackendError, "Laya bridge failed: #{stderr}" unless status.success?
|
|
56
|
+
|
|
57
|
+
ModelBackends.parse_answers(stdout, source: "Laya bridge")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# nil means "configured" — anything else is the reason it isn't,
|
|
61
|
+
# returned rather than raised so #configured?, #ask and a setup
|
|
62
|
+
# check (portage doctor) share one check without swallowing an
|
|
63
|
+
# exception.
|
|
64
|
+
def configuration_problem
|
|
65
|
+
return nil if @custom_command
|
|
66
|
+
|
|
67
|
+
unless @bridge_script
|
|
68
|
+
return "no Laya bridge configured — set #{ENV_BRIDGE_SCRIPT_KEY} to a Python script implementing " \
|
|
69
|
+
"the state/questions -> answers JSON contract (see examples/laya_bridge.py), or " \
|
|
70
|
+
"#{ENV_COMMAND_KEY} for a fully custom command"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return "#{ENV_BRIDGE_SCRIPT_KEY}=#{@bridge_script} does not exist" unless File.file?(@bridge_script)
|
|
74
|
+
|
|
75
|
+
return "#{ENV_PYTHON_KEY}=#{@python} isn't an executable on PATH" unless which(@python)
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def command = @custom_command ? Array(@custom_command) : [@python, @bridge_script]
|
|
83
|
+
|
|
84
|
+
def request_json(state, questions)
|
|
85
|
+
JSON.generate(state: state, questions: questions.transform_values(&:to_wire_h))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Open3.capture3 with a deadline: the child is killed, not left
|
|
89
|
+
# running, when it overruns. A command that can't start at all
|
|
90
|
+
# (ENOENT, EACCES) is a BackendError like any other bridge failure.
|
|
91
|
+
def run(input)
|
|
92
|
+
Open3.popen3(*command) do |stdin, stdout, stderr, wait|
|
|
93
|
+
readers = [Thread.new { stdout.read }, Thread.new { stderr.read }]
|
|
94
|
+
write_request(stdin, input)
|
|
95
|
+
timed_out = wait.join(@timeout).nil?
|
|
96
|
+
Process.kill("KILL", wait.pid) if timed_out
|
|
97
|
+
# After a kill, a grandchild (a wrapper's python) can still hold
|
|
98
|
+
# the pipes open, so don't wait on the readers indefinitely.
|
|
99
|
+
out, err = readers.map { |reader| reader.join(timed_out ? 1 : nil)&.value.to_s }
|
|
100
|
+
raise Portage::Ucp::Decision::BackendError, "Laya bridge timed out after #{@timeout}s" if timed_out
|
|
101
|
+
|
|
102
|
+
[out, err, wait.value]
|
|
103
|
+
end
|
|
104
|
+
rescue SystemCallError, IOError => e
|
|
105
|
+
raise Portage::Ucp::Decision::BackendError, "Laya bridge couldn't run #{command.first}: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A bridge that exits without reading its stdin closes the pipe
|
|
109
|
+
# early. Its exit status and stderr say why, so that's reported
|
|
110
|
+
# rather than the EPIPE.
|
|
111
|
+
def write_request(stdin, input)
|
|
112
|
+
stdin.write(input)
|
|
113
|
+
rescue Errno::EPIPE
|
|
114
|
+
nil
|
|
115
|
+
ensure
|
|
116
|
+
stdin.close
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A path (LAYA_PYTHON=/opt/venv/bin/python) is checked as-is; a
|
|
120
|
+
# bare name is looked up on PATH.
|
|
121
|
+
def which(command)
|
|
122
|
+
return (executable_file?(command) ? command : nil) if command.include?("/")
|
|
123
|
+
|
|
124
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, command) }
|
|
125
|
+
.find { |path| executable_file?(path) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def executable_file?(path) = File.executable?(path) && !File.directory?(path)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative "errors"
|
|
2
|
+
require_relative "model_backends/answer"
|
|
3
|
+
require_relative "model_backends/jev"
|
|
4
|
+
require_relative "model_backends/laya"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Ucp
|
|
8
|
+
module Decision
|
|
9
|
+
# docs/plans/system-one-decision-layer.md's open question — "Confidence
|
|
10
|
+
# gating needs a defined scale and source" — resolved here for two
|
|
11
|
+
# concrete, swappable model backends: Jev (TypeSafe AI's hosted
|
|
12
|
+
# "System One Model", the plan doc's own namesake) and Laya (an
|
|
13
|
+
# open-weights local model). Both speak the same `#ask(state:,
|
|
14
|
+
# questions:) -> Hash{String => Answer}` shape, so ConfidenceGate can
|
|
15
|
+
# threshold either one's `confidence` without knowing which is behind
|
|
16
|
+
# it.
|
|
17
|
+
module ModelBackends
|
|
18
|
+
REGISTRY = { "jev" => Jev, "laya" => Laya }.freeze
|
|
19
|
+
|
|
20
|
+
# @param name [String, Symbol] a REGISTRY key.
|
|
21
|
+
# @return a new backend instance.
|
|
22
|
+
def self.resolve(name, **)
|
|
23
|
+
REGISTRY.fetch(name.to_s) do
|
|
24
|
+
raise Portage::Ucp::Decision::UnknownBackendError,
|
|
25
|
+
"unknown decision model backend #{name.inspect} — known: #{REGISTRY.keys.join(', ')}"
|
|
26
|
+
end.new(**)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Decision
|
|
6
|
+
# docs/plans/system-one-decision-layer.md § Responsibilities 1.
|
|
7
|
+
#
|
|
8
|
+
# A typed wrapper around `Portage::Ucp::Support::OfferRanking`, the
|
|
9
|
+
# way PolicyCheck wraps `PolicyGuard`. The rule itself lives in core:
|
|
10
|
+
# buyable candidates first, then cheapest, then unpriced. portage-cli
|
|
11
|
+
# calls core directly, so its ranking can't drift from this one.
|
|
12
|
+
module OfferRanking
|
|
13
|
+
# A caller's candidate, wrapped just enough for ranking to compare
|
|
14
|
+
# it — the caller keeps whatever richer type (Product, a CLI hash,
|
|
15
|
+
# an agent-loop's own struct) it already has on `#offer`.
|
|
16
|
+
Candidate = Data.define(:offer, :buyable, :amount)
|
|
17
|
+
|
|
18
|
+
# @param candidates [Array<Candidate>]
|
|
19
|
+
# @return [Array<Candidate>] the same candidates, stable-sorted
|
|
20
|
+
# buyable-first, cheapest-first, unpriced-last.
|
|
21
|
+
def self.call(candidates)
|
|
22
|
+
Portage::Ucp::Support::OfferRanking.rank(candidates) { |candidate| [candidate.buyable, candidate.amount] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Decision
|
|
6
|
+
# docs/plans/system-one-decision-layer.md § Responsibilities 4.
|
|
7
|
+
#
|
|
8
|
+
# Closest responsibility to done: `Portage::Ucp::PolicyGuard` already
|
|
9
|
+
# covers per-transaction cap, rolling cap, velocity, and merchant
|
|
10
|
+
# allowlist. This wraps it as a typed `Verdict` instead of a raised
|
|
11
|
+
# exception, so a caller gets the same inspectable shape as the other
|
|
12
|
+
# three decisions in this gem.
|
|
13
|
+
module PolicyCheck
|
|
14
|
+
Verdict = Data.define(:allowed, :reason)
|
|
15
|
+
|
|
16
|
+
# @return [Verdict]
|
|
17
|
+
def self.call(amount:, currency:, merchant:, token_ref:, policy: Portage::Ucp::Policy.load,
|
|
18
|
+
transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
19
|
+
Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
|
|
20
|
+
token_ref: token_ref, policy: policy, transaction_log: transaction_log)
|
|
21
|
+
Verdict.new(allowed: true, reason: nil)
|
|
22
|
+
rescue Portage::Ucp::PolicyViolationError => e
|
|
23
|
+
Verdict.new(allowed: false, reason: e.reason)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# docs/plans/system-one-decision-layer.md — the typed decision layer that
|
|
2
|
+
# sits between the agent loop and the Adapter/client layer: offer ranking,
|
|
3
|
+
# escalation policy, confidence gating, and policy checks as inspectable
|
|
4
|
+
# data, not control flow scattered across skill instructions and the CLI.
|
|
5
|
+
require_relative "decision/version"
|
|
6
|
+
require_relative "decision/errors"
|
|
7
|
+
require_relative "decision/model_backends"
|
|
8
|
+
require_relative "decision/offer_ranking"
|
|
9
|
+
require_relative "decision/escalation_policy"
|
|
10
|
+
require_relative "decision/confidence_gate"
|
|
11
|
+
require_relative "decision/policy_check"
|
metadata
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: portage-ucp-decision
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tom Whitbread
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: portage-ucp
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.9'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.9'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.88'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.88'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: webmock
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.24'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.24'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: yard
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.9'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.9'
|
|
97
|
+
description: 'The judgment calls between the agent loop and the Adapter/client layer,
|
|
98
|
+
as typed, inspectable decisions instead of control flow scattered across skill instructions
|
|
99
|
+
and the CLI: which offer to pick (OfferRanking), hand off vs. keep going (EscalationPolicy),
|
|
100
|
+
whether a result is confident enough to act on unattended (ConfidenceGate), and
|
|
101
|
+
a typed wrapper around Portage::Ucp::PolicyGuard (PolicyCheck). See docs/plans/system-one-decision-layer.md.'
|
|
102
|
+
email:
|
|
103
|
+
executables: []
|
|
104
|
+
extensions: []
|
|
105
|
+
extra_rdoc_files: []
|
|
106
|
+
files:
|
|
107
|
+
- CHANGELOG.md
|
|
108
|
+
- LICENSE
|
|
109
|
+
- README.md
|
|
110
|
+
- lib/portage/ucp/decision.rb
|
|
111
|
+
- lib/portage/ucp/decision/confidence_gate.rb
|
|
112
|
+
- lib/portage/ucp/decision/errors.rb
|
|
113
|
+
- lib/portage/ucp/decision/escalation_policy.rb
|
|
114
|
+
- lib/portage/ucp/decision/model_backends.rb
|
|
115
|
+
- lib/portage/ucp/decision/model_backends/answer.rb
|
|
116
|
+
- lib/portage/ucp/decision/model_backends/jev.rb
|
|
117
|
+
- lib/portage/ucp/decision/model_backends/laya.rb
|
|
118
|
+
- lib/portage/ucp/decision/offer_ranking.rb
|
|
119
|
+
- lib/portage/ucp/decision/policy_check.rb
|
|
120
|
+
- lib/portage/ucp/decision/version.rb
|
|
121
|
+
homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-decision
|
|
122
|
+
licenses:
|
|
123
|
+
- MIT
|
|
124
|
+
metadata:
|
|
125
|
+
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-decision
|
|
126
|
+
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-decision/CHANGELOG.md
|
|
127
|
+
rubygems_mfa_required: 'true'
|
|
128
|
+
post_install_message:
|
|
129
|
+
rdoc_options: []
|
|
130
|
+
require_paths:
|
|
131
|
+
- lib
|
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '3.2'
|
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '0'
|
|
142
|
+
requirements: []
|
|
143
|
+
rubygems_version: 3.5.22
|
|
144
|
+
signing_key:
|
|
145
|
+
specification_version: 4
|
|
146
|
+
summary: System One decision layer for portage-ucp — offer ranking, escalation, confidence,
|
|
147
|
+
policy
|
|
148
|
+
test_files: []
|