portage-cli 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +87 -17
- data/lib/portage/cli/buy.rb +217 -39
- data/lib/portage/cli/confidence_check.rb +27 -7
- data/lib/portage/cli/confidence_state.rb +109 -0
- data/lib/portage/cli/find.rb +64 -1
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +21 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e0a17085649f21644b4e373afc590ab69b92cfdbf23c4b83fbf7ed33b1cc1c8
|
|
4
|
+
data.tar.gz: 75e4435e369e70f529af29ce9891208615d2f0d458df616dbf11a032f8276ed3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d79a48ad6cf39bad956cafbb2e9f3317485af02a14fa3305306a4185839ce3add20326025e9eb4cfa0e0e1709ecbade149b5b2e9109627fb9b3b6112f11e91ed
|
|
7
|
+
data.tar.gz: 5bbf4e713f496fef30925cb4f59de1ad929e1a51b960c804f6bb60bbce39c8dd0856d3efcd5641f175a25bebfd7868358d63a827d0e6123a3d5b9763a7d27ead
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,83 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.12.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
- **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
|
|
12
|
+
|
|
13
|
+
- **`find --store URL --query Q [--max-price N]` searches one store, live.** The index-search hint, the `buy` skill and the docs already told agents to re-check an index hit this way, but `find` had no `--store`. It now skips the search backends and retailer offer sources and probes and searches only that store's catalogue (read-only: never a cart or checkout), with offers, `offer_ref`, `search_id` and the history entry shaped like any find, so `pick` and `buy --offer` work on them. A hand-off-only host is never fetched and is reported as such; a store without UCP is reported as having no catalogue; a non-http(s) URL is a usage error. The index-search hint now reads `find --store URL --query ...`.
|
|
14
|
+
|
|
15
|
+
- **Security: a priced line nobody asked for is a checkout mismatch.** `buy` requests exactly one
|
|
16
|
+
line, but only checked that line, so a store that added an upsell, a "shipping protection"
|
|
17
|
+
add-on or a second copy of the item (or, on a WebMCP cart, whatever was already in the store's
|
|
18
|
+
cart) still went through. Any other line now stops a real run with `checkout_mismatch` ("Store
|
|
19
|
+
added ... to checkout, which wasn't requested."), and a dry run flags it with
|
|
20
|
+
`checkout_mismatch: true`. An extra line that costs nothing (its own total, or its unit price
|
|
21
|
+
times quantity, is 0) is allowed, so a free gift or $0 sample doesn't block a purchase; one
|
|
22
|
+
whose cost can't be read counts as priced.
|
|
23
|
+
|
|
24
|
+
- **Security: the confidence check sends an allowlisted summary, and compares against the
|
|
25
|
+
approved quote.** The state sent to the decision backend (`PORTAGE_DECISION_BACKEND`, e.g.
|
|
26
|
+
`jev`, TypeSafe's hosted API) used to be a slice of the raw checkout hash, so whatever a store
|
|
27
|
+
nested under `line_items` or `totals` went along with it. It is now built field by field by the
|
|
28
|
+
new `Portage::Cli::ConfidenceState`: the request (query, store host, quantity, picked item id
|
|
29
|
+
and title), on a `buy --quote` run the approved quote (store, product id, title, quantity,
|
|
30
|
+
total, currency), and the checkout's status, currency, per-line item id, title, unit price,
|
|
31
|
+
quantity, totals and whether it's the requested line, the totals (shipping, tax, fees included),
|
|
32
|
+
applied discounts' titles and amounts and the selected shipping option's title and price, plus
|
|
33
|
+
`warnings`. Never the payment token, the address, the buyer's name, phone or email, ids, links,
|
|
34
|
+
discount codes or environment values; store strings are cut to 200 characters. The question
|
|
35
|
+
now asks the model to say yes only when the checkout matches the request and the approved
|
|
36
|
+
quote. `Buy.new` takes `quote_store:` and `quote_title:`, which `buy --quote` passes from the
|
|
37
|
+
saved quote. The check stays additive: it only sees a checkout the mismatch check, the quote
|
|
38
|
+
cap and the spend policy let through, and can hold it but never let through one they stop.
|
|
39
|
+
|
|
40
|
+
- **Security: the non-preset WebMCP hand-off runs the confidence check too.** A page whose WebMCP
|
|
41
|
+
tools build a checkout (no preset, so `buy` ends in `express_stop` through `finish_checkout`)
|
|
42
|
+
applied the quote cap and the mismatch stop but not the opt-in confidence check. With a decision
|
|
43
|
+
backend enabled it now runs before the hand-off, after those two. A hold reports `low_confidence`
|
|
44
|
+
with the store's `/cart` page as `checkout_url`, as the preset flow's does, and nothing is handed
|
|
45
|
+
to the checkout (a `profile` target is not navigated there). A backend error holds the same way.
|
|
46
|
+
No backend enabled: unchanged.
|
|
47
|
+
|
|
48
|
+
- **Security: the WebMCP hand-off flow runs the quote cap and the confidence check.** Against a
|
|
49
|
+
page whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy`
|
|
50
|
+
now checks, before that tool runs and before autofill: a `--quote` run's cap (`quote_changed`,
|
|
51
|
+
never handed off; this flow never checked the quote before), the mismatch check, then, when a
|
|
52
|
+
decision backend is enabled, the confidence check. A hold reports `low_confidence` with the
|
|
53
|
+
store's `/cart` page as `checkout_url`; checkout isn't opened and nothing is autofilled. A
|
|
54
|
+
backend error holds the same way.
|
|
55
|
+
|
|
56
|
+
- **Security: a quote with no total refuses instead of buying uncapped.** `buy --quote` capped the
|
|
57
|
+
checkout at the quote's total only when the quote had one. A quote saved from a dry run with no
|
|
58
|
+
priced total (a WebMCP preset dry run never has one) set no cap at all, so its `--yes` run
|
|
59
|
+
bought at whatever the store asked. It now ends in `quote_changed`, saying the quote has no
|
|
60
|
+
total, and nothing is bought or handed off.
|
|
61
|
+
|
|
62
|
+
- **Security: a checkout mismatch always stops the purchase.** `buy` used to stop on a checkout
|
|
63
|
+
that didn't match the request (the item dropped, another quantity, another unit price) only under
|
|
64
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`. Without it the mismatch was a `warnings` entry and the
|
|
65
|
+
purchase went ahead, so a real `--yes` run could pay for the wrong checkout and report
|
|
66
|
+
`purchased` (reported by ClawHub's security audit of the `portage-buy` skill). Now every
|
|
67
|
+
mismatch on a run that isn't `--dry-run` escalates (`decisions.escalation.reason: "mismatch"`)
|
|
68
|
+
and ends in `checkout_mismatch` before the payment token, policy and completion are reached. The
|
|
69
|
+
quote it ran under is spent, so the person dry-runs again for a new one. There is no opt-out:
|
|
70
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored, whatever its value. A
|
|
71
|
+
`--dry-run` keeps its `dry_run` outcome and `warnings`, and now adds `checkout_mismatch: true`
|
|
72
|
+
and says in `message` that a real run would stop. The check also compares currency: a checkout
|
|
73
|
+
in another currency than the catalog's price is a mismatch. The quoted total and currency are
|
|
74
|
+
still `--quote`'s `quote_changed` check, unchanged.
|
|
75
|
+
|
|
76
|
+
- **Security: the WebMCP hand-off flow stops on a mismatched cart too.** Against a WebMCP page
|
|
77
|
+
whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy` read
|
|
78
|
+
the cart back and checked it, but only put a mismatch in `warnings`: it still called the hand-off
|
|
79
|
+
tool, sending the browser tab to the store's checkout, and ran autofill there when it was
|
|
80
|
+
approved. Now any mismatch stops the run before that tool, with outcome `checkout_mismatch` and
|
|
81
|
+
`decisions.escalation.reason: "mismatch"`. Nothing is autofilled, and `checkout_url` is the
|
|
82
|
+
store's `/cart` page, so the shopper can look at the cart that was built. A matching cart hands
|
|
83
|
+
off as `express_stop`, as before. The dry run of this flow builds no cart, so it can't check one
|
|
84
|
+
and never carries `checkout_mismatch: true`.
|
|
85
|
+
|
|
9
86
|
## [0.11.0] - 2026-10-01
|
|
10
87
|
|
|
11
88
|
- **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
|
data/README.md
CHANGED
|
@@ -29,6 +29,20 @@ portage find --query "burton snowboard" --max-price 400
|
|
|
29
29
|
|
|
30
30
|
`portage buy` with no URL runs that search and then buys the offer you pick.
|
|
31
31
|
|
|
32
|
+
Already know the store? `portage find --store URL --query "..."` skips the search
|
|
33
|
+
backends and offer sources and searches only that store's catalogue, live and
|
|
34
|
+
read-only (catalogue search only, never a cart or checkout). Use it to re-check
|
|
35
|
+
an index hit or an earlier offer before quoting its price or stock. The report
|
|
36
|
+
has the same shape as a normal find (`offer_ref`, `search_id`, a history entry),
|
|
37
|
+
so `pick` and `buy --offer` work on its offers. A hand-off-only host is never
|
|
38
|
+
fetched and is reported as such; a store that doesn't speak UCP is reported as
|
|
39
|
+
having no catalogue, with exit code 1 (as for any find with no offers). `--store`
|
|
40
|
+
must be an http(s) URL (a bare host is read as https).
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
portage find --store https://shop.example --query "cold brew" --json
|
|
44
|
+
```
|
|
45
|
+
|
|
32
46
|
Already have the item and want to know where else it's sold? `portage compare`
|
|
33
47
|
resolves a product you name by URL + product id, then runs the same
|
|
34
48
|
find pipeline against its title and ranks the results by how confident the
|
|
@@ -131,6 +145,7 @@ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
|
|
|
131
145
|
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
132
146
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
133
147
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
148
|
+
portage find --store URL --query "..." [--max-price N] [--json]
|
|
134
149
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
135
150
|
[--max-price N] [--json]
|
|
136
151
|
portage check <url> [--json]
|
|
@@ -811,6 +826,30 @@ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
|
|
|
811
826
|
product search: nothing is added to the store's cart, the tab isn't sent to
|
|
812
827
|
checkout and nothing is autofilled. The `dry_run` report carries a `would:`
|
|
813
828
|
key with the line item, the hand-off tool and whether autofill would run.
|
|
829
|
+
Because it builds no cart, it can't check one, so it never carries
|
|
830
|
+
`checkout_mismatch: true`.
|
|
831
|
+
|
|
832
|
+
A real run against that kind of page reads the cart back after adding to it
|
|
833
|
+
and checks it the same way `buy` checks any checkout (item, quantity, unit
|
|
834
|
+
price, currency, extra lines). Any mismatch stops the run there, with outcome
|
|
835
|
+
`checkout_mismatch` and `decisions.escalation.reason: "mismatch"`: the
|
|
836
|
+
hand-off tool isn't called, so the tab never goes to checkout, and nothing is
|
|
837
|
+
autofilled. The cart is already on the store, so `checkout_url` is the
|
|
838
|
+
store's `/cart` page, for the shopper to look at. Items already sitting in
|
|
839
|
+
the store's cart count as extra lines, so they stop the run too.
|
|
840
|
+
|
|
841
|
+
The same run then applies the other gates a checkout gets before anything
|
|
842
|
+
leaves the cart: a `--quote` run's cap (`quote_changed`), and, when a
|
|
843
|
+
decision backend is enabled, the confidence check (see "Decisions"). A hold
|
|
844
|
+
from the confidence check reports `low_confidence` with the `/cart` page as
|
|
845
|
+
`checkout_url`; again the tab isn't sent to checkout and nothing is
|
|
846
|
+
autofilled.
|
|
847
|
+
|
|
848
|
+
A page whose WebMCP tools build a checkout (no preset) gets the same confidence
|
|
849
|
+
check before its `express_stop` hand-off, after the quote cap and the mismatch
|
|
850
|
+
stop. A hold reports `low_confidence` with the `/cart` page as `checkout_url`
|
|
851
|
+
and no navigation to the checkout, so a `profile` hand-off target is never sent
|
|
852
|
+
there. With no decision backend enabled, nothing changes.
|
|
814
853
|
|
|
815
854
|
Once the flow hands off to the store's own checkout page, the shopper can
|
|
816
855
|
opt into having it pre-filled: `--autofill`, or
|
|
@@ -842,7 +881,9 @@ gem install portage-ucp-decision # only needed for the confidence gate
|
|
|
842
881
|
```
|
|
843
882
|
|
|
844
883
|
The confidence gate is the one feature that needs the gem, because the
|
|
845
|
-
model backends live there.
|
|
884
|
+
model backends live there. Use `portage-ucp-decision` 0.1.2 or newer: it
|
|
885
|
+
rejects a backend answer that isn't a probability between 0 and 1, where
|
|
886
|
+
0.1.1 let an answer above 1 clear any threshold.
|
|
846
887
|
|
|
847
888
|
Every `portage buy` report carries an `outcome`, so a script or agent loop
|
|
848
889
|
can branch on data rather than on the message text. The text output leads
|
|
@@ -852,12 +893,12 @@ with the same value, as `[outcome]`.
|
|
|
852
893
|
| --- | --- | --- |
|
|
853
894
|
| `purchased` | Completed. | no |
|
|
854
895
|
| `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
|
|
855
|
-
| `dry_run` | Checkout created, `--dry-run` stopped it. | no |
|
|
896
|
+
| `dry_run` | Checkout created, `--dry-run` stopped it. `checkout_mismatch: true` when a real run would stop on a mismatch. | no |
|
|
856
897
|
| `requires_escalation` | The store wants the shopper to finish. | yes |
|
|
857
|
-
| `checkout_mismatch` | Checkout differs from the request (
|
|
898
|
+
| `checkout_mismatch` | Checkout differs from the request (item, quantity, unit price, currency, or a priced line nobody asked for); stopped before payment. | yes |
|
|
858
899
|
| `no_payment_token` | No `--payment-token` and no default payment method. | yes |
|
|
859
900
|
| `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
|
|
860
|
-
| `low_confidence` | The confidence gate held it. | yes |
|
|
901
|
+
| `low_confidence` | The confidence gate held it (before a `--yes` completion, or before a WebMCP hand-off to checkout, preset or not). | yes |
|
|
861
902
|
| `permission_denied` | The store doesn't let this agent complete checkout. | yes |
|
|
862
903
|
| `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
|
|
863
904
|
| `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
|
|
@@ -884,9 +925,15 @@ Every verdict has a `reason`: `null` when the gate let the purchase through,
|
|
|
884
925
|
otherwise a string naming why it stopped it.
|
|
885
926
|
|
|
886
927
|
- **escalation** — `Support::Escalation`. A `requires_escalation` checkout
|
|
887
|
-
always escalates (`reason: "requires_escalation"`).
|
|
888
|
-
match the request
|
|
889
|
-
|
|
928
|
+
always escalates (`reason: "requires_escalation"`). So does a checkout that
|
|
929
|
+
doesn't match the request (`reason: "mismatch"`), on every run but
|
|
930
|
+
`--dry-run`, where the mismatch is reported in `warnings` and flagged with
|
|
931
|
+
`checkout_mismatch: true` instead. Nothing turns this off:
|
|
932
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored. A mismatch
|
|
933
|
+
is the requested line missing, a different quantity, a different unit
|
|
934
|
+
price or currency than the store's own catalog, or any extra line the
|
|
935
|
+
request didn't include. An extra line that costs nothing (a free gift, a
|
|
936
|
+
$0 sample) is allowed; one whose cost can't be read counts as priced.
|
|
890
937
|
- **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
|
|
891
938
|
before any `--yes` completion. `reason` is the guard's own
|
|
892
939
|
(`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
|
|
@@ -901,16 +948,39 @@ otherwise a string naming why it stopped it.
|
|
|
901
948
|
when the store answers that it's purchased.
|
|
902
949
|
- **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
|
|
903
950
|
`PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
|
|
904
|
-
completion
|
|
905
|
-
and
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
951
|
+
completion, and before a WebMCP preset flow sends the browser to the
|
|
952
|
+
store's checkout (and autofills it), it asks the backend whether the
|
|
953
|
+
checkout matches the request, and the approved quote on a `--quote` run,
|
|
954
|
+
and is safe to complete unattended. It is additive only: it runs after
|
|
955
|
+
the mismatch check, the quote cap and the spend policy, on a checkout all
|
|
956
|
+
of them let through, so it can hold a purchase but never let through one
|
|
957
|
+
they would stop. The gate fails closed, so three things hold the
|
|
958
|
+
purchase: a score below the threshold (`reason: "below_threshold"`), a
|
|
959
|
+
backend that can't answer or answers with something that isn't a
|
|
960
|
+
probability (`"backend_error"`), and naming a backend without
|
|
961
|
+
`portage-ucp-decision` installed (`"not_installed"`). For the last two,
|
|
962
|
+
`error` says what went wrong. `jev` needs `JEV_API_KEY`; `laya` needs
|
|
963
|
+
`LAYA_BRIDGE_SCRIPT` (see `portage-ucp-decision`'s README). `portage
|
|
964
|
+
doctor` flags whichever of these is missing for the selected backend.
|
|
965
|
+
|
|
966
|
+
`jev` is TypeSafe's hosted API, so what it's sent is kept to a minimal,
|
|
967
|
+
allowlisted summary (`Portage::Cli::ConfidenceState`); nothing else on
|
|
968
|
+
the checkout is read:
|
|
969
|
+
|
|
970
|
+
- `request`: the search query, the store's host, the quantity, and the
|
|
971
|
+
picked item's id and title.
|
|
972
|
+
- `approved_quote` (`--quote` runs only): the quote's store, product id,
|
|
973
|
+
title, quantity, total and currency.
|
|
974
|
+
- `checkout`: status, currency; per line, item id, title, unit price,
|
|
975
|
+
quantity, line totals and whether it's the requested line; the totals
|
|
976
|
+
(subtotal, shipping, tax, fees, total — whatever the store lists);
|
|
977
|
+
applied discounts' titles and amounts; the selected shipping option's
|
|
978
|
+
title and price.
|
|
979
|
+
- `warnings`.
|
|
980
|
+
|
|
981
|
+
Never sent: the payment token, the shipping address, the buyer's name,
|
|
982
|
+
phone or email, checkout ids, links and URLs, discount codes, or anything
|
|
983
|
+
from your environment. Store-supplied strings are cut to 200 characters.
|
|
914
984
|
- **policy** also denies a checkout with no `total` line as
|
|
915
985
|
`total_unknown` whenever a spend cap is set, rather than skipping the cap.
|
|
916
986
|
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "payment_methods"
|
|
|
9
9
|
require_relative "setting"
|
|
10
10
|
require_relative "decisions"
|
|
11
11
|
require_relative "confidence_check"
|
|
12
|
+
require_relative "confidence_state"
|
|
12
13
|
require_relative "checkout_handoff"
|
|
13
14
|
require_relative "money"
|
|
14
15
|
require_relative "notifier"
|
|
@@ -122,14 +123,18 @@ module Portage
|
|
|
122
123
|
# hand off the real checkout first checks its total against this
|
|
123
124
|
# (with `quote_currency:`) and reports `quote_changed` instead if it
|
|
124
125
|
# is higher, in another currency, or missing. See #finish_checkout.
|
|
125
|
-
#
|
|
126
|
+
# @param quote_store [String, nil] the approved quote's store, and
|
|
127
|
+
# @param quote_title [String, nil] its title — set by `buy --quote`
|
|
128
|
+
# alongside quote_total:. Only the confidence check reads them: they
|
|
129
|
+
# go into its `approved_quote` (see #confidence_quote).
|
|
130
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize -- all keywords; one per flag, plus
|
|
126
131
|
# injectable collaborators, each assigned to its own ivar
|
|
127
132
|
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
|
|
128
133
|
auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
|
|
129
134
|
transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
|
|
130
135
|
webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
|
|
131
|
-
quote_total: nil, quote_currency: nil)
|
|
132
|
-
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
136
|
+
quote_total: nil, quote_currency: nil, quote_store: nil, quote_title: nil)
|
|
137
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
|
|
133
138
|
raw = url.to_s.strip
|
|
134
139
|
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
135
140
|
@query = query
|
|
@@ -151,6 +156,8 @@ module Portage
|
|
|
151
156
|
@json = json
|
|
152
157
|
@quote_total = quote_total
|
|
153
158
|
@quote_currency = quote_currency
|
|
159
|
+
@quote_store = quote_store
|
|
160
|
+
@quote_title = quote_title
|
|
154
161
|
@webmcp_bridge = webmcp_bridge
|
|
155
162
|
@decisions = {}
|
|
156
163
|
end
|
|
@@ -510,7 +517,9 @@ module Portage
|
|
|
510
517
|
# call it against. Builds a cart, reads it back through the (already
|
|
511
518
|
# cart/checkout-capable, per the gate in #webmcp_flow) session so
|
|
512
519
|
# #reconcile_checkout has a `get_cart`-shaped document to check against
|
|
513
|
-
# (there's no checkout document either),
|
|
520
|
+
# (there's no checkout document either), stops there if the quote cap,
|
|
521
|
+
# the mismatch check or the opt-in confidence check holds it
|
|
522
|
+
# (#webmcp_cart_held_report), and only then calls the preset's
|
|
514
523
|
# `handoff_checkout` tool directly on the bridge — after the cart
|
|
515
524
|
# read-back, not before, so any post-mutation "page not ready" gap
|
|
516
525
|
# (see Transport's own retry) has already been waited out by then.
|
|
@@ -536,24 +545,84 @@ module Portage
|
|
|
536
545
|
end
|
|
537
546
|
return webmcp_handoff_dry_run_report(products, product, preset) if @dry_run
|
|
538
547
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
548
|
+
@product = product
|
|
549
|
+
cart = webmcp_build_cart(session, product)
|
|
550
|
+
held = webmcp_cart_held_report(products, product, cart)
|
|
551
|
+
return held if held
|
|
543
552
|
|
|
544
553
|
result = @webmcp_bridge.execute_tool(preset.handoff_checkout, {})
|
|
545
554
|
autofill = attempt_webmcp_autofill(preset)
|
|
546
|
-
webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)),
|
|
555
|
+
webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), [],
|
|
547
556
|
autofill: autofill)
|
|
548
557
|
end
|
|
549
558
|
|
|
559
|
+
# The same gates #finish_checkout runs before anything leaves this
|
|
560
|
+
# process, in the same order, all before the hand-off tool sends the
|
|
561
|
+
# tab to checkout and before anything is autofilled: the quote cap,
|
|
562
|
+
# then the deterministic mismatch stop, then (only when a decision
|
|
563
|
+
# backend is enabled) the confidence check. The confidence check only
|
|
564
|
+
# sees a cart the first two let through, so it can hold this hand-off
|
|
565
|
+
# but never let through one they stop.
|
|
566
|
+
# @return [Hash, nil] the report for the first gate that held, or nil.
|
|
567
|
+
def webmcp_cart_held_report(products, product, cart)
|
|
568
|
+
warnings = reconcile_checkout(product, cart)
|
|
569
|
+
return quote_changed_report("webmcp", products, cart, warnings) if quote_exceeded?(cart)
|
|
570
|
+
return webmcp_cart_mismatch_report(products, cart, warnings) if warnings.any?
|
|
571
|
+
|
|
572
|
+
webmcp_low_confidence_report(products, cart)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# A hold here points at the store's cart page, like the mismatch stop:
|
|
576
|
+
# checkout was never opened, so there's nothing at a checkout URL yet.
|
|
577
|
+
def webmcp_low_confidence_report(products, cart)
|
|
578
|
+
verdict = decide_confidence(cart, [])
|
|
579
|
+
return nil if verdict.nil? || verdict[:proceed]
|
|
580
|
+
|
|
581
|
+
handoff_report("webmcp", products, cart.merge("continue_url" => webmcp_cart_page_url), [],
|
|
582
|
+
outcome: "low_confidence",
|
|
583
|
+
message: "#{low_confidence_message(verdict)} Checkout wasn't opened, and nothing was " \
|
|
584
|
+
"autofilled.")
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Adds the line to the store's cart, then reads the cart back.
|
|
588
|
+
def webmcp_build_cart(session, product)
|
|
589
|
+
created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
|
|
590
|
+
context: buyer_context, meta: agent_meta)
|
|
591
|
+
session.get_cart(cart_id: created["id"], meta: agent_meta)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Same fail-closed rule as #full_buy (see #decide_escalation): a cart
|
|
595
|
+
# that doesn't match the request stops here, before the hand-off tool
|
|
596
|
+
# sends the tab to checkout and before anything is autofilled. This
|
|
597
|
+
# flow used to only add the mismatch to `warnings` and carry on to
|
|
598
|
+
# checkout. The cart already exists on the store, so the report points
|
|
599
|
+
# at the store's cart page for the person to inspect, not at checkout.
|
|
600
|
+
# Every preset with a `handoff_checkout` tool today is Shopify's, where
|
|
601
|
+
# that page is /cart. A cart has no checkout status, so the verdict is
|
|
602
|
+
# the mismatch alone: `decisions.escalation.reason` is "mismatch", as
|
|
603
|
+
# on #full_buy's own stop.
|
|
604
|
+
def webmcp_cart_mismatch_report(products, cart, warnings)
|
|
605
|
+
@decisions[:escalation] = Decisions.escalation(checkout_status: nil, warnings: warnings)
|
|
606
|
+
handoff_report("webmcp", products, cart.merge("continue_url" => webmcp_cart_page_url), warnings,
|
|
607
|
+
outcome: "checkout_mismatch",
|
|
608
|
+
message: "Stopped before checkout — the store's cart didn't match the request: " \
|
|
609
|
+
"#{warnings.join(' ')} Nothing was bought, and checkout wasn't opened.")
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def webmcp_cart_page_url
|
|
613
|
+
URI.join(@uri, "/cart").to_s
|
|
614
|
+
end
|
|
615
|
+
|
|
550
616
|
# Unlike #full_buy's dry run (which still creates a UCP checkout, since
|
|
551
617
|
# that's a document the store drops on its own), a dry run here stops
|
|
552
618
|
# before `create_cart`: every step after it changes something outside
|
|
553
619
|
# this process — a real cart on the store, the bridge's own tab
|
|
554
620
|
# navigated to checkout, and (with autofill on) typing into that page.
|
|
555
621
|
# None of that is safe to do on a preview run, so this only reports
|
|
556
|
-
# what the run would have done.
|
|
622
|
+
# what the run would have done. That also means there's no cart to
|
|
623
|
+
# reconcile, so unlike #dry_run_report this can never carry
|
|
624
|
+
# `checkout_mismatch: true`; the real run checks the cart and stops
|
|
625
|
+
# there instead. No checkout_id either, so History
|
|
557
626
|
# records it as a search, not a purchase.
|
|
558
627
|
def webmcp_handoff_dry_run_report(products, product, preset)
|
|
559
628
|
build_report(
|
|
@@ -764,6 +833,7 @@ module Portage
|
|
|
764
833
|
message: no_match_message)
|
|
765
834
|
end
|
|
766
835
|
|
|
836
|
+
@product = product
|
|
767
837
|
checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
|
|
768
838
|
fulfillment: requested_fulfillment(fulfillment_adapter),
|
|
769
839
|
context: buyer_context, meta: agent_meta)
|
|
@@ -779,34 +849,78 @@ module Portage
|
|
|
779
849
|
# (if surprising) checkout response, not a transport error. Buying
|
|
780
850
|
# blind against a mismatch this method could have caught defeats the
|
|
781
851
|
# point of an agent shopping on the buyer's behalf, so this always
|
|
782
|
-
# checks and
|
|
783
|
-
#
|
|
852
|
+
# checks, and any warning it returns stops a real purchase before
|
|
853
|
+
# payment (see #decide_escalation). The quoted total and currency are
|
|
854
|
+
# #quote_exceeded?'s job, not this method's.
|
|
855
|
+
#
|
|
856
|
+
# This is the authoritative check, and it always runs first: the
|
|
857
|
+
# opt-in model check (#decide_confidence) only ever sees a checkout
|
|
858
|
+
# that already passed it, and can hold that checkout but never
|
|
859
|
+
# overrule a warning from here.
|
|
784
860
|
def reconcile_checkout(product, checkout)
|
|
785
861
|
item_id = line_item_id_of(product)
|
|
786
|
-
|
|
862
|
+
lines = Array(checkout["line_items"])
|
|
863
|
+
line = lines.find { |li| li.dig("item", "id") == item_id }
|
|
787
864
|
return ["Store dropped the requested item (#{item_id}) from checkout."] unless line
|
|
788
865
|
|
|
789
866
|
warnings = []
|
|
790
867
|
if line["quantity"] != @qty
|
|
791
868
|
warnings << "Store checked out quantity #{line['quantity']}, not the requested #{@qty}."
|
|
792
869
|
end
|
|
870
|
+
warnings + price_mismatches(product, item_id, line, checkout["currency"]) +
|
|
871
|
+
unrequested_lines(lines.reject { |li| li.equal?(line) })
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# Only one line is ever requested, so any other line is something the
|
|
875
|
+
# person never asked for: an upsell, a "shipping protection" add-on,
|
|
876
|
+
# or (on a WebMCP cart) whatever was already sitting in the store's
|
|
877
|
+
# cart. One that costs nothing is let through, so a store's free gift
|
|
878
|
+
# or $0 sample doesn't stop the purchase; one whose cost can't be read
|
|
879
|
+
# is treated as costing something, so it stops.
|
|
880
|
+
def unrequested_lines(extras)
|
|
881
|
+
extras.reject { |li| line_cost(li)&.zero? }.map do |li|
|
|
882
|
+
label = [li.dig("item", "id"), li.dig("item", "title")].compact.join(" ")
|
|
883
|
+
"Store added #{label.empty? ? 'a line' : label} to checkout, which wasn't requested."
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# The line's own total, else its unit price times its quantity; nil
|
|
888
|
+
# when the line carries neither.
|
|
889
|
+
def line_cost(line)
|
|
890
|
+
total = Portage::Ucp::Support::Totals.amount(line["totals"])
|
|
891
|
+
return total if total.is_a?(Numeric)
|
|
892
|
+
|
|
893
|
+
price = line.dig("item", "price")
|
|
894
|
+
quantity = line["quantity"] || 1
|
|
895
|
+
price * quantity if price.is_a?(Numeric) && quantity.is_a?(Numeric)
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# A unit price is only comparable in the catalog's own currency, so a
|
|
899
|
+
# checkout in another one is reported as that, not as a price change.
|
|
900
|
+
def price_mismatches(product, item_id, line, currency)
|
|
901
|
+
catalog_currency = expected_unit_currency(product, item_id)
|
|
902
|
+
if catalog_currency && currency && catalog_currency != currency
|
|
903
|
+
return ["Store checked out in #{currency}, not the catalog's #{catalog_currency}."]
|
|
904
|
+
end
|
|
793
905
|
|
|
794
906
|
expected = expected_unit_price(product, item_id)
|
|
795
907
|
actual = line.dig("item", "price")
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
end
|
|
800
|
-
warnings
|
|
908
|
+
return [] unless expected && actual && expected != actual
|
|
909
|
+
|
|
910
|
+
["Store priced the item at #{actual} #{currency} minor units per unit, not the catalog's #{expected}."]
|
|
801
911
|
end
|
|
802
912
|
|
|
803
913
|
def expected_unit_price(product, item_id)
|
|
804
|
-
|
|
805
|
-
variant&.dig("price", "amount") || product.dig("price_range", "min", "amount")
|
|
914
|
+
expected_price_of(product, item_id)&.dig("amount")
|
|
806
915
|
end
|
|
807
916
|
|
|
808
|
-
def
|
|
809
|
-
|
|
917
|
+
def expected_unit_currency(product, item_id)
|
|
918
|
+
expected_price_of(product, item_id)&.dig("currency")
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def expected_price_of(product, item_id)
|
|
922
|
+
variant = Array(product["variants"]).find { |v| v["id"] == item_id }
|
|
923
|
+
variant&.dig("price") || product.dig("price_range", "min")
|
|
810
924
|
end
|
|
811
925
|
|
|
812
926
|
# Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
|
|
@@ -963,22 +1077,38 @@ module Portage
|
|
|
963
1077
|
escalation = decide_escalation(checkout, warnings)
|
|
964
1078
|
return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
|
|
965
1079
|
return dry_run_report(source, products, checkout, warnings) if @dry_run
|
|
966
|
-
return
|
|
1080
|
+
return webmcp_handoff_checkout_report(source, products, checkout, warnings) if force_handoff
|
|
967
1081
|
return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
|
|
968
1082
|
|
|
969
1083
|
complete(session, source, products, checkout, warnings)
|
|
970
1084
|
end
|
|
971
1085
|
|
|
1086
|
+
# The non-preset WebMCP hand-off (`express_stop` through #full_buy). Same
|
|
1087
|
+
# last gate as #webmcp_handoff_checkout_flow's: with a decision backend
|
|
1088
|
+
# enabled, the confidence check runs before anything is handed off, on a
|
|
1089
|
+
# checkout the quote cap and the mismatch stop already let through. A
|
|
1090
|
+
# hold reports `low_confidence` pointing at the store's cart page, as the
|
|
1091
|
+
# preset flow's does, so nothing is sent to a `profile` target's checkout.
|
|
1092
|
+
# No backend named: straight to the hand-off, as before.
|
|
1093
|
+
def webmcp_handoff_checkout_report(source, products, checkout, warnings)
|
|
1094
|
+
webmcp_low_confidence_report(products, checkout) ||
|
|
1095
|
+
webmcp_handoff_report(source, products, checkout, warnings)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
972
1098
|
# First in #finish_checkout, ahead of the escalation gates: a
|
|
973
1099
|
# `quote_changed` refusal never hands off, since that would spend the
|
|
974
1100
|
# quote and open a checkout the person never approved. #complete is
|
|
975
1101
|
# the only place this file charges, and it is reached only through
|
|
976
1102
|
# #finish_checkout.
|
|
1103
|
+
#
|
|
1104
|
+
# A quote with no total (its dry run had none to show, as a WebMCP
|
|
1105
|
+
# preset dry run never does) caps nothing, so it refuses too, rather
|
|
1106
|
+
# than buying at whatever the store now asks.
|
|
977
1107
|
def quote_exceeded?(checkout)
|
|
978
|
-
return false unless
|
|
1108
|
+
return false unless quote_run?
|
|
979
1109
|
|
|
980
1110
|
total = checkout_total(checkout)
|
|
981
|
-
total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
|
|
1111
|
+
@quote_total.nil? || total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
|
|
982
1112
|
end
|
|
983
1113
|
|
|
984
1114
|
def quote_changed_report(source, products, checkout, warnings)
|
|
@@ -990,6 +1120,11 @@ module Portage
|
|
|
990
1120
|
end
|
|
991
1121
|
|
|
992
1122
|
def quote_changed_message(total, currency)
|
|
1123
|
+
if @quote_total.nil?
|
|
1124
|
+
return "The quote has no total to hold this checkout (now #{quoted_amount(total, currency)}) to — " \
|
|
1125
|
+
"nothing was bought. Dry-run again for a priced quote."
|
|
1126
|
+
end
|
|
1127
|
+
|
|
993
1128
|
"The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
|
|
994
1129
|
"now #{quoted_amount(total, currency)}) — nothing was bought."
|
|
995
1130
|
end
|
|
@@ -998,15 +1133,18 @@ module Portage
|
|
|
998
1133
|
|
|
999
1134
|
# Hand off vs. keep going is Decisions.escalation's call
|
|
1000
1135
|
# (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
|
|
1001
|
-
# literal `requires_escalation` status always escalates
|
|
1002
|
-
# from #reconcile_checkout
|
|
1003
|
-
#
|
|
1004
|
-
#
|
|
1136
|
+
# literal `requires_escalation` status always escalates, and so does
|
|
1137
|
+
# any mismatch from #reconcile_checkout, with no setting to turn that
|
|
1138
|
+
# off. This used to only warn unless PORTAGE_ABORT_ON_CHECKOUT_MISMATCH
|
|
1139
|
+
# was set, so by default a checkout that didn't match what the person
|
|
1140
|
+
# approved was still paid for and reported `purchased`. That variable
|
|
1141
|
+
# is now ignored. A --dry-run never pays, so there the mismatch stays
|
|
1142
|
+
# in `warnings` and #dry_run_report flags it instead, since an agent
|
|
1143
|
+
# needs the priced checkout to tell the person what went wrong.
|
|
1005
1144
|
# The verdict lands on the report's `decisions:`, and the report's
|
|
1006
1145
|
# `outcome:` names which gate (if any) stopped the purchase.
|
|
1007
1146
|
def decide_escalation(checkout, warnings)
|
|
1008
|
-
verdict = Decisions.escalation(checkout_status: checkout["status"],
|
|
1009
|
-
warnings: abort_on_mismatch? ? warnings : [])
|
|
1147
|
+
verdict = Decisions.escalation(checkout_status: checkout["status"], warnings: @dry_run ? [] : warnings)
|
|
1010
1148
|
@decisions[:escalation] = verdict
|
|
1011
1149
|
verdict
|
|
1012
1150
|
end
|
|
@@ -1129,17 +1267,48 @@ module Portage
|
|
|
1129
1267
|
Portage::Ucp::Support::Totals.amount(checkout["totals"])
|
|
1130
1268
|
end
|
|
1131
1269
|
|
|
1132
|
-
#
|
|
1133
|
-
#
|
|
1270
|
+
# Runs only on a checkout that already passed #reconcile_checkout, the
|
|
1271
|
+
# quote cap and the spend policy, so it can hold a purchase but never
|
|
1272
|
+
# let through one those would stop. The state is ConfidenceState's
|
|
1273
|
+
# allowlisted summary — never the payment token, the address or the
|
|
1274
|
+
# buyer's contact details — because it goes to a model backend that
|
|
1275
|
+
# may be a hosted third-party API (Jev).
|
|
1134
1276
|
def decide_confidence(checkout, warnings)
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
)
|
|
1277
|
+
return nil unless confidence_check.enabled?
|
|
1278
|
+
|
|
1279
|
+
verdict = confidence_check.call(confidence_state(checkout, warnings))
|
|
1139
1280
|
@decisions[:confidence] = verdict if verdict
|
|
1140
1281
|
verdict
|
|
1141
1282
|
end
|
|
1142
1283
|
|
|
1284
|
+
def confidence_state(checkout, warnings)
|
|
1285
|
+
item_id = @product && line_item_id_of(@product)
|
|
1286
|
+
ConfidenceState.build(
|
|
1287
|
+
request: { query: @query, merchant: @uri.host, quantity: @qty, item_id: item_id,
|
|
1288
|
+
item_title: picked_title(item_id) },
|
|
1289
|
+
checkout: checkout, warnings: warnings, quote: confidence_quote
|
|
1290
|
+
)
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# The picked product's title from the store's own search, plus its
|
|
1294
|
+
# variant's when the checked-out variant has one ("Tee — Large").
|
|
1295
|
+
def picked_title(item_id)
|
|
1296
|
+
return nil unless @product
|
|
1297
|
+
|
|
1298
|
+
[@product["title"], variant_matching(@product, item_id)&.dig("title")].compact.uniq.join(" — ")
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
# What `buy --quote` pinned, as the person approved it. nil on a run
|
|
1302
|
+
# with no quote.
|
|
1303
|
+
def confidence_quote
|
|
1304
|
+
return nil unless quote_run?
|
|
1305
|
+
|
|
1306
|
+
{ store: @quote_store, product_id: @product_id, title: @quote_title, quantity: @qty,
|
|
1307
|
+
total: @quote_total, currency: @quote_currency }
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
def quote_run? = !(@quote_store || @quote_total || @quote_currency).nil?
|
|
1311
|
+
|
|
1143
1312
|
def confidence_check
|
|
1144
1313
|
@confidence_check ||= ConfidenceCheck.new
|
|
1145
1314
|
end
|
|
@@ -1410,9 +1579,18 @@ module Portage
|
|
|
1410
1579
|
@yes
|
|
1411
1580
|
end
|
|
1412
1581
|
|
|
1582
|
+
# `warnings` here are #reconcile_checkout's mismatches, which a real
|
|
1583
|
+
# run of the same checkout stops on — so the report says so up front,
|
|
1584
|
+
# before anyone approves a quote for it.
|
|
1413
1585
|
def dry_run_report(source, products, checkout, warnings = [])
|
|
1414
|
-
|
|
1415
|
-
|
|
1586
|
+
message = "Dry run — checkout created but not completed."
|
|
1587
|
+
extra = {}
|
|
1588
|
+
if warnings.any?
|
|
1589
|
+
message += " It doesn't match the request (#{warnings.join(' ')}), so a real purchase would stop " \
|
|
1590
|
+
"with checkout_mismatch."
|
|
1591
|
+
extra[:checkout_mismatch] = true
|
|
1592
|
+
end
|
|
1593
|
+
checkout_report(source, products, checkout, outcome: "dry_run", warnings: warnings, message: message, **extra)
|
|
1416
1594
|
end
|
|
1417
1595
|
|
|
1418
1596
|
def confirmation_needed_report(source, products, checkout, warnings = [])
|
|
@@ -8,7 +8,9 @@ module Portage
|
|
|
8
8
|
# § Responsibilities 3) as `portage buy` uses it: one yes/no question put
|
|
9
9
|
# to a Decision::ModelBackends backend right before an unattended
|
|
10
10
|
# (`--yes`) completion — "is this checkout what the shopper asked for,
|
|
11
|
-
# and safe to complete without a person looking at it?"
|
|
11
|
+
# and safe to complete without a person looking at it?" Also asked
|
|
12
|
+
# before the WebMCP preset flow sends the browser to the store's
|
|
13
|
+
# checkout and autofills it (Buy#webmcp_cart_held_report).
|
|
12
14
|
#
|
|
13
15
|
# Default off. Nothing asks a model anything unless a backend is named,
|
|
14
16
|
# via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
|
|
@@ -22,6 +24,16 @@ module Portage
|
|
|
22
24
|
# purchase and hands the checkout to the shopper. It never
|
|
23
25
|
# auto-approves anything `--yes` wouldn't already have allowed.
|
|
24
26
|
#
|
|
27
|
+
# Additive only. Buy runs it after #reconcile_checkout's deterministic
|
|
28
|
+
# mismatch check, the quote cap and the spend policy, and only on a
|
|
29
|
+
# checkout all three let through, so a "yes" here can never let
|
|
30
|
+
# through a purchase one of those would stop. A "no" can only hold it.
|
|
31
|
+
#
|
|
32
|
+
# What it sends is ConfidenceState's allowlisted summary, never the
|
|
33
|
+
# raw checkout: no payment token, no address, no buyer contact
|
|
34
|
+
# details. #call JSON-encodes whatever state it is given, so a caller
|
|
35
|
+
# outside Buy has to build its state the same way.
|
|
36
|
+
#
|
|
25
37
|
# Fails closed. An unknown backend name, a backend that isn't configured
|
|
26
38
|
# (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
|
|
27
39
|
# the purchase, the same as a low score does. So does naming a backend
|
|
@@ -35,11 +47,18 @@ module Portage
|
|
|
35
47
|
QUESTION = "safe_to_complete".freeze
|
|
36
48
|
# Phrased so "yes" means "proceed": a noul answer's value is the
|
|
37
49
|
# probability of yes, and that is what ConfidenceGate thresholds.
|
|
38
|
-
INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
50
|
+
INSTRUCTIONS = "The state is a summary of a checkout an agent built on a shopper's behalf. `request` " \
|
|
51
|
+
"is what the shopper asked for: the search query, the store, the quantity, and the item " \
|
|
52
|
+
"picked from the store's search results. `approved_quote`, when present, is the exact " \
|
|
53
|
+
"purchase the shopper approved beforehand: store, product, title, quantity, total and " \
|
|
54
|
+
"currency. `checkout` is what the store is about to charge for: its line items (`requested` " \
|
|
55
|
+
"marks the line that was asked for), totals including any shipping, tax and fees, " \
|
|
56
|
+
"discounts and the selected shipping option. `warnings` lists differences an automatic " \
|
|
57
|
+
"check already found. Answer yes only if the checkout clearly matches the request and, " \
|
|
58
|
+
"when there is one, the approved quote: the same product (not a different model, size, " \
|
|
59
|
+
"bundle or subscription), the same quantity, no extra items, and a total with no " \
|
|
60
|
+
"surprising shipping, fees or other charges. Answer no if anything is unclear, missing " \
|
|
61
|
+
"or doesn't fit, or if the checkout would need a person to review it before paying.".freeze
|
|
43
62
|
|
|
44
63
|
# @param backend [String, nil] a ModelBackends::REGISTRY key; nil
|
|
45
64
|
# defers to PORTAGE_DECISION_BACKEND.
|
|
@@ -64,7 +83,8 @@ module Portage
|
|
|
64
83
|
|
|
65
84
|
def enabled? = !@backend_name.nil?
|
|
66
85
|
|
|
67
|
-
# @param state [Hash] JSON-serializable
|
|
86
|
+
# @param state [Hash] JSON-serializable — ConfidenceState.build's
|
|
87
|
+
# output. Never a payment token, address or contact detail.
|
|
68
88
|
# @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
|
|
69
89
|
# `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
|
|
70
90
|
# `reason` is nil when it proceeds, else why it held:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# The state ConfidenceCheck sends to its model backend, which may be a
|
|
6
|
+
# hosted third-party API (Jev, run by TypeSafe). Built from an allowlist:
|
|
7
|
+
# every field below is copied by name, and nothing else in the checkout
|
|
8
|
+
# hash is ever read. A store can put a buyer block, a shipping address,
|
|
9
|
+
# payment handlers or anything else on its checkout, and none of it
|
|
10
|
+
# reaches the backend, because this never copies a sub-hash wholesale.
|
|
11
|
+
#
|
|
12
|
+
# Never sent: the payment token (or any token reference), the shipping
|
|
13
|
+
# address and destinations, the buyer's name, phone or email, links and
|
|
14
|
+
# continue URLs, ids of the checkout itself, image URLs, discount codes,
|
|
15
|
+
# and anything read from the environment.
|
|
16
|
+
#
|
|
17
|
+
# Sent:
|
|
18
|
+
# - `request`: the search query, the store's host, the requested
|
|
19
|
+
# quantity, and the item id and title that were picked from the
|
|
20
|
+
# store's own search results.
|
|
21
|
+
# - `approved_quote` (only on `buy --quote`): the store, product id,
|
|
22
|
+
# title, quantity, total and currency the person approved.
|
|
23
|
+
# - `checkout`: its status and currency; each line's item id, title,
|
|
24
|
+
# unit price, quantity, line totals and whether it is the requested
|
|
25
|
+
# line; the checkout's totals (subtotal, shipping, tax, fees, total:
|
|
26
|
+
# whatever types the store sends); applied discounts' titles and
|
|
27
|
+
# amounts; and the title and price of each selected shipping option.
|
|
28
|
+
# - `warnings`: the deterministic check's warnings, if any.
|
|
29
|
+
#
|
|
30
|
+
# Strings are cut to MAX_STRING characters and anything that isn't a
|
|
31
|
+
# string, number, boolean or nil is dropped, so a store can't smuggle a
|
|
32
|
+
# nested object (or a very long prompt) in through a title.
|
|
33
|
+
module ConfidenceState
|
|
34
|
+
MAX_STRING = 200
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param request [Hash] `query:`, `merchant:`, `quantity:`,
|
|
39
|
+
# `item_id:`, `item_title:`.
|
|
40
|
+
# @param checkout [Hash] a checkout or cart wire hash, string-keyed.
|
|
41
|
+
# @param warnings [Array<String>]
|
|
42
|
+
# @param quote [Hash, nil] `store:`, `product_id:`, `title:`,
|
|
43
|
+
# `quantity:`, `total:`, `currency:` — nil when the run has no
|
|
44
|
+
# approved quote.
|
|
45
|
+
# @return [Hash] JSON-serializable, string-keyed.
|
|
46
|
+
def build(request:, checkout:, warnings:, quote: nil)
|
|
47
|
+
state = { "request" => pick(request, %i[query merchant quantity item_id item_title]) }
|
|
48
|
+
state["approved_quote"] = pick(quote, %i[store product_id title quantity total currency]) if quote
|
|
49
|
+
state["checkout"] = checkout_summary(checkout, request[:item_id])
|
|
50
|
+
state["warnings"] = Array(warnings).map { |warning| scalar(warning.to_s) }
|
|
51
|
+
state
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def checkout_summary(checkout, requested_id)
|
|
55
|
+
{ "status" => scalar(checkout["status"]), "currency" => scalar(checkout["currency"]),
|
|
56
|
+
"line_items" => Array(checkout["line_items"]).map { |line| line_summary(line, requested_id) },
|
|
57
|
+
"totals" => totals_summary(checkout["totals"]),
|
|
58
|
+
"discounts" => discounts_summary(checkout["discounts"]),
|
|
59
|
+
"shipping" => shipping_summary(checkout["fulfillment"]) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def line_summary(line, requested_id)
|
|
63
|
+
line = {} unless line.is_a?(Hash)
|
|
64
|
+
item = line["item"].is_a?(Hash) ? line["item"] : {}
|
|
65
|
+
{ "item_id" => scalar(item["id"]), "title" => scalar(item["title"]), "unit_price" => scalar(item["price"]),
|
|
66
|
+
"quantity" => scalar(line["quantity"]), "totals" => totals_summary(line["totals"]),
|
|
67
|
+
"requested" => !requested_id.nil? && item["id"] == requested_id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def totals_summary(totals)
|
|
71
|
+
hashes(totals).map { |total| { "type" => scalar(total["type"]), "amount" => scalar(total["amount"]) } }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def discounts_summary(discounts)
|
|
75
|
+
applied = discounts.is_a?(Hash) ? discounts["applied"] : nil
|
|
76
|
+
hashes(applied).map do |discount|
|
|
77
|
+
{ "title" => scalar(discount["title"]), "amount" => scalar(discount["amount"]) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Only the options the checkout has selected — the price the person
|
|
82
|
+
# pays for shipping — never the destinations they're priced for.
|
|
83
|
+
def shipping_summary(fulfillment)
|
|
84
|
+
methods = fulfillment.is_a?(Hash) ? fulfillment["methods"] : nil
|
|
85
|
+
hashes(methods).flat_map { |method| hashes(method["groups"]) }.filter_map do |group|
|
|
86
|
+
option = hashes(group["options"]).find { |o| o["id"] == group["selected_option_id"] }
|
|
87
|
+
next unless option
|
|
88
|
+
|
|
89
|
+
{ "title" => scalar(option["title"]),
|
|
90
|
+
"amount" => scalar(Portage::Ucp::Support::Totals.amount(hashes(option["totals"]))) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pick(hash, keys)
|
|
95
|
+
keys.to_h { |key| [key.to_s, scalar(hash[key])] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def hashes(value) = Array(value).grep(Hash)
|
|
99
|
+
|
|
100
|
+
def scalar(value)
|
|
101
|
+
case value
|
|
102
|
+
when String then value[0, MAX_STRING]
|
|
103
|
+
when Integer, true, false, nil then value
|
|
104
|
+
when Float then value.finite? ? value : nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -24,6 +24,12 @@ module Portage
|
|
|
24
24
|
# search ranker and the purchase decision to `--yes` in one breath is how
|
|
25
25
|
# you end up owning a counterfeit from a shop you've never heard of, so
|
|
26
26
|
# picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
|
|
27
|
+
#
|
|
28
|
+
# `store:` narrows the same pipeline to one store the caller already
|
|
29
|
+
# named — the live re-check of an index hit or an earlier offer. No
|
|
30
|
+
# search backend or OfferSource runs; the store is probed and its catalog
|
|
31
|
+
# searched, read-only, so its offers carry the same shape, `offer_ref`
|
|
32
|
+
# and history entry as any other find.
|
|
27
33
|
class Find
|
|
28
34
|
CART_CAP = "dev.ucp.shopping.cart".freeze
|
|
29
35
|
CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
|
|
@@ -43,8 +49,11 @@ module Portage
|
|
|
43
49
|
# candidate on it is never probed (see #call): it still surfaces as
|
|
44
50
|
# a candidate, marked `handoff_only: true`, so an agent can list it
|
|
45
51
|
# ("Amazon also sells this") without this process ever fetching it.
|
|
52
|
+
# @param store [String, nil] a store URL (see Find.store_origin): search
|
|
53
|
+
# only that store, live, and skip every backend and OfferSource.
|
|
46
54
|
def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
|
|
47
|
-
offer_sources: nil, handoff_only: nil)
|
|
55
|
+
offer_sources: nil, handoff_only: nil, store: nil)
|
|
56
|
+
@store = store
|
|
48
57
|
@query = query.to_s
|
|
49
58
|
@limit = [limit, MAX_PROBES].min
|
|
50
59
|
@max_price = max_price
|
|
@@ -55,6 +64,27 @@ module Portage
|
|
|
55
64
|
@handoff_only = handoff_only || HandoffOnly.new
|
|
56
65
|
end
|
|
57
66
|
|
|
67
|
+
# The origin a `--store` value names: an http(s) URL (or a bare host,
|
|
68
|
+
# as `check` and `buy` accept) collapsed onto scheme://host[:port].
|
|
69
|
+
# @return [String, nil] nil for anything else, such as ftp:// or junk.
|
|
70
|
+
def self.store_origin(url)
|
|
71
|
+
text = url.to_s.strip
|
|
72
|
+
return nil if text.empty? || (text.include?("://") && !text.match?(%r{\Ahttps?://}i))
|
|
73
|
+
|
|
74
|
+
uri = URI.parse(text.include?("://") ? text : "https://#{text}")
|
|
75
|
+
origin_of_uri(uri)
|
|
76
|
+
rescue URI::InvalidURIError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.origin_of_uri(uri)
|
|
81
|
+
return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
82
|
+
|
|
83
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
84
|
+
"#{uri.scheme}://#{uri.host.downcase}#{port}"
|
|
85
|
+
end
|
|
86
|
+
private_class_method :origin_of_uri
|
|
87
|
+
|
|
58
88
|
def call
|
|
59
89
|
return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
|
|
60
90
|
|
|
@@ -88,6 +118,8 @@ module Portage
|
|
|
88
118
|
# --- Step 1: ask the backends who might sell this ---
|
|
89
119
|
|
|
90
120
|
def candidate_origins
|
|
121
|
+
return store_candidate if @store
|
|
122
|
+
|
|
91
123
|
seen = {}
|
|
92
124
|
@backends.each do |backend|
|
|
93
125
|
urls_from(backend).each { |url| add_candidate(seen, backend, url) }
|
|
@@ -95,6 +127,15 @@ module Portage
|
|
|
95
127
|
seen.values.first(@limit)
|
|
96
128
|
end
|
|
97
129
|
|
|
130
|
+
# `--store`: the one candidate is the named store itself. A hand-off-only
|
|
131
|
+
# host stays a candidate, flagged, so #probe_candidates never fetches it.
|
|
132
|
+
def store_candidate
|
|
133
|
+
origin = self.class.store_origin(@store)
|
|
134
|
+
return [] unless origin
|
|
135
|
+
|
|
136
|
+
[{ origin: origin, source: "store", handoff_only: @handoff_only.host?(URI.parse(origin).host) }]
|
|
137
|
+
end
|
|
138
|
+
|
|
98
139
|
# Keyed by host rather than by full origin: backends routinely hand back
|
|
99
140
|
# both `http://` and `https://` for the same shop, and probing one host
|
|
100
141
|
# twice over two schemes is a wasted request every time. https wins when
|
|
@@ -141,6 +182,8 @@ module Portage
|
|
|
141
182
|
# #urls_from gives the URL backends. --max-price applies here exactly
|
|
142
183
|
# as it does to a probed store's offers in #offer.
|
|
143
184
|
def source_offers
|
|
185
|
+
return [] if @store
|
|
186
|
+
|
|
144
187
|
offers = @offer_sources.flat_map do |source|
|
|
145
188
|
source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
|
|
146
189
|
end
|
|
@@ -281,6 +324,8 @@ module Portage
|
|
|
281
324
|
end
|
|
282
325
|
|
|
283
326
|
def no_candidates_message
|
|
327
|
+
return "#{@store.inspect} isn't an http(s) store URL." if @store
|
|
328
|
+
|
|
284
329
|
names = @backends.map(&:name)
|
|
285
330
|
return no_backends_message if names.empty?
|
|
286
331
|
|
|
@@ -312,6 +357,8 @@ module Portage
|
|
|
312
357
|
end
|
|
313
358
|
|
|
314
359
|
def summary(candidates, stores, offers)
|
|
360
|
+
return store_summary(candidates.first, stores, offers) if @store
|
|
361
|
+
|
|
315
362
|
# Counted from the offers, not `stores`: an OfferSource's offers
|
|
316
363
|
# come from stores that were never probed.
|
|
317
364
|
selling = offers.map { |o| o[:store] }.uniq.length
|
|
@@ -320,6 +367,22 @@ module Portage
|
|
|
320
367
|
|
|
321
368
|
"Checked #{candidates.length} store(s); none of them speak UCP."
|
|
322
369
|
end
|
|
370
|
+
|
|
371
|
+
# The `--store` wording: say what happened to that one store, not "N
|
|
372
|
+
# stores".
|
|
373
|
+
def store_summary(candidate, stores, offers)
|
|
374
|
+
origin = candidate[:origin]
|
|
375
|
+
return "Found #{offers.length} offer(s) at #{origin}." if offers.any?
|
|
376
|
+
return handoff_only_message(origin) if candidate[:handoff_only]
|
|
377
|
+
return "#{origin} doesn't speak UCP, so there is no catalogue to search." if stores.empty?
|
|
378
|
+
|
|
379
|
+
"#{origin} speaks UCP but has nothing matching \"#{@query}\"."
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def handoff_only_message(origin)
|
|
383
|
+
"#{origin} is a hand-off-only retailer: Portage never fetches it, so it wasn't searched. " \
|
|
384
|
+
"Open the site yourself."
|
|
385
|
+
end
|
|
323
386
|
end
|
|
324
387
|
end
|
|
325
388
|
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Portage
|
|
|
55
55
|
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
56
56
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
57
57
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
58
|
+
portage find --store URL --query "..." [--max-price N] [--json] (one store, live, read-only)
|
|
58
59
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
59
60
|
[--max-price N] [--json]
|
|
60
61
|
portage check <url> [--json]
|
|
@@ -188,14 +189,29 @@ module Portage
|
|
|
188
189
|
return nil
|
|
189
190
|
end
|
|
190
191
|
|
|
191
|
-
opts
|
|
192
|
+
valid_find_store?(opts) ? opts : nil
|
|
192
193
|
end
|
|
193
194
|
private_class_method :parse_find_options
|
|
194
195
|
|
|
196
|
+
# `--store` must be an http(s) URL (a bare host is read as https); anything
|
|
197
|
+
# else is a usage error rather than a silent no-op search.
|
|
198
|
+
def self.valid_find_store?(opts)
|
|
199
|
+
return true unless opts.key?(:store)
|
|
200
|
+
return true if Find.store_origin(opts[:store])
|
|
201
|
+
|
|
202
|
+
warn "portage: --store needs an http(s) store URL, got #{opts[:store].inspect}."
|
|
203
|
+
warn USAGE
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
private_class_method :valid_find_store?
|
|
207
|
+
|
|
195
208
|
def self.find_option_parser(opts)
|
|
196
209
|
opts[:proxy] = {}
|
|
197
210
|
OptionParser.new do |parser|
|
|
198
211
|
parser.on("--query QUERY") { |v| opts[:query] = v }
|
|
212
|
+
parser.on("--store URL", "Search only this store's catalogue, live and read-only (http/https)") do |v|
|
|
213
|
+
opts[:store] = v
|
|
214
|
+
end
|
|
199
215
|
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
200
216
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
201
217
|
parser.on("--json") { opts[:json] = true }
|
|
@@ -414,7 +430,8 @@ module Portage
|
|
|
414
430
|
parsed[:quote_record] = quote
|
|
415
431
|
buy = parsed[:buy]
|
|
416
432
|
buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
|
|
417
|
-
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"]
|
|
433
|
+
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"], quote_store: quote["store"],
|
|
434
|
+
quote_title: quote["title"])
|
|
418
435
|
execute_buy(parsed, quote["store"])
|
|
419
436
|
end
|
|
420
437
|
private_class_method :buy_from_quote
|
|
@@ -1512,7 +1529,8 @@ module Portage
|
|
|
1512
1529
|
return "No index products match \"#{result[:query]}\"." if result[:products].empty?
|
|
1513
1530
|
|
|
1514
1531
|
lines = result[:products].map { |p| index_search_line(p) }
|
|
1515
|
-
lines << "(From the local index, not live: check the price and stock with
|
|
1532
|
+
lines << "(From the local index, not live: check the price and stock with " \
|
|
1533
|
+
"`portage find --store URL --query ...`.)"
|
|
1516
1534
|
lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
|
|
1517
1535
|
lines.join("\n")
|
|
1518
1536
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: irb
|
|
@@ -208,6 +208,7 @@ files:
|
|
|
208
208
|
- lib/portage/cli/classifier/table.rb
|
|
209
209
|
- lib/portage/cli/compare.rb
|
|
210
210
|
- lib/portage/cli/confidence_check.rb
|
|
211
|
+
- lib/portage/cli/confidence_state.rb
|
|
211
212
|
- lib/portage/cli/config.rb
|
|
212
213
|
- lib/portage/cli/console.rb
|
|
213
214
|
- lib/portage/cli/decisions.rb
|