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.
- shopping_cli/__init__.py +3 -0
- shopping_cli/agents/__init__.py +1 -0
- shopping_cli/agents/agent_cli.py +31 -0
- shopping_cli/agents/buyer_cli.py +163 -0
- shopping_cli/agents/merchant_agent.py +263 -0
- shopping_cli/agents/merchant_daemon.py +872 -0
- shopping_cli/agents/tools.py +488 -0
- shopping_cli/api/__init__.py +1 -0
- shopping_cli/api/app.py +1353 -0
- shopping_cli/api/auth.py +125 -0
- shopping_cli/api/fallback_asgi.py +219 -0
- shopping_cli/api/handlers/__init__.py +1 -0
- shopping_cli/api/handlers/agents.py +277 -0
- shopping_cli/api/handlers/audit.py +97 -0
- shopping_cli/api/handlers/buyer.py +291 -0
- shopping_cli/api/handlers/catalog.py +392 -0
- shopping_cli/api/handlers/common.py +76 -0
- shopping_cli/api/handlers/conversations.py +172 -0
- shopping_cli/api/handlers/erp.py +127 -0
- shopping_cli/api/handlers/human_review.py +293 -0
- shopping_cli/api/handlers/listings_projection.py +42 -0
- shopping_cli/api/handlers/negotiation.py +121 -0
- shopping_cli/api/idempotency.py +291 -0
- shopping_cli/api/limits.py +59 -0
- shopping_cli/api/route_registry.py +105 -0
- shopping_cli/api/routes_agents.py +13 -0
- shopping_cli/api/routes_conversations.py +13 -0
- shopping_cli/api/routes_marketplace.py +13 -0
- shopping_cli/api/routes_merchants.py +13 -0
- shopping_cli/api/server.py +31 -0
- shopping_cli/cli.py +897 -0
- shopping_cli/cli_agent_commands.py +386 -0
- shopping_cli/cli_buyer_commands.py +231 -0
- shopping_cli/cli_catalog_commands.py +281 -0
- shopping_cli/cli_common.py +135 -0
- shopping_cli/cli_conversation_commands.py +167 -0
- shopping_cli/cli_erp_commands.py +60 -0
- shopping_cli/cli_listing_commands.py +35 -0
- shopping_cli/commerce/__init__.py +6 -0
- shopping_cli/commerce/adapters.py +310 -0
- shopping_cli/commerce/data_source.py +95 -0
- shopping_cli/config.py +262 -0
- shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
- shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
- shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
- shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
- shopping_cli/core/__init__.py +1 -0
- shopping_cli/core/catalog.py +1281 -0
- shopping_cli/core/channels.py +329 -0
- shopping_cli/core/conversations.py +502 -0
- shopping_cli/core/delivery.py +7 -0
- shopping_cli/core/errors.py +43 -0
- shopping_cli/core/harness.py +345 -0
- shopping_cli/core/limits.py +137 -0
- shopping_cli/core/negotiation.py +273 -0
- shopping_cli/core/policies.py +356 -0
- shopping_cli/core/risk.py +101 -0
- shopping_cli/core/tokens.py +33 -0
- shopping_cli/data_sources/__init__.py +12 -0
- shopping_cli/data_sources/erp_source.py +440 -0
- shopping_cli/db/__init__.py +1 -0
- shopping_cli/db/migrations.py +415 -0
- shopping_cli/db/models.py +325 -0
- shopping_cli/db/provenance.py +21 -0
- shopping_cli/db/session.py +108 -0
- shopping_cli/http_client.py +149 -0
- shopping_cli/listings/__init__.py +4 -0
- shopping_cli/listings/projection.py +148 -0
- shopping_cli/services/__init__.py +1 -0
- shopping_cli/services/agents.py +244 -0
- shopping_cli/services/audit.py +34 -0
- shopping_cli/services/buyer_bootstrap.py +23 -0
- shopping_cli/services/conversations.py +209 -0
- shopping_cli/services/human_review.py +165 -0
- shopping_cli/services/negotiation.py +929 -0
- shopping_cli/services/tokens.py +318 -0
- shopping_cli-3.0.0.dist-info/METADATA +219 -0
- shopping_cli-3.0.0.dist-info/RECORD +82 -0
- shopping_cli-3.0.0.dist-info/WHEEL +5 -0
- shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
- shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|