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,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
|