shopping-cli 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. shopping_cli/__init__.py +3 -0
  2. shopping_cli/agents/__init__.py +1 -0
  3. shopping_cli/agents/agent_cli.py +31 -0
  4. shopping_cli/agents/buyer_cli.py +163 -0
  5. shopping_cli/agents/merchant_agent.py +263 -0
  6. shopping_cli/agents/merchant_daemon.py +872 -0
  7. shopping_cli/agents/tools.py +488 -0
  8. shopping_cli/api/__init__.py +1 -0
  9. shopping_cli/api/app.py +1353 -0
  10. shopping_cli/api/auth.py +125 -0
  11. shopping_cli/api/fallback_asgi.py +219 -0
  12. shopping_cli/api/handlers/__init__.py +1 -0
  13. shopping_cli/api/handlers/agents.py +277 -0
  14. shopping_cli/api/handlers/audit.py +97 -0
  15. shopping_cli/api/handlers/buyer.py +291 -0
  16. shopping_cli/api/handlers/catalog.py +392 -0
  17. shopping_cli/api/handlers/common.py +76 -0
  18. shopping_cli/api/handlers/conversations.py +172 -0
  19. shopping_cli/api/handlers/erp.py +127 -0
  20. shopping_cli/api/handlers/human_review.py +293 -0
  21. shopping_cli/api/handlers/listings_projection.py +42 -0
  22. shopping_cli/api/handlers/negotiation.py +121 -0
  23. shopping_cli/api/idempotency.py +291 -0
  24. shopping_cli/api/limits.py +59 -0
  25. shopping_cli/api/route_registry.py +105 -0
  26. shopping_cli/api/routes_agents.py +13 -0
  27. shopping_cli/api/routes_conversations.py +13 -0
  28. shopping_cli/api/routes_marketplace.py +13 -0
  29. shopping_cli/api/routes_merchants.py +13 -0
  30. shopping_cli/api/server.py +31 -0
  31. shopping_cli/cli.py +897 -0
  32. shopping_cli/cli_agent_commands.py +386 -0
  33. shopping_cli/cli_buyer_commands.py +231 -0
  34. shopping_cli/cli_catalog_commands.py +281 -0
  35. shopping_cli/cli_common.py +135 -0
  36. shopping_cli/cli_conversation_commands.py +167 -0
  37. shopping_cli/cli_erp_commands.py +60 -0
  38. shopping_cli/cli_listing_commands.py +35 -0
  39. shopping_cli/commerce/__init__.py +6 -0
  40. shopping_cli/commerce/adapters.py +310 -0
  41. shopping_cli/commerce/data_source.py +95 -0
  42. shopping_cli/config.py +262 -0
  43. shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
  44. shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
  45. shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
  46. shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
  47. shopping_cli/core/__init__.py +1 -0
  48. shopping_cli/core/catalog.py +1281 -0
  49. shopping_cli/core/channels.py +329 -0
  50. shopping_cli/core/conversations.py +502 -0
  51. shopping_cli/core/delivery.py +7 -0
  52. shopping_cli/core/errors.py +43 -0
  53. shopping_cli/core/harness.py +345 -0
  54. shopping_cli/core/limits.py +137 -0
  55. shopping_cli/core/negotiation.py +273 -0
  56. shopping_cli/core/policies.py +356 -0
  57. shopping_cli/core/risk.py +101 -0
  58. shopping_cli/core/tokens.py +33 -0
  59. shopping_cli/data_sources/__init__.py +12 -0
  60. shopping_cli/data_sources/erp_source.py +440 -0
  61. shopping_cli/db/__init__.py +1 -0
  62. shopping_cli/db/migrations.py +415 -0
  63. shopping_cli/db/models.py +325 -0
  64. shopping_cli/db/provenance.py +21 -0
  65. shopping_cli/db/session.py +108 -0
  66. shopping_cli/http_client.py +149 -0
  67. shopping_cli/listings/__init__.py +4 -0
  68. shopping_cli/listings/projection.py +148 -0
  69. shopping_cli/services/__init__.py +1 -0
  70. shopping_cli/services/agents.py +244 -0
  71. shopping_cli/services/audit.py +34 -0
  72. shopping_cli/services/buyer_bootstrap.py +23 -0
  73. shopping_cli/services/conversations.py +209 -0
  74. shopping_cli/services/human_review.py +165 -0
  75. shopping_cli/services/negotiation.py +929 -0
  76. shopping_cli/services/tokens.py +318 -0
  77. shopping_cli-3.0.0.dist-info/METADATA +219 -0
  78. shopping_cli-3.0.0.dist-info/RECORD +82 -0
  79. shopping_cli-3.0.0.dist-info/WHEEL +5 -0
  80. shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
  81. shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
  82. shopping_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ """Audit API handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shopping_cli.api import auth as api_auth
9
+ from shopping_cli.api.handlers.common import positive_whole_int, result_offset
10
+ from shopping_cli.core.conversations import conversation_summary
11
+ from shopping_cli.core.errors import AuthError
12
+ from shopping_cli.core.harness import append_audit_event
13
+ from shopping_cli.db.session import db_session
14
+ from shopping_cli.services import audit as audit_service
15
+ from shopping_cli.services import tokens as token_service
16
+
17
+
18
+ MAX_AUDIT_EVENT_LIMIT = 200
19
+
20
+
21
+ def _payload_token(payload: dict[str, Any]) -> str:
22
+ return api_auth.payload_token(payload)
23
+
24
+
25
+ def audit_event_limit(value: Any) -> int:
26
+ if value in (None, ""):
27
+ return 50
28
+ return min(positive_whole_int(value, "limit"), MAX_AUDIT_EVENT_LIMIT)
29
+
30
+
31
+ def record_tool_call_audit(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
32
+ conversation_id = str(payload.get("conversation_id") or "")
33
+ with db_session(db_path) as conn:
34
+ if conversation_id:
35
+ conversation = conversation_summary(conn, conversation_id)
36
+ row = token_service.require_api_token(conn, _payload_token(payload), "merchant or agent audit token required")
37
+ if row["role"] == "merchant" and row["merchant_id"] == conversation["merchant_id"]:
38
+ actor = row["merchant_id"]
39
+ token_scope = "merchant"
40
+ elif row["role"] == "agent" and row["merchant_id"] == conversation["merchant_id"]:
41
+ actor = row["agent_id"] or token_service.default_merchant_agent_id(conversation["merchant_id"])
42
+ token_scope = "merchant_agent"
43
+ else:
44
+ raise AuthError("merchant or agent audit token required")
45
+ else:
46
+ row = token_service.require_api_token(conn, _payload_token(payload), "merchant or agent audit token required")
47
+ if row["role"] == "merchant":
48
+ actor = row["merchant_id"]
49
+ token_scope = "merchant"
50
+ elif row["role"] == "agent":
51
+ actor = row["agent_id"] or token_service.default_merchant_agent_id(row["merchant_id"])
52
+ token_scope = "merchant_agent"
53
+ else:
54
+ raise AuthError("merchant or agent audit token required")
55
+ event = append_audit_event(
56
+ conn,
57
+ conversation_id,
58
+ actor,
59
+ "agent_tool_call",
60
+ {
61
+ "tool": str(payload.get("tool") or ""),
62
+ "status": str(payload.get("status") or ""),
63
+ "host": str(payload.get("host") or ""),
64
+ "session_id": str(payload.get("session_id") or ""),
65
+ "actor": actor,
66
+ "source_id": actor,
67
+ "token_scope": token_scope,
68
+ "error": str(payload.get("error") or ""),
69
+ },
70
+ )
71
+ return {"ok": True, "event": event}
72
+
73
+
74
+ def audit_events(
75
+ db_path: str | Path,
76
+ payload: dict[str, Any],
77
+ merchant_id: str = "",
78
+ event: str = "",
79
+ limit: Any = 50,
80
+ offset: Any = 0,
81
+ ) -> dict[str, Any]:
82
+ with db_session(db_path) as conn:
83
+ resolved_merchant_id = str(merchant_id or payload.get("merchant_id") or "")
84
+ if not resolved_merchant_id:
85
+ raise AuthError("merchant_id is required for audit events")
86
+ token_service.require_merchant_token(conn, resolved_merchant_id, _payload_token(payload))
87
+ return {
88
+ "ok": True,
89
+ "merchant_id": resolved_merchant_id,
90
+ "events": audit_service.merchant_audit_events(
91
+ conn,
92
+ resolved_merchant_id,
93
+ event=event,
94
+ limit=audit_event_limit(limit),
95
+ offset=result_offset(offset),
96
+ ),
97
+ }
@@ -0,0 +1,291 @@
1
+ """Buyer-facing API handlers: buyer/ask, conversation creation, channel ingress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shopping_cli.agents import buyer_cli
10
+ from shopping_cli.api import auth as api_auth
11
+ from shopping_cli.api import idempotency as api_idempotency
12
+ from shopping_cli.api.handlers.common import MAX_SQLITE_INTEGER, non_negative_whole_int, require_field
13
+ from shopping_cli.core.channels import ingest_buyer_message
14
+ from shopping_cli.core.errors import ValidationError
15
+ from shopping_cli.core.tokens import token_digest
16
+ from shopping_cli.db.session import db_session
17
+ from shopping_cli.services import buyer_bootstrap as buyer_bootstrap_service
18
+ from shopping_cli.services import conversations as conversation_service
19
+ from shopping_cli.services import tokens as token_service
20
+
21
+ _DEFAULT_BUYER_BOOTSTRAP_RATE_LIMIT_PER_MINUTE = api_idempotency.DEFAULT_BUYER_BOOTSTRAP_RATE_LIMIT_PER_MINUTE
22
+
23
+
24
+ def _issue_buyer_token(conn: Any, buyer_id: str, conversation_id: str) -> str:
25
+ return token_service.issue_buyer_token(conn, buyer_id, conversation_id)
26
+
27
+
28
+ def _ensure_buyer_token(conn: Any, buyer_id: str, conversation_id: str, token: str) -> str:
29
+ return token_service.ensure_buyer_token(conn, buyer_id, conversation_id, token)
30
+
31
+
32
+ def _buyer_bootstrap_rate_limit_per_minute() -> int:
33
+ return buyer_bootstrap_service.rate_limit_per_minute(
34
+ os.environ.get("SHOPPING_BUYER_BOOTSTRAP_RATE_LIMIT_PER_MINUTE"),
35
+ default=_DEFAULT_BUYER_BOOTSTRAP_RATE_LIMIT_PER_MINUTE,
36
+ maximum=MAX_SQLITE_INTEGER,
37
+ )
38
+
39
+
40
+ def _enforce_buyer_bootstrap_rate_limit(conn: Any, bootstrap_token_hash: str, buyer_id: str) -> None:
41
+ api_idempotency.enforce_buyer_bootstrap_rate_limit(
42
+ conn,
43
+ bootstrap_token_hash,
44
+ buyer_id,
45
+ _buyer_bootstrap_rate_limit_per_minute(),
46
+ )
47
+
48
+
49
+ def _replay_idempotency(
50
+ conn: Any,
51
+ payload: dict[str, Any],
52
+ endpoint: str,
53
+ bootstrap_token_hash: str,
54
+ buyer_id: str,
55
+ idempotency_key: str,
56
+ request_hash: str,
57
+ ) -> dict[str, Any] | None:
58
+ return api_idempotency.replay_buyer_idempotency(
59
+ conn,
60
+ payload,
61
+ endpoint,
62
+ bootstrap_token_hash,
63
+ buyer_id,
64
+ idempotency_key,
65
+ request_hash,
66
+ _ensure_buyer_token,
67
+ )
68
+
69
+
70
+ def _claim_idempotency(
71
+ conn: Any,
72
+ payload: dict[str, Any],
73
+ endpoint: str,
74
+ bootstrap_token_hash: str,
75
+ buyer_id: str,
76
+ idempotency_key: str,
77
+ request_hash: str,
78
+ ) -> dict[str, Any] | None:
79
+ return api_idempotency.claim_buyer_idempotency(
80
+ conn,
81
+ payload,
82
+ endpoint,
83
+ bootstrap_token_hash,
84
+ buyer_id,
85
+ idempotency_key,
86
+ request_hash,
87
+ _ensure_buyer_token,
88
+ )
89
+
90
+
91
+ def _complete_idempotency(
92
+ conn: Any,
93
+ endpoint: str,
94
+ bootstrap_token_hash: str,
95
+ buyer_id: str,
96
+ idempotency_key: str,
97
+ request_hash: str,
98
+ response: dict[str, Any],
99
+ ) -> None:
100
+ api_idempotency.complete_buyer_idempotency(
101
+ conn,
102
+ endpoint,
103
+ bootstrap_token_hash,
104
+ buyer_id,
105
+ idempotency_key,
106
+ request_hash,
107
+ response,
108
+ non_negative_whole_int,
109
+ )
110
+
111
+
112
+ def _clear_idempotency_claim(
113
+ conn: Any,
114
+ endpoint: str,
115
+ bootstrap_token_hash: str,
116
+ idempotency_key: str,
117
+ request_hash: str,
118
+ ) -> None:
119
+ api_idempotency.clear_buyer_idempotency_claim(
120
+ conn,
121
+ endpoint,
122
+ bootstrap_token_hash,
123
+ idempotency_key,
124
+ request_hash,
125
+ )
126
+
127
+
128
+ def buyer_ask(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
129
+ bootstrap_token_hash = api_auth.require_buyer_bootstrap_token(payload, token_digest)
130
+ buyer_id = str(require_field(payload, "buyer_id")).strip()
131
+ text = str(require_field(payload, "text"))
132
+ if not buyer_id:
133
+ raise ValidationError("buyer_id is required")
134
+ if not text.strip():
135
+ raise ValidationError("text is required")
136
+ payload = {**payload, "buyer_id": buyer_id, "text": text}
137
+ idempotency_key = api_idempotency.idempotency_key_from_payload(payload)
138
+ request_hash = api_idempotency.buyer_ask_request_hash(payload)
139
+ endpoint = "/buyer/ask"
140
+ with db_session(db_path) as conn:
141
+ replayed = _replay_idempotency(
142
+ conn,
143
+ payload,
144
+ endpoint,
145
+ bootstrap_token_hash,
146
+ buyer_id,
147
+ idempotency_key,
148
+ request_hash,
149
+ )
150
+ if replayed is not None:
151
+ return replayed
152
+ _enforce_buyer_bootstrap_rate_limit(conn, bootstrap_token_hash, buyer_id)
153
+ replayed = _claim_idempotency(
154
+ conn,
155
+ payload,
156
+ endpoint,
157
+ bootstrap_token_hash,
158
+ buyer_id,
159
+ idempotency_key,
160
+ request_hash,
161
+ )
162
+ if replayed is not None:
163
+ return replayed
164
+ try:
165
+ result = buyer_cli.ask(
166
+ conn,
167
+ buyer_id=buyer_id,
168
+ text=str(payload["text"]),
169
+ city=str(payload.get("city") or ""),
170
+ area=str(payload.get("area") or ""),
171
+ source_id=str(payload.get("source_id") or "buyer-cli"),
172
+ host=str(payload.get("host") or ""),
173
+ session_id=str(payload.get("session_id") or ""),
174
+ reuse_open=False,
175
+ )
176
+ if result.get("conversation"):
177
+ if idempotency_key:
178
+ token = api_idempotency.deterministic_buyer_token(
179
+ payload,
180
+ endpoint,
181
+ idempotency_key,
182
+ result["buyer_id"],
183
+ result["conversation"]["id"],
184
+ )
185
+ result["buyer_token"] = _ensure_buyer_token(
186
+ conn,
187
+ result["buyer_id"],
188
+ result["conversation"]["id"],
189
+ token,
190
+ )
191
+ else:
192
+ result["buyer_token"] = _issue_buyer_token(conn, result["buyer_id"], result["conversation"]["id"])
193
+ result["idempotent"] = False
194
+ _complete_idempotency(conn, endpoint, bootstrap_token_hash, buyer_id, idempotency_key, request_hash, result)
195
+ return result
196
+ except KeyError as exc:
197
+ raise ValidationError(f"missing required field: {exc.args[0]}") from exc
198
+ except Exception:
199
+ _clear_idempotency_claim(conn, endpoint, bootstrap_token_hash, buyer_id, idempotency_key, request_hash)
200
+ raise
201
+
202
+
203
+ def ingest_channel_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
204
+ if payload.get("buyer_id"):
205
+ raise ValidationError("buyer_id override is not allowed for channel ingress")
206
+ channel = require_field(payload, "channel")
207
+ api_auth.require_channel_token(str(channel), payload)
208
+ with db_session(db_path) as conn:
209
+ return ingest_buyer_message(
210
+ conn,
211
+ channel=str(channel),
212
+ external_user_id=str(require_field(payload, "external_user_id")),
213
+ text=str(require_field(payload, "text")),
214
+ city=str(payload.get("city") or ""),
215
+ area=str(payload.get("area") or ""),
216
+ conversation_id=str(payload.get("conversation_id") or ""),
217
+ external_message_id=str(payload.get("external_message_id") or ""),
218
+ )
219
+
220
+
221
+ def create_conversation(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
222
+ bootstrap_token_hash = api_auth.require_buyer_bootstrap_token(payload, token_digest)
223
+ buyer_id = str(require_field(payload, "buyer_id")).strip()
224
+ merchant_id = str(require_field(payload, "merchant_id")).strip()
225
+ if not buyer_id:
226
+ raise ValidationError("buyer id is required")
227
+ if not merchant_id:
228
+ raise ValidationError("merchant id is required")
229
+ payload = {**payload, "buyer_id": buyer_id, "merchant_id": merchant_id}
230
+ idempotency_key = api_idempotency.idempotency_key_from_payload(payload)
231
+ request_hash = api_idempotency.conversation_create_request_hash(payload)
232
+ endpoint = "/conversations"
233
+ with db_session(db_path) as conn:
234
+ replayed = _replay_idempotency(
235
+ conn,
236
+ payload,
237
+ endpoint,
238
+ bootstrap_token_hash,
239
+ buyer_id,
240
+ idempotency_key,
241
+ request_hash,
242
+ )
243
+ if replayed is not None:
244
+ return replayed
245
+ _enforce_buyer_bootstrap_rate_limit(conn, bootstrap_token_hash, buyer_id)
246
+ replayed = _claim_idempotency(
247
+ conn,
248
+ payload,
249
+ endpoint,
250
+ bootstrap_token_hash,
251
+ buyer_id,
252
+ idempotency_key,
253
+ request_hash,
254
+ )
255
+ if replayed is not None:
256
+ return replayed
257
+ try:
258
+ conversation = conversation_service.create_conversation(
259
+ conn,
260
+ buyer_id=buyer_id,
261
+ merchant_id=merchant_id,
262
+ sku=str(payload.get("sku") or ""),
263
+ text=str(payload.get("text") or ""),
264
+ intent=str(payload.get("intent") or "ask_product"),
265
+ source_id=str(payload.get("source_id") or ""),
266
+ reuse_open=False,
267
+ )
268
+ if idempotency_key:
269
+ token = api_idempotency.deterministic_buyer_token(
270
+ payload,
271
+ endpoint,
272
+ idempotency_key,
273
+ conversation["buyer_id"],
274
+ conversation["id"],
275
+ )
276
+ buyer_token = _ensure_buyer_token(conn, conversation["buyer_id"], conversation["id"], token)
277
+ else:
278
+ buyer_token = _issue_buyer_token(conn, conversation["buyer_id"], conversation["id"])
279
+ result = {
280
+ "ok": True,
281
+ "conversation": conversation,
282
+ "buyer_token": buyer_token,
283
+ "idempotent": False,
284
+ }
285
+ _complete_idempotency(conn, endpoint, bootstrap_token_hash, buyer_id, idempotency_key, request_hash, result)
286
+ return result
287
+ except KeyError as exc:
288
+ raise ValidationError(f"missing required field: {exc.args[0]}") from exc
289
+ except Exception:
290
+ _clear_idempotency_claim(conn, endpoint, bootstrap_token_hash, buyer_id, idempotency_key, request_hash)
291
+ raise