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,392 @@
1
+ """Catalog and marketplace HTTP handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import hashlib
7
+ import hmac
8
+ import sqlite3
9
+ from typing import Any
10
+
11
+ from shopping_cli import VERSION
12
+ from shopping_cli.config import deployment_profile_from, production_config_checks, validate_production_config
13
+ from shopping_cli.core import catalog
14
+ from shopping_cli.api import auth as api_auth
15
+ from shopping_cli.api import idempotency
16
+ from shopping_cli.core.errors import AuthError, ConflictError, IdempotencyConflict
17
+ from shopping_cli.core.harness import append_audit_event
18
+ from shopping_cli.core.tokens import token_digest
19
+ from shopping_cli.db.session import db_session
20
+ from shopping_cli.services import tokens as token_service
21
+
22
+ from .common import (
23
+ bool_from_query,
24
+ public_merchant_summary,
25
+ public_product_summary,
26
+ require_field,
27
+ result_limit,
28
+ result_offset,
29
+ )
30
+
31
+
32
+ def health(db_path: str | Path) -> dict[str, Any]:
33
+ profile = deployment_profile_from()
34
+ checks: dict[str, Any] = production_config_checks()
35
+ checks["database"] = "ok"
36
+ ok = True
37
+ try:
38
+ validate_production_config()
39
+ except ValueError:
40
+ ok = False
41
+ try:
42
+ with db_session(db_path):
43
+ pass
44
+ except Exception:
45
+ checks["database"] = "error"
46
+ ok = False
47
+ return {
48
+ "ok": ok,
49
+ "service": "shopping-cli-marketplace",
50
+ "version": VERSION,
51
+ "storage": "sqlite",
52
+ "deployment_profile": profile,
53
+ "checks": checks,
54
+ }
55
+
56
+
57
+ def merchant_list(conn: Any, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
58
+ return catalog.list_merchants(conn, limit=int(limit), offset=int(offset))
59
+
60
+
61
+ def _server_idempotency_key(admin_token_hash: str, merchant_id: str) -> str:
62
+ """Derive a server-side idempotency key so every bootstrap is replayable."""
63
+ return f"auto:{admin_token_hash}:{merchant_id}"
64
+
65
+
66
+ def create_merchant(db_path: str | Path, payload: dict[str, Any], require_admin_token: Any) -> dict[str, Any]:
67
+ require_admin_token(payload)
68
+ merchant_id = str(require_field(payload, "id"))
69
+ name = str(require_field(payload, "name"))
70
+ admin_token = api_auth.payload_admin_token(payload)
71
+ admin_token_hash = token_digest(admin_token)
72
+ # Always use a deterministic idempotency key: honour the client-supplied key
73
+ # when present, otherwise derive one from admin_token + merchant_id so every
74
+ # bootstrap is replayable even when the response was lost mid-flight.
75
+ client_key = idempotency.idempotency_key_from_payload(payload)
76
+ idempotency_key = client_key or _server_idempotency_key(admin_token_hash, merchant_id)
77
+ request_hash = idempotency.request_hash(
78
+ {
79
+ "id": merchant_id,
80
+ "name": name,
81
+ "city": str(payload.get("city") or ""),
82
+ "service_area": str(payload.get("service_area") or ""),
83
+ "contact": str(payload.get("contact") or ""),
84
+ "hours": str(payload.get("hours") or ""),
85
+ "automation_boundaries": str(payload.get("automation_boundaries") or ""),
86
+ "tags": payload.get("tags") or [],
87
+ "delivery_fee": payload.get("delivery_fee", 0),
88
+ "delivery_eta_minutes": payload.get("delivery_eta_minutes", 0),
89
+ "delivery_radius_km": payload.get("delivery_radius_km", 0),
90
+ }
91
+ )
92
+
93
+ def replay(conn: Any) -> dict[str, Any] | None:
94
+ row = conn.execute(
95
+ """
96
+ select request_hash, merchant_id from merchant_bootstrap_idempotency
97
+ where admin_token_hash = ? and idempotency_key = ?
98
+ """,
99
+ (admin_token_hash, idempotency_key),
100
+ ).fetchone()
101
+ if row is None:
102
+ return None
103
+ if str(row["request_hash"]) != request_hash:
104
+ raise IdempotencyConflict("idempotency key was reused with a different request")
105
+ replay_merchant_id = str(row["merchant_id"] or merchant_id)
106
+ token = deterministic_merchant_token(admin_token, idempotency_key, replay_merchant_id)
107
+ token_row = conn.execute(
108
+ "select revoked_at from api_tokens where token_hash = ?",
109
+ (token_digest(token),),
110
+ ).fetchone()
111
+ if token_row is not None and str(token_row["revoked_at"] or ""):
112
+ raise ConflictError(
113
+ "merchant bootstrap token was rotated or revoked; "
114
+ "use POST /merchants/{merchant_id}/token/recover to recover"
115
+ )
116
+ token_service.ensure_merchant_token(conn, token, replay_merchant_id)
117
+ return {
118
+ "ok": True,
119
+ "merchant": catalog.merchant_summary(conn, replay_merchant_id),
120
+ "merchant_token": token,
121
+ "idempotent": True,
122
+ }
123
+
124
+ with db_session(db_path) as conn:
125
+ replayed = replay(conn)
126
+ if replayed is not None:
127
+ return replayed
128
+ try:
129
+ conn.execute(
130
+ """
131
+ insert into merchant_bootstrap_idempotency(
132
+ admin_token_hash, idempotency_key, request_hash, merchant_id, created_at, updated_at
133
+ ) values (?, ?, ?, ?, datetime('now'), datetime('now'))
134
+ """,
135
+ (admin_token_hash, idempotency_key, request_hash, merchant_id),
136
+ )
137
+ except sqlite3.IntegrityError:
138
+ replayed = replay(conn)
139
+ if replayed is not None:
140
+ return replayed
141
+ raise
142
+ try:
143
+ merchant = catalog.create_merchant(
144
+ conn,
145
+ merchant_id=merchant_id,
146
+ name=name,
147
+ city=str(payload.get("city") or ""),
148
+ service_area=str(payload.get("service_area") or ""),
149
+ contact=str(payload.get("contact") or ""),
150
+ hours=str(payload.get("hours") or ""),
151
+ automation_boundaries=str(payload.get("automation_boundaries") or ""),
152
+ tags=payload.get("tags") or [],
153
+ delivery_fee=payload.get("delivery_fee", 0),
154
+ delivery_eta_minutes=payload.get("delivery_eta_minutes", 0),
155
+ delivery_radius_km=payload.get("delivery_radius_km", 0),
156
+ )
157
+ except sqlite3.IntegrityError as exc:
158
+ raise ConflictError(f"Merchant already exists: {merchant_id}") from exc
159
+ token = deterministic_merchant_token(admin_token, idempotency_key, merchant["id"])
160
+ token_service.ensure_merchant_token(conn, token, merchant["id"])
161
+ return {"ok": True, "merchant": merchant, "merchant_token": token}
162
+
163
+
164
+ def deterministic_merchant_token(admin_token: str, idempotency_key: str, merchant_id: str) -> str:
165
+ material = f"merchant-bootstrap\n{idempotency_key}\n{merchant_id}"
166
+ digest = hmac.new(admin_token.encode("utf-8"), material.encode("utf-8"), hashlib.sha256).hexdigest()
167
+ return f"shopping_merchant_{digest}"
168
+
169
+
170
+ def rotate_merchant_token(
171
+ db_path: str | Path,
172
+ merchant_id: str,
173
+ payload: dict[str, Any],
174
+ require_admin_token: Any,
175
+ ) -> dict[str, Any]:
176
+ with db_session(db_path) as conn:
177
+ catalog.require_merchant(conn, merchant_id)
178
+ actor = "admin"
179
+ try:
180
+ require_admin_token(payload)
181
+ except AuthError:
182
+ token_row = token_service.require_merchant_token(conn, merchant_id, api_auth.payload_token(payload))
183
+ actor = str(token_row["merchant_id"])
184
+ token = token_service.rotate_merchant_token(conn, merchant_id, actor=actor)
185
+ return {"ok": True, "merchant_id": merchant_id, "merchant_token": token, "rotated": True}
186
+
187
+
188
+ def recover_merchant_token(
189
+ db_path: str | Path,
190
+ merchant_id: str,
191
+ payload: dict[str, Any],
192
+ require_admin_token: Any,
193
+ ) -> dict[str, Any]:
194
+ """Admin recovery: issue a fresh merchant token when the bootstrap token was lost.
195
+
196
+ This is the explicit recovery path promised by the bootstrap replay error.
197
+ It revokes all existing merchant tokens and issues a replacement, so the
198
+ caller does not need the lost token.
199
+ """
200
+ require_admin_token(payload)
201
+ with db_session(db_path) as conn:
202
+ catalog.require_merchant(conn, merchant_id)
203
+ token = token_service.rotate_merchant_token(conn, merchant_id, actor="admin")
204
+ return {
205
+ "ok": True,
206
+ "merchant_id": merchant_id,
207
+ "merchant_token": token,
208
+ "recovered": True,
209
+ }
210
+
211
+
212
+ def revoke_merchant_tokens(
213
+ db_path: str | Path,
214
+ merchant_id: str,
215
+ payload: dict[str, Any],
216
+ require_admin_token: Any,
217
+ ) -> dict[str, Any]:
218
+ require_admin_token(payload)
219
+ with db_session(db_path) as conn:
220
+ catalog.require_merchant(conn, merchant_id)
221
+ count = token_service.revoke_merchant_tokens(conn, merchant_id, actor="admin")
222
+ return {"ok": True, "merchant_id": merchant_id, "revoked_count": count}
223
+
224
+
225
+ def update_merchant(
226
+ db_path: str | Path,
227
+ merchant_id: str,
228
+ payload: dict[str, Any],
229
+ require_merchant_token: Any,
230
+ ) -> dict[str, Any]:
231
+ with db_session(db_path) as conn:
232
+ require_merchant_token(conn, merchant_id, payload)
233
+ merchant = catalog.update_merchant(
234
+ conn,
235
+ merchant_id=merchant_id,
236
+ name=payload.get("name"),
237
+ city=payload.get("city"),
238
+ service_area=payload.get("service_area"),
239
+ contact=payload.get("contact"),
240
+ hours=payload.get("hours"),
241
+ automation_boundaries=payload.get("automation_boundaries"),
242
+ tags=payload.get("tags") if "tags" in payload else None,
243
+ delivery_fee=payload.get("delivery_fee"),
244
+ delivery_eta_minutes=payload.get("delivery_eta_minutes"),
245
+ delivery_radius_km=payload.get("delivery_radius_km"),
246
+ )
247
+ return {"ok": True, "merchant": merchant}
248
+
249
+
250
+ def get_merchant(db_path: str | Path, merchant_id: str) -> dict[str, Any]:
251
+ with db_session(db_path) as conn:
252
+ return {"ok": True, "merchant": public_merchant_summary(catalog.merchant_summary(conn, merchant_id))}
253
+
254
+
255
+ def get_merchant_private_config(
256
+ db_path: str | Path,
257
+ merchant_id: str,
258
+ payload: dict[str, Any],
259
+ ) -> dict[str, Any]:
260
+ """Return private automation policy to an authenticated merchant/agent."""
261
+ with db_session(db_path) as conn:
262
+ merchant = catalog.require_merchant(conn, merchant_id)
263
+ token_row = token_service.require_api_token(
264
+ conn,
265
+ api_auth.payload_token(payload),
266
+ "merchant or agent token required",
267
+ )
268
+ if token_row["merchant_id"] != merchant_id or token_row["role"] not in {"merchant", "agent"}:
269
+ raise AuthError("invalid merchant or agent token")
270
+ actor = str(token_row["agent_id"] or merchant_id)
271
+ version = str(merchant["updated_at"] or "")
272
+ append_audit_event(
273
+ conn,
274
+ "",
275
+ actor,
276
+ "merchant_automation_boundaries_loaded",
277
+ {"merchant_id": merchant_id, "version": version},
278
+ )
279
+ return {
280
+ "ok": True,
281
+ "merchant_id": merchant_id,
282
+ "automation_boundaries": str(merchant["automation_boundaries"] or ""),
283
+ "version": version,
284
+ }
285
+
286
+ def list_merchants(db_path: str | Path, query: dict[str, Any] | None = None) -> dict[str, Any]:
287
+ query = query or {}
288
+ with db_session(db_path) as conn:
289
+ return {
290
+ "ok": True,
291
+ "results": [
292
+ public_merchant_summary(merchant)
293
+ for merchant in merchant_list(
294
+ conn,
295
+ limit=result_limit(query.get("limit")),
296
+ offset=result_offset(query.get("offset")),
297
+ )
298
+ ],
299
+ }
300
+
301
+
302
+ def create_product(
303
+ db_path: str | Path,
304
+ payload: dict[str, Any],
305
+ require_merchant_token: Any,
306
+ ) -> dict[str, Any]:
307
+ with db_session(db_path) as conn:
308
+ merchant_id = str(require_field(payload, "merchant_id"))
309
+ require_merchant_token(conn, merchant_id, payload)
310
+ product = catalog.create_product(
311
+ conn,
312
+ merchant_id=merchant_id,
313
+ sku=str(require_field(payload, "sku")),
314
+ title=str(require_field(payload, "title")),
315
+ price=require_field(payload, "price"),
316
+ stock=require_field(payload, "stock"),
317
+ currency=str(payload.get("currency") or "CNY"),
318
+ category=str(payload.get("category") or ""),
319
+ tags=payload.get("tags") or [],
320
+ description=str(payload.get("description") or ""),
321
+ delivery_attributes=payload.get("delivery_attributes") or [],
322
+ )
323
+ return {"ok": True, "product": product}
324
+
325
+
326
+ def update_product(
327
+ db_path: str | Path,
328
+ sku: str,
329
+ payload: dict[str, Any],
330
+ require_merchant_token: Any,
331
+ ) -> dict[str, Any]:
332
+ with db_session(db_path) as conn:
333
+ existing = catalog.product_summary(conn, sku)
334
+ merchant_id = str(payload.get("merchant_id") or existing["merchant_id"])
335
+ require_merchant_token(conn, merchant_id, payload)
336
+ product = catalog.update_product(
337
+ conn,
338
+ sku=sku,
339
+ merchant_id=merchant_id,
340
+ title=payload.get("title"),
341
+ price=payload.get("price"),
342
+ stock=payload.get("stock"),
343
+ currency=payload.get("currency"),
344
+ category=payload.get("category"),
345
+ tags=payload.get("tags") if "tags" in payload else None,
346
+ description=payload.get("description"),
347
+ delivery_attributes=payload.get("delivery_attributes") if "delivery_attributes" in payload else None,
348
+ )
349
+ return {"ok": True, "product": product}
350
+
351
+
352
+ def get_product(db_path: str | Path, sku: str) -> dict[str, Any]:
353
+ with db_session(db_path) as conn:
354
+ return {"ok": True, "product": public_product_summary(catalog.product_summary(conn, sku))}
355
+
356
+
357
+ def search_products(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
358
+ max_price = query.get("max_price")
359
+ with db_session(db_path) as conn:
360
+ return {
361
+ "ok": True,
362
+ "results": [
363
+ public_product_summary(product)
364
+ for product in catalog.search_products(
365
+ conn,
366
+ query=str(query.get("query") or ""),
367
+ city=str(query.get("city") or ""),
368
+ area=str(query.get("area") or ""),
369
+ max_price=max_price if str(max_price or "") else None,
370
+ include_out_of_stock=bool_from_query(query.get("include_out_of_stock")),
371
+ limit=result_limit(query.get("limit"), default=10),
372
+ offset=result_offset(query.get("offset")),
373
+ )
374
+ ],
375
+ }
376
+
377
+
378
+ def search_merchants(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
379
+ with db_session(db_path) as conn:
380
+ return {
381
+ "ok": True,
382
+ "results": [
383
+ public_merchant_summary(merchant)
384
+ for merchant in catalog.search_merchants(
385
+ conn,
386
+ query=str(query.get("query") or ""),
387
+ city=str(query.get("city") or ""),
388
+ limit=result_limit(query.get("limit"), default=10),
389
+ offset=result_offset(query.get("offset")),
390
+ )
391
+ ],
392
+ }
@@ -0,0 +1,76 @@
1
+ """Shared API handler normalization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ from shopping_cli.core.catalog import (
9
+ public_merchant_summary as catalog_public_merchant_summary,
10
+ public_product_summary as catalog_public_product_summary,
11
+ )
12
+ from shopping_cli.core.errors import ValidationError
13
+
14
+ MAX_SQLITE_INTEGER = 2**63 - 1
15
+
16
+
17
+ def require_field(payload: dict[str, Any], field: str) -> Any:
18
+ try:
19
+ return payload[field]
20
+ except KeyError as exc:
21
+ raise ValidationError(f"missing required field: {field}") from exc
22
+ DEFAULT_RESULT_LIMIT = 50
23
+ MAX_RESULT_LIMIT = 100
24
+
25
+
26
+ def bool_from_query(value: Any) -> bool:
27
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
28
+
29
+
30
+ def non_negative_whole_int(value: Any, field_name: str, default: int = 0) -> int:
31
+ if value in (None, ""):
32
+ return default
33
+ if isinstance(value, bool):
34
+ raise ValidationError(f"{field_name} must be a whole number")
35
+ if isinstance(value, int):
36
+ number = value
37
+ elif isinstance(value, float):
38
+ if not math.isfinite(value) or not value.is_integer():
39
+ raise ValidationError(f"{field_name} must be a whole number")
40
+ number = int(value)
41
+ else:
42
+ try:
43
+ number = int(str(value).strip())
44
+ except ValueError as exc:
45
+ raise ValidationError(f"{field_name} must be a whole number") from exc
46
+ if number < 0:
47
+ raise ValidationError(f"{field_name} must be non-negative")
48
+ if number > MAX_SQLITE_INTEGER:
49
+ raise ValidationError(f"{field_name} must be <= {MAX_SQLITE_INTEGER}")
50
+ return number
51
+
52
+
53
+ def positive_whole_int(value: Any, field_name: str) -> int:
54
+ number = non_negative_whole_int(value, field_name)
55
+ if number <= 0:
56
+ raise ValidationError(f"{field_name} must be greater than 0")
57
+ return number
58
+
59
+
60
+ def result_limit(value: Any, default: int = DEFAULT_RESULT_LIMIT) -> int:
61
+ if value in (None, ""):
62
+ return default
63
+ return min(positive_whole_int(value, "limit"), MAX_RESULT_LIMIT)
64
+
65
+
66
+ def result_offset(value: Any) -> int:
67
+ return non_negative_whole_int(value, "offset", default=0)
68
+
69
+
70
+ def public_merchant_summary(merchant: dict[str, Any]) -> dict[str, Any]:
71
+ """公开投影:剥离商家私有字段(contact / automation_boundaries)。"""
72
+ return catalog_public_merchant_summary(merchant)
73
+
74
+
75
+ def public_product_summary(product: dict[str, Any]) -> dict[str, Any]:
76
+ return catalog_public_product_summary(product)
@@ -0,0 +1,172 @@
1
+ """Conversation 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 DEFAULT_RESULT_LIMIT, require_field, result_limit, result_offset
10
+ from shopping_cli.core.conversations import (
11
+ conversation_summary,
12
+ merchant_conversations as merchant_conversation_summaries,
13
+ normalize_structured_payload,
14
+ )
15
+ from shopping_cli.core.errors import AuthError, ValidationError
16
+ from shopping_cli.db.session import db_session
17
+ from shopping_cli.services import conversations as conversation_service
18
+ from shopping_cli.services import tokens as token_service
19
+
20
+
21
+ def _payload_token(payload: dict[str, Any]) -> str:
22
+ return api_auth.payload_token(payload)
23
+
24
+
25
+ def get_conversation(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
26
+ with db_session(db_path) as conn:
27
+ conversation = conversation_summary(conn, conversation_id)
28
+ token_service.require_conversation_read_token(conn, conversation, _payload_token(payload))
29
+ return {"ok": True, "conversation": conversation}
30
+
31
+
32
+ def append_conversation_message(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
33
+ with db_session(db_path) as conn:
34
+ conversation = conversation_summary(conn, conversation_id)
35
+ sender = str(require_field(payload, "sender"))
36
+ structured_payload = normalize_structured_payload(payload.get("structured_payload"))
37
+ if payload.get("source_id"):
38
+ structured_payload["source_id"] = payload.get("source_id")
39
+ status = payload.get("status")
40
+ if sender in {"buyer", "buyer_cli"}:
41
+ token_service.require_buyer_conversation_token(conn, conversation, _payload_token(payload))
42
+ if status not in (None, ""):
43
+ raise ValidationError("buyer messages cannot set conversation status")
44
+ elif sender == "merchant":
45
+ token_service.require_merchant_token(conn, conversation["merchant_id"], _payload_token(payload))
46
+ elif sender == "merchant_agent":
47
+ agent_id = str(
48
+ structured_payload.get("source_id")
49
+ or token_service.default_merchant_agent_id(conversation["merchant_id"])
50
+ )
51
+ token_service.require_agent_or_merchant_token(
52
+ conn,
53
+ conversation["merchant_id"],
54
+ agent_id,
55
+ _payload_token(payload),
56
+ )
57
+ elif sender == "operator":
58
+ token_service.require_merchant_token(conn, conversation["merchant_id"], _payload_token(payload))
59
+ else:
60
+ raise ValidationError(f"Unknown conversation sender: {sender}")
61
+ result = conversation_service.append_conversation_message(
62
+ conn,
63
+ conversation,
64
+ conversation_id,
65
+ sender=sender,
66
+ intent=str(require_field(payload, "intent")),
67
+ text=str(require_field(payload, "text")),
68
+ structured_payload=structured_payload,
69
+ status=status,
70
+ )
71
+ return {"ok": True, **result}
72
+
73
+
74
+ def close_conversation(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
75
+ with db_session(db_path) as conn:
76
+ conversation = conversation_summary(conn, conversation_id)
77
+ sender = str(payload.get("sender") or "operator")
78
+ if sender in {"buyer", "buyer_cli"}:
79
+ token_service.require_buyer_conversation_token(conn, conversation, _payload_token(payload))
80
+ elif sender == "merchant":
81
+ token_service.require_merchant_token(conn, conversation["merchant_id"], _payload_token(payload))
82
+ elif sender == "merchant_agent":
83
+ agent_id = str(payload.get("source_id") or token_service.default_merchant_agent_id(conversation["merchant_id"]))
84
+ token_service.require_agent_or_merchant_token(conn, conversation["merchant_id"], agent_id, _payload_token(payload))
85
+ elif sender == "operator":
86
+ token_service.require_merchant_token(conn, conversation["merchant_id"], _payload_token(payload))
87
+ else:
88
+ raise ValidationError(f"Unknown conversation sender: {sender}")
89
+ conversation = conversation_service.close_conversation(
90
+ conn,
91
+ conversation,
92
+ conversation_id,
93
+ sender=sender,
94
+ intent=str(payload.get("intent") or "support"),
95
+ text=str(payload.get("text") or ""),
96
+ source_id=str(payload.get("source_id") or ""),
97
+ )
98
+ return {"ok": True, "conversation": conversation}
99
+
100
+
101
+ def append_conversation_closed_audit(
102
+ conn: Any,
103
+ conversation_id: str,
104
+ actor: str,
105
+ next_actor: str,
106
+ details: dict[str, Any] | None = None,
107
+ ) -> None:
108
+ conversation_service.append_conversation_closed_audit(conn, conversation_id, actor, next_actor, details)
109
+
110
+
111
+ def conversation_list(
112
+ db_path: str | Path,
113
+ filters: dict[str, Any],
114
+ payload: dict[str, Any],
115
+ owner_kind: str,
116
+ owner_id: str,
117
+ ) -> dict[str, Any]:
118
+ clauses: list[str] = []
119
+ values: list[Any] = []
120
+ for column in ("status", "merchant_id", "buyer_id", "sku"):
121
+ if filters.get(column):
122
+ clauses.append(f"{column} = ?")
123
+ values.append(str(filters[column]))
124
+ if filters.get("updated_since"):
125
+ clauses.append("updated_at >= ?")
126
+ values.append(str(filters["updated_since"]))
127
+ with db_session(db_path) as conn:
128
+ if owner_kind == "buyer":
129
+ token_row = token_service.require_buyer_read_token(conn, owner_id, _payload_token(payload))
130
+ if token_row["conversation_id"]:
131
+ clauses.append("id = ?")
132
+ values.append(str(token_row["conversation_id"]))
133
+ elif owner_kind == "merchant":
134
+ token_service.require_merchant_read_token(conn, owner_id, _payload_token(payload))
135
+ else:
136
+ raise AuthError("conversation list owner is required")
137
+ limit = result_limit(filters.get("limit"))
138
+ offset = result_offset(filters.get("offset"))
139
+ if str(filters.get("include") or "").lower() == "details":
140
+ results = conversation_service.list_conversation_details(
141
+ conn, clauses=clauses, values=values, limit=limit, offset=offset
142
+ )
143
+ else:
144
+ results = conversation_service.list_conversation_summaries(
145
+ conn, clauses=clauses, values=values, limit=limit, offset=offset
146
+ )
147
+ return {"ok": True, "conversations": results}
148
+
149
+
150
+ def merchant_conversations(
151
+ db_path: str | Path,
152
+ merchant_id: str,
153
+ payload: dict[str, Any],
154
+ status: str = "",
155
+ limit: Any = DEFAULT_RESULT_LIMIT,
156
+ offset: Any = 0,
157
+ include: Any = "",
158
+ ) -> dict[str, Any]:
159
+ with db_session(db_path) as conn:
160
+ token_service.require_merchant_read_token(conn, merchant_id, _payload_token(payload))
161
+ return {
162
+ "ok": True,
163
+ "merchant_id": merchant_id,
164
+ "conversations": merchant_conversation_summaries(
165
+ conn,
166
+ merchant_id,
167
+ status,
168
+ limit=result_limit(limit),
169
+ offset=result_offset(offset),
170
+ summary_only=str(include or "").lower() != "details",
171
+ ),
172
+ }