From 9b9b553001b8b2ce1a1249403c21fe4b8841f6e7 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 12 Sep 2026 03:28:20 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20automations=20=E2=80=94=20weekly=20?= =?UTF-8?q?review=20job=20on=20the=20host=20scheduler=20+=20re-price=20pla?= =?UTF-8?q?n=20tool=20(0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `weekly_review: true` arms a plugin-owned recurring turn via sdk.schedule_recurring (idempotent by job id; re-armed on reload, cancelled on disable). The prompt runs inventory_stale → inventory_reprice_plan → ebay_price_check (sold) → inventory_set_price with basis + evidence, reports stale listings with a recommendation (never changes a listing), and ends with inventory_summary. Cron / timezone / caps in config + settings. - inventory_reprice_plan: the worklist — unsold items with the oldest or missing price evidence, capped, each with a suggested buyer-style query and its current band. - 70 host-free tests (fake graph.sdk records the scheduling calls; no seam → no crash). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011UQ33xXfygwEkzbbJx3YwZ --- README.md | 3 +- __init__.py | 11 +++ automations.py | 97 +++++++++++++++++++++++++ protoagent.plugin.yaml | 22 +++++- pyproject.toml | 2 +- skills/inventory-ops/SKILL.md | 10 ++- tests/test_automations.py | 132 ++++++++++++++++++++++++++++++++++ tests/test_tools.py | 2 +- tools.py | 23 ++++++ 9 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 automations.py create mode 100644 tests/test_automations.py diff --git a/README.md b/README.md index ad55a1b..ad536e6 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ inventory_summary() | | | |---|---| -| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_stale` | +| **Tools** | `inventory_summary` · `inventory_list` · `inventory_get` · `inventory_upsert_lot` · `inventory_upsert_item` · `inventory_delete_item` · `inventory_set_price` · `inventory_mark_sold` · `inventory_listing` · `inventory_import_csv` · `inventory_export_csv` · `inventory_reprice_plan` · `inventory_stale` | | **View** | a rail panel: the item grid (double-click to edit name/category/condition/qty; status select; click a price for the Price dialog), Price / Sold / Listing / Edit / Delete per row, lots with their P&L, sales, an activity log, CSV import (file or paste, with the mapping report) and export | | **API** | bearer-gated JSON under `/api/plugins/inventory` — `summary`, `lots`, `items`, `items/{id}/price`, `items/{id}/sold`, `items/{id}/listings`, `listings/{id}/end`, `sales`, `stale`, `audit`, `import`, `export` | +| **Automations** | `weekly_review: true` arms a plugin-owned recurring turn (`weekly_review_cron`, default Monday 09:00 in `review_timezone`) that re-prices stale evidence from eBay sold comps through the same tools, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L. Cancelled when the plugin is disabled. | | **Events** | `inventory.item.changed`, `inventory.lot.changed`, `inventory.sale.recorded`, `inventory.imported` | | **Skill** | `inventory-ops` — the rules (a target needs a basis; never set sold by hand; sold ≠ active ≠ retail) and the re-price / weekly-review routines | diff --git a/__init__.py b/__init__.py index bb1af08..51eb3e2 100644 --- a/__init__.py +++ b/__init__.py @@ -83,4 +83,15 @@ def emit(topic: str, data: dict) -> None: except Exception: # noqa: BLE001 log.exception("[inventory] registering skills failed") + # Automations (slice 3): a plugin-owned weekly review turn on the host scheduler. Armed + # here so a config reload re-arms it and a disable sweeps it (#1642). Best-effort: a + # host without the scheduler seam just logs. + try: + from .automations import arm + from .tools import as_bool + + arm(registry, cfg, plugin_id, enabled=as_bool(cfg.get("weekly_review"), False)) + except Exception: # noqa: BLE001 + log.debug("[inventory] automations not armed (no scheduler seam on this host?)", exc_info=True) + log.info("[inventory] registered") diff --git a/automations.py b/automations.py new file mode 100644 index 0000000..ea4f757 --- /dev/null +++ b/automations.py @@ -0,0 +1,97 @@ +"""Slice 3 — the automations: a plugin-owned weekly review job and the re-price plan the +agent (or that job) works through. Nothing here touches a listing or a price by itself; +the job is an agent TURN that uses the same tools an operator would, with the same rules. +""" + +from __future__ import annotations + +import logging + +from .store import InventoryStore + +log = logging.getLogger("protoagent.plugins.inventory") + +JOB_ID = "weekly-review" +DEFAULT_CRON = "0 9 * * 1" # Monday 09:00 + +WEEKLY_REVIEW_PROMPT = """Weekly inventory review (automated; owned by the inventory plugin). Load the inventory-ops skill and the ebay-pricing skill first, then: + +1. Call inventory_stale(listed_days={listed_days}, price_days={price_days}). +2. Stale PRICES — at most {max_items} items this run, oldest evidence first: for each, run ebay_price_check (sold) with a buyer-style query (inventory_reprice_plan suggests one). Read notes / headline_count / results_found before the statistics. With at least 5 exact sold comps, call inventory_set_price with low=p25, target=median, high=p75, basis "eBay sold comps (N sold, incl. shipping)" and the evidence fields (source=ebay_sold, n, p25, median, p75, query). With fewer, keep the targets and call inventory_set_price with the existing numbers and a basis noting "sold comps thin (N)" so the attempt is on record. If eBay answers with a sign-in wall or a challenge page, stop the eBay calls and say so. +3. Stale LISTINGS — for each, report the channel, days live, listed price vs the current target, and recommend one of: drop the price, relist, bundle, withdraw. Do NOT change or end a listing yourself; that is the operator's call. +4. Finish with inventory_summary and report per lot: cost, remaining at target (with the low–high band), realized net, projected net at target. Every number carries its basis; say once that fees are not yet netted out of remaining value. + +Keep the whole report short enough to read in a minute.""" + + +def weekly_review_prompt(cfg: dict) -> str: + return WEEKLY_REVIEW_PROMPT.format( + listed_days=int(cfg.get("stale_listing_days") or 14), + price_days=int(cfg.get("stale_price_days") or 30), + max_items=int(cfg.get("review_max_items") or 15), + ) + + +def arm(registry, cfg: dict, plugin_id: str, *, enabled: bool) -> dict: + """Arm (or disarm) the weekly review job through the host scheduler. Idempotent by + job id, so a config reload re-arms cleanly; a disable cancels. Returns what happened.""" + from graph import sdk # host-only; absent in the test suite unless stubbed + + if not enabled: + removed = sdk.cancel_scheduled(JOB_ID, plugin_id=plugin_id) + return {"armed": False, "cancelled": bool(removed)} + cron = str(cfg.get("weekly_review_cron") or DEFAULT_CRON).strip() + tz = str(cfg.get("review_timezone") or "").strip() or None + res = sdk.schedule_recurring(weekly_review_prompt(cfg), cron, plugin_id=plugin_id, job_id=JOB_ID, timezone=tz) + if not res.get("ok"): + log.warning("[inventory] weekly review not scheduled: %s", res.get("message")) + else: + log.info("[inventory] weekly review armed: %s (%s) next %s", cron, tz or "UTC", res.get("next_fire")) + return { + "armed": bool(res.get("ok")), + "cron": cron, + "timezone": tz or "UTC", + "next_fire": res.get("next_fire"), + "message": res.get("message"), + } + + +_CONDITION_WORDS = ("sealed", "new on sprue", "on sprue", "nib", "new", "unpainted", "painted", "assembled") + + +def suggest_query(item: dict) -> str: + """A buyer-style eBay query for an item: the name, plus the condition words that change + the price, minus the operator's own shorthand (ids, "x10", parenthetical notes).""" + name = str(item.get("name") or "").strip() + cond = str(item.get("condition") or "").strip().lower() + words = [w for w in _CONDITION_WORDS if w in cond and w not in name.lower()] + # keep the most specific condition phrase only ("new on sprue" already implies "new") + if "new on sprue" in words and "new" in words: + words.remove("new") + if "new on sprue" in words and "on sprue" in words: + words.remove("on sprue") + q = " ".join([name, *words]).strip() + return q + + +def reprice_plan(store: InventoryStore, *, lot_id: str = "", max_items: int = 15, price_days: int = 30) -> dict: + """The unsold items whose price evidence is oldest (or missing), capped, each with a + suggested query and its current band — the worklist a re-price loops over.""" + stale = store.stale(listed_days=10_000, price_days=price_days)["stale_prices"] + if lot_id: + stale = [i for i in stale if i["lot_id"] == lot_id] + stale.sort(key=lambda i: (i["price_updated_on"] or "", i["id"])) + plan = [ + { + "item_id": i["id"], + "name": i["name"], + "lot_id": i["lot_id"], + "status": i["status"], + "query": suggest_query(i), + "current": {"low": i["target_low"], "target": i["target"], "high": i["target_high"]}, + "price_basis": i["price_basis"], + "price_updated_on": i["price_updated_on"], + } + for i in stale[: max(1, int(max_items))] + ] + return {"items": plan, "total_stale": len(stale), "price_days": price_days} diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 1918dfb..1eef71d 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: inventory name: Inventory (resale source of truth) -version: 0.2.0 +version: 0.3.0 description: >- The resale inventory as a source of truth: lots (what you bought and for how much), items (what each lot split into, its condition and status), listings (where each item is @@ -9,8 +9,8 @@ description: >- audit trail of every change and who made it. Contributes agent tools for every operation (summary/P&L, list, get, upsert lot/item, - set price with evidence, mark sold, listings, CSV import/export, stale review), a - console rail view (a grid with inline edits, price / sold / listing dialogs, CSV + set price with evidence, mark sold, listings, CSV import/export, stale review, re-price + plan), an optional weekly-review job on the host scheduler, a console rail view (a grid with inline edits, price / sold / listing dialogs, CSV import/export, lots, sales and an activity log) and a bearer-gated JSON API under /api/plugins/inventory. Money is stored as integer cents and exposed as dollars. Ships DISABLED; enable with `plugins: { enabled: [inventory] }`. @@ -27,6 +27,16 @@ config: # Directory that relative paths in inventory_import_csv / inventory_export_csv resolve # against. Blank = the process working directory; set it to the agent workspace. workspace_dir: "" + # ── Automations ── a plugin-owned recurring agent turn on the host scheduler (cancelled + # when the plugin is disabled). The weekly review: re-prices stale evidence from eBay + # sold comps (through the same tools, same rules), reports stale listings with a + # recommendation (never changes a listing itself), and ends with the per-lot P&L. + weekly_review: false + weekly_review_cron: "0 9 * * 1" # Monday 09:00 in review_timezone + review_timezone: "" # IANA name, e.g. America/Los_Angeles; blank = UTC + review_max_items: 15 # re-price at most this many items per run (eBay pacing) + stale_listing_days: 14 # a live listing older than this is reported + stale_price_days: 30 # price evidence older than this is re-checked # Console view (ADR 0026): a left-rail icon whose panel iframes the page this plugin # serves at `path` (public prefix); its data comes through /api/plugins/inventory (gated). @@ -36,6 +46,12 @@ views: # Editable in Settings ▸ Plugins (ADR 0019). settings: - { key: workspace_dir, label: "Workspace directory", type: path, path_kind: dir, description: "Relative paths given to inventory_import_csv / inventory_export_csv resolve here. Point it at the agent workspace." } + - { key: weekly_review, label: "Weekly review", type: bool, description: "Arm a recurring agent turn that re-prices stale evidence from eBay sold comps, reports stale listings with a recommendation (it never changes a listing itself), and posts the per-lot P&L." } + - { key: weekly_review_cron, label: "Weekly review cron", type: string, description: "5-field cron, evaluated in the review timezone. Default Monday 09:00." } + - { key: review_timezone, label: "Review timezone", type: string, description: "IANA name such as America/Los_Angeles. Blank = UTC." } + - { key: review_max_items, label: "Items re-priced per run", type: number, description: "Caps eBay searches per review run; the rest wait for the next one." } + - { key: stale_listing_days, label: "Stale listing (days)", type: number, description: "A live listing older than this is reported in the review." } + - { key: stale_price_days, label: "Stale price (days)", type: number, description: "Price evidence older than this is re-checked in the review." } # Event bus (ADR 0039): the topics this plugin publishes. emits: diff --git a/pyproject.toml b/pyproject.toml index 2565c6b..fb8fad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inventory-plugin" -version = "0.2.0" +version = "0.3.0" description = "Resale inventory as a source of truth for protoAgent — lots, items, listings, sales, price evidence, audit" requires-python = ">=3.11" diff --git a/skills/inventory-ops/SKILL.md b/skills/inventory-ops/SKILL.md index 55e0be7..c2f7220 100644 --- a/skills/inventory-ops/SKILL.md +++ b/skills/inventory-ops/SKILL.md @@ -53,8 +53,14 @@ not a note. If a number is not in the inventory, it has not been decided. ## Weekly review `inventory_stale` lists listings older than 14 days and items whose price evidence is -older than 30 days. Re-price the stale prices, ask the operator about the stale listings -(drop the price, relist, bundle, or withdraw), and finish with `inventory_summary`. +older than 30 days. `inventory_reprice_plan` turns the stale prices into a worklist +(oldest evidence first, capped, each with a suggested buyer-style query). Re-price them, +ask the operator about the stale listings (drop the price, relist, bundle, or withdraw — +never end a listing yourself), and finish with `inventory_summary`. + +When `weekly_review: true` is set in the plugin config, the host scheduler runs exactly +this routine as an automated turn on `weekly_review_cron` (default Monday 09:00). It uses +the same tools and the same rules; the only thing it never does is touch a listing. ## Importing a spreadsheet diff --git a/tests/test_automations.py b/tests/test_automations.py new file mode 100644 index 0000000..a9455a2 --- /dev/null +++ b/tests/test_automations.py @@ -0,0 +1,132 @@ +"""Slice 3: the weekly review job is armed/disarmed through the host scheduler seam, the +prompt drives the same tools, and the re-price plan orders and caps the worklist.""" + +from __future__ import annotations + +import json +import sys +import types + +import inventory_plugin +import pytest +import yaml +from inventory_plugin.automations import DEFAULT_CRON, JOB_ID, reprice_plan, suggest_query, weekly_review_prompt + + +@pytest.fixture +def fake_sdk(monkeypatch): + """A stand-in for the host's `graph.sdk` — records schedule/cancel calls.""" + calls = [] + sdk = types.SimpleNamespace( + schedule_recurring=lambda prompt, cron, *, plugin_id, job_id, session="", timezone=None: ( + calls.append(("schedule", prompt, cron, plugin_id, job_id, timezone)) + or { + "ok": True, + "job_id": f"plugin:{plugin_id}:{job_id}", + "next_fire": "2026-09-14T09:00:00", + "message": "ok", + } + ), + cancel_scheduled=lambda job_id, *, plugin_id: calls.append(("cancel", job_id, plugin_id)) or True, + ) + graph = types.ModuleType("graph") + graph.sdk = sdk + monkeypatch.setitem(sys.modules, "graph", graph) + monkeypatch.setitem(sys.modules, "graph.sdk", sdk) + return calls + + +class TestArming: + def test_enabled_arms_the_job_with_the_configured_cadence(self, registry, fake_sdk): + registry.config.update( + { + "weekly_review": True, + "weekly_review_cron": "30 8 * * 2", + "review_timezone": "America/Los_Angeles", + "review_max_items": 7, + } + ) + inventory_plugin.register(registry) + sched = [c for c in fake_sdk if c[0] == "schedule"] + assert len(sched) == 1 + _, prompt, cron, plugin_id, job_id, tz = sched[0] + assert (cron, plugin_id, job_id, tz) == ("30 8 * * 2", "inventory", JOB_ID, "America/Los_Angeles") + for tool in ( + "inventory_stale", + "inventory_reprice_plan", + "ebay_price_check", + "inventory_set_price", + "inventory_summary", + ): + assert tool in prompt + assert "at most 7 items" in prompt + assert "Do NOT change or end a listing" in prompt + + def test_disabled_cancels_the_job(self, registry, fake_sdk): + registry.config.update({"weekly_review": "false"}) + inventory_plugin.register(registry) + assert [c for c in fake_sdk if c[0] == "schedule"] == [] + assert ("cancel", JOB_ID, "inventory") in fake_sdk + + def test_default_is_off_and_default_cron_is_monday_morning(self, registry, fake_sdk): + inventory_plugin.register(registry) + assert [c for c in fake_sdk if c[0] == "schedule"] == [] + assert DEFAULT_CRON == "0 9 * * 1" + + def test_no_scheduler_seam_does_not_break_registration(self, registry, monkeypatch): + monkeypatch.setitem(sys.modules, "graph", None) # `from graph import sdk` raises ImportError + registry.config.update({"weekly_review": True}) + inventory_plugin.register(registry) # must not raise + assert registry.tools + + def test_the_manifest_ships_the_review_off_with_settings(self): + from pathlib import Path + + manifest = yaml.safe_load((Path(inventory_plugin.__file__).parent / "protoagent.plugin.yaml").read_text()) + assert manifest["config"]["weekly_review"] is False + assert manifest["config"]["weekly_review_cron"] == DEFAULT_CRON + assert {s["key"] for s in manifest["settings"]} >= { + "weekly_review", + "weekly_review_cron", + "review_timezone", + "stale_price_days", + } + + +class TestReplan: + def test_suggest_query_keeps_price_changing_condition_words_only(self): + assert ( + suggest_query({"name": "Brionne Barons Bretonnian Team", "condition": "new on sprue"}) + == "Brionne Barons Bretonnian Team new on sprue" + ) + assert ( + suggest_query({"name": "Third Season box", "condition": "Sealed (shrink torn)"}) + == "Third Season box sealed" + ) + assert suggest_query({"name": "Sealed Second Season box", "condition": "sealed"}) == "Sealed Second Season box" + assert suggest_query({"name": "Dice", "condition": ""}) == "Dice" + + def test_plan_orders_oldest_evidence_first_and_caps(self, store): + for i, (iid, when) in enumerate((("A", "2026-09-01"), ("B", ""), ("C", "2025-01-01"), ("D", "2026-09-10"))): + store.upsert_item({"id": iid, "name": f"Item {iid}", "lot_id": "L"}, actor="t") + if when: + store.set_price(iid, target=10 + i, basis="b", observed_on=when, actor="t") + store.upsert_item({"id": "S", "name": "Sold", "lot_id": "L"}, actor="t") + store.mark_sold("S", price=5, channel="x", actor="t") + plan = reprice_plan(store, price_days=30, max_items=2) + assert [p["item_id"] for p in plan["items"]] == ["B", "C"] # missing evidence first, then oldest + assert plan["total_stale"] == 2 # A (11 days) and D are fresh; S is sold; B has no evidence, C is ancient + assert plan["items"][1]["current"]["target"] == 12.0 + assert plan["items"][0]["query"] == "Item B" + + def test_plan_tool_and_lot_filter(self, registry): + inventory_plugin.register(registry) + t = {x.name: x for x in registry.tools} + t["inventory_upsert_item"].invoke({"id": "X", "name": "Thing", "lot_id": "L1"}) + t["inventory_upsert_item"].invoke({"id": "Y", "name": "Other", "lot_id": "L2"}) + out = json.loads(t["inventory_reprice_plan"].invoke({"lot_id": "L1"})) + assert out["ok"] and [p["item_id"] for p in out["items"]] == ["X"] + + def test_prompt_uses_the_configured_thresholds(self): + p = weekly_review_prompt({"stale_listing_days": 7, "stale_price_days": 21, "review_max_items": 3}) + assert "listed_days=7" in p and "price_days=21" in p and "at most 3 items" in p diff --git a/tests/test_tools.py b/tests/test_tools.py index a79b41b..4f9209d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -22,7 +22,7 @@ def test_register_contributes_tools_router_skills(registry): "inventory_mark_sold", "inventory_import_csv", } <= names - assert len(names) == 12 + assert len(names) == 13 assert {prefix for _, prefix in registry.routers} == {"/api/plugins/inventory", "/plugins/inventory"} assert "skills" in registry.skill_dirs for t in registry.tools: diff --git a/tools.py b/tools.py index f608a05..1085550 100644 --- a/tools.py +++ b/tools.py @@ -31,6 +31,19 @@ def resolve_workspace_path(cfg: dict, path: str) -> Path: return p +def as_bool(value, default: bool) -> bool: + """A YAML/console flag: real bools pass through; the strings "false"/"no"/"0"/"off" mean + False; blank means unset.""" + if value is None: + return default + if isinstance(value, str): + text = value.strip().lower() + if not text: + return default + return text not in {"false", "no", "0", "off"} + return bool(value) + + def build_tools(store: InventoryStore, cfg: dict, *, emit=lambda topic, data: None): from langchain_core.tools import tool @@ -294,6 +307,15 @@ def inventory_export_csv(path: str = "", kind: str = "items", lot_id: str = "", except (OSError, InventoryError) as exc: return _err(exc) + @tool + def inventory_reprice_plan(lot_id: str = "", max_items: int = 15, price_days: int = 30) -> str: + """The re-price worklist: unsold items whose price evidence is older than `price_days` or missing, oldest first, capped at `max_items`, each with a suggested buyer-style eBay query and its current band. Loop it: ebay_price_check (sold) → inventory_set_price with the basis and the comps.""" + from .automations import reprice_plan + + return json.dumps( + {"ok": True, **reprice_plan(store, lot_id=lot_id, max_items=max_items, price_days=price_days)} + ) + @tool def inventory_stale(listed_days: int = 14, price_days: int = 30) -> str: """What needs attention: listings live longer than `listed_days`, and unsold items whose price evidence is older than `price_days` or missing. The weekly-review starting point.""" @@ -311,6 +333,7 @@ def inventory_stale(listed_days: int = 14, price_days: int = 30) -> str: inventory_listing, inventory_import_csv, inventory_export_csv, + inventory_reprice_plan, inventory_stale, ] From 01046fe4216d7bfac52434060864346b915a55bd Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 12 Sep 2026 03:50:52 -0700 Subject: [PATCH 2/2] test: automations tests create their lots first (lot_id must exist since 0.1.1) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011UQ33xXfygwEkzbbJx3YwZ --- tests/test_automations.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_automations.py b/tests/test_automations.py index a9455a2..47632f3 100644 --- a/tests/test_automations.py +++ b/tests/test_automations.py @@ -107,6 +107,7 @@ def test_suggest_query_keeps_price_changing_condition_words_only(self): assert suggest_query({"name": "Dice", "condition": ""}) == "Dice" def test_plan_orders_oldest_evidence_first_and_caps(self, store): + store.upsert_lot({"id": "L", "name": "L"}, actor="t") for i, (iid, when) in enumerate((("A", "2026-09-01"), ("B", ""), ("C", "2025-01-01"), ("D", "2026-09-10"))): store.upsert_item({"id": iid, "name": f"Item {iid}", "lot_id": "L"}, actor="t") if when: @@ -122,6 +123,8 @@ def test_plan_orders_oldest_evidence_first_and_caps(self, store): def test_plan_tool_and_lot_filter(self, registry): inventory_plugin.register(registry) t = {x.name: x for x in registry.tools} + for lot in ("L1", "L2"): + t["inventory_upsert_lot"].invoke({"id": lot, "name": lot}) t["inventory_upsert_item"].invoke({"id": "X", "name": "Thing", "lot_id": "L1"}) t["inventory_upsert_item"].invoke({"id": "Y", "name": "Other", "lot_id": "L2"}) out = json.loads(t["inventory_reprice_plan"].invoke({"lot_id": "L1"}))