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
shopping_cli/api/auth.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""API authentication and bearer-token payload helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from shopping_cli.core.channels import normalize_channel
|
|
10
|
+
from shopping_cli.core.errors import AuthError
|
|
11
|
+
from shopping_cli.core.tokens import token_matches
|
|
12
|
+
from shopping_cli.config import secret_is_usable
|
|
13
|
+
from shopping_cli.api.limits import validate_payload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def payload_token(payload: dict[str, Any]) -> str:
|
|
17
|
+
return str(
|
|
18
|
+
payload.get("merchant_token")
|
|
19
|
+
or payload.get("agent_token")
|
|
20
|
+
or payload.get("buyer_token")
|
|
21
|
+
or payload.get("_auth_token")
|
|
22
|
+
or ""
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def payload_admin_token(payload: dict[str, Any]) -> str:
|
|
27
|
+
return str(payload.get("admin_token") or payload.get("_auth_token") or "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def payload_channel_token(payload: dict[str, Any]) -> str:
|
|
31
|
+
return str(payload.get("channel_token") or payload.get("_auth_token") or "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def payload_buyer_bootstrap_token(payload: dict[str, Any]) -> str:
|
|
35
|
+
return str(payload.get("buyer_bootstrap_token") or payload.get("_auth_token") or "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def payload_with_auth(
|
|
39
|
+
payload: dict[str, Any],
|
|
40
|
+
authorization: Any = "",
|
|
41
|
+
idempotency_key: Any = "",
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
validate_payload(payload)
|
|
44
|
+
merged = dict(payload or {})
|
|
45
|
+
if isinstance(authorization, str) and authorization.lower().startswith("bearer "):
|
|
46
|
+
merged["_auth_token"] = authorization.split(" ", 1)[1].strip()
|
|
47
|
+
if isinstance(idempotency_key, str) and idempotency_key.strip():
|
|
48
|
+
merged["_idempotency_key"] = idempotency_key.strip()
|
|
49
|
+
return merged
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def configured_admin_token() -> str:
|
|
53
|
+
return str(os.environ.get("SHOPPING_ADMIN_TOKEN") or "").strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configured_buyer_bootstrap_token() -> str:
|
|
57
|
+
return str(os.environ.get("SHOPPING_BUYER_BOOTSTRAP_TOKEN") or "").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def require_admin_token(payload: dict[str, Any]) -> None:
|
|
61
|
+
expected = configured_admin_token()
|
|
62
|
+
if not secret_is_usable(expected):
|
|
63
|
+
raise AuthError("admin bootstrap token is not configured")
|
|
64
|
+
token = payload_admin_token(payload)
|
|
65
|
+
if not token:
|
|
66
|
+
raise AuthError("admin bootstrap token required")
|
|
67
|
+
if not token_matches(token, expected):
|
|
68
|
+
raise AuthError("invalid admin bootstrap token")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def require_buyer_bootstrap_token(payload: dict[str, Any], digest_fn: Any) -> str:
|
|
72
|
+
expected = configured_buyer_bootstrap_token()
|
|
73
|
+
if not secret_is_usable(expected):
|
|
74
|
+
raise AuthError("buyer bootstrap token is not configured")
|
|
75
|
+
token = payload_buyer_bootstrap_token(payload)
|
|
76
|
+
if not token:
|
|
77
|
+
raise AuthError("buyer bootstrap token required")
|
|
78
|
+
if not token_matches(token, expected):
|
|
79
|
+
raise AuthError("invalid buyer bootstrap token")
|
|
80
|
+
return digest_fn(token)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def channel_token_map() -> dict[str, str]:
|
|
84
|
+
tokens: dict[str, str] = {}
|
|
85
|
+
global_token = str(os.environ.get("SHOPPING_CHANNEL_TOKEN") or "").strip()
|
|
86
|
+
if secret_is_usable(global_token):
|
|
87
|
+
tokens["*"] = global_token
|
|
88
|
+
raw = str(os.environ.get("SHOPPING_CHANNEL_TOKENS") or "").strip()
|
|
89
|
+
if not raw:
|
|
90
|
+
return tokens
|
|
91
|
+
try:
|
|
92
|
+
decoded = json.loads(raw)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
decoded = None
|
|
95
|
+
if isinstance(decoded, dict):
|
|
96
|
+
for channel, token in decoded.items():
|
|
97
|
+
normalized = normalize_channel(str(channel))
|
|
98
|
+
if normalized and secret_is_usable(str(token or "")):
|
|
99
|
+
tokens[normalized] = str(token).strip()
|
|
100
|
+
return tokens
|
|
101
|
+
for part in raw.replace("\n", ",").split(","):
|
|
102
|
+
text = part.strip()
|
|
103
|
+
if not text:
|
|
104
|
+
continue
|
|
105
|
+
separator = ":" if ":" in text else "=" if "=" in text else ""
|
|
106
|
+
if not separator:
|
|
107
|
+
continue
|
|
108
|
+
channel, token = text.split(separator, 1)
|
|
109
|
+
normalized = normalize_channel(channel)
|
|
110
|
+
if normalized and secret_is_usable(token):
|
|
111
|
+
tokens[normalized] = token.strip()
|
|
112
|
+
return tokens
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_channel_token(channel: str, payload: dict[str, Any]) -> None:
|
|
116
|
+
normalized = normalize_channel(channel)
|
|
117
|
+
tokens = channel_token_map()
|
|
118
|
+
expected = tokens.get(normalized) or tokens.get("*") or ""
|
|
119
|
+
if not expected:
|
|
120
|
+
raise AuthError(f"channel token is not configured for {normalized or '-'}")
|
|
121
|
+
token = payload_channel_token(payload)
|
|
122
|
+
if not token:
|
|
123
|
+
raise AuthError("channel token required")
|
|
124
|
+
if not token_matches(token, expected):
|
|
125
|
+
raise AuthError("invalid channel token")
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Lightweight ASGI fallback used when FastAPI is unavailable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
from urllib.parse import parse_qs
|
|
12
|
+
|
|
13
|
+
from shopping_cli.api import auth as api_auth
|
|
14
|
+
from shopping_cli.api.limits import max_request_body_bytes
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_etag(content: str | bytes) -> str:
|
|
18
|
+
"""Compute a strong ETag (quoted content hash) for an HTTP response body.
|
|
19
|
+
|
|
20
|
+
Same semantics as the discovery cache module used before v3.0 (the Agent
|
|
21
|
+
Catalog subsystem moved to the standalone kiwi-catalog service): the same
|
|
22
|
+
content always yields the same ETag, and the value is opaque to clients.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(content, str):
|
|
25
|
+
content = content.encode("utf-8")
|
|
26
|
+
return f'"{hashlib.sha256(content).hexdigest()}"'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def etag_matches(if_none_match: str, etag: str) -> bool:
|
|
30
|
+
"""True when an ``If-None-Match`` header value matches *etag*.
|
|
31
|
+
|
|
32
|
+
Handles strong and weak ETags and the ``*`` wildcard. The header is
|
|
33
|
+
untrusted request data; every comparison is against the server's own
|
|
34
|
+
computed *etag*, so a malformed header simply never matches.
|
|
35
|
+
"""
|
|
36
|
+
expected = etag.strip('"')
|
|
37
|
+
for raw in if_none_match.split(","):
|
|
38
|
+
token = raw.strip()
|
|
39
|
+
if token == "*":
|
|
40
|
+
return True
|
|
41
|
+
if token.startswith("W/"):
|
|
42
|
+
token = token[2:].strip()
|
|
43
|
+
token = token.strip('"')
|
|
44
|
+
if token and token == expected:
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
HandleRequest = Callable[[str | Path, str, str, dict[str, Any] | None, dict[str, Any] | None], tuple[int, dict[str, Any]]]
|
|
50
|
+
RouteProvider = Callable[[], list[Any]]
|
|
51
|
+
RouteResolver = Callable[[str, str], tuple[bool, bool]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MarketplaceASGIApp:
|
|
55
|
+
title = "shopping-cli Marketplace API"
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
db_path: str | Path,
|
|
60
|
+
handle_request_fn: HandleRequest | None = None,
|
|
61
|
+
route_provider: RouteProvider | None = None,
|
|
62
|
+
route_resolver: RouteResolver | None = None,
|
|
63
|
+
):
|
|
64
|
+
self.state = SimpleNamespace(db_path=str(db_path), fastapi_available=False)
|
|
65
|
+
self._handle_request = handle_request_fn
|
|
66
|
+
self._route_provider = route_provider
|
|
67
|
+
self._route_resolver = route_resolver
|
|
68
|
+
self.routes = self._routes()
|
|
69
|
+
|
|
70
|
+
def _routes(self) -> list[Any]:
|
|
71
|
+
provider = self._route_provider
|
|
72
|
+
if provider is None:
|
|
73
|
+
from shopping_cli.api.route_registry import route_info
|
|
74
|
+
|
|
75
|
+
provider = route_info
|
|
76
|
+
return provider()
|
|
77
|
+
|
|
78
|
+
def _handler(self) -> HandleRequest:
|
|
79
|
+
if self._handle_request is None:
|
|
80
|
+
from shopping_cli.api.app import handle_request
|
|
81
|
+
|
|
82
|
+
self._handle_request = handle_request
|
|
83
|
+
return self._handle_request
|
|
84
|
+
|
|
85
|
+
def _resolver(self) -> RouteResolver:
|
|
86
|
+
if self._route_resolver is None:
|
|
87
|
+
if self._handle_request is not None:
|
|
88
|
+
# Custom handlers own their routing; stay permissive for them.
|
|
89
|
+
return lambda _method, _path: (True, True)
|
|
90
|
+
from shopping_cli.api.app import resolve_route
|
|
91
|
+
|
|
92
|
+
self._route_resolver = resolve_route
|
|
93
|
+
return self._route_resolver
|
|
94
|
+
|
|
95
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
96
|
+
if scope.get("type") != "http":
|
|
97
|
+
await send({"type": "http.response.start", "status": 404, "headers": [(b"content-type", b"application/json")]})
|
|
98
|
+
await send({"type": "http.response.body", "body": b'{"ok":false,"error":"unsupported scope"}'})
|
|
99
|
+
return
|
|
100
|
+
headers = {
|
|
101
|
+
key.decode("latin1").lower(): value.decode("latin1")
|
|
102
|
+
for key, value in scope.get("headers", [])
|
|
103
|
+
}
|
|
104
|
+
maximum = max_request_body_bytes()
|
|
105
|
+
try:
|
|
106
|
+
content_length = int(headers.get("content-length", "0") or 0)
|
|
107
|
+
except ValueError:
|
|
108
|
+
content_length = 0
|
|
109
|
+
if content_length > maximum:
|
|
110
|
+
await self._send_json(send, 413, {"ok": False, "error": "request body is too large"})
|
|
111
|
+
return
|
|
112
|
+
method = str(scope.get("method") or "GET").upper()
|
|
113
|
+
path = str(scope.get("path") or "/")
|
|
114
|
+
path_known, method_allowed = self._resolver()(method, path)
|
|
115
|
+
if not path_known:
|
|
116
|
+
await self._send_json(send, 404, {"ok": False, "error": f"No route for {method} {path}"})
|
|
117
|
+
return
|
|
118
|
+
if not method_allowed:
|
|
119
|
+
await self._send_json(send, 405, {"ok": False, "error": f"Method not allowed for {method} {path}"})
|
|
120
|
+
return
|
|
121
|
+
chunks: list[bytes] = []
|
|
122
|
+
body_size = 0
|
|
123
|
+
while True:
|
|
124
|
+
message = await receive()
|
|
125
|
+
chunk = message.get("body", b"")
|
|
126
|
+
body_size += len(chunk)
|
|
127
|
+
if body_size > maximum:
|
|
128
|
+
await self._send_json(send, 413, {"ok": False, "error": "request body is too large"})
|
|
129
|
+
return
|
|
130
|
+
chunks.append(chunk)
|
|
131
|
+
if not message.get("more_body", False):
|
|
132
|
+
break
|
|
133
|
+
try:
|
|
134
|
+
decoded_payload = json.loads(b"".join(chunks).decode("utf-8") or "{}")
|
|
135
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
136
|
+
body = json.dumps(
|
|
137
|
+
{"ok": False, "error": "invalid JSON request body"},
|
|
138
|
+
ensure_ascii=False,
|
|
139
|
+
sort_keys=True,
|
|
140
|
+
).encode("utf-8")
|
|
141
|
+
await send({"type": "http.response.start", "status": 400, "headers": [(b"content-type", b"application/json")]})
|
|
142
|
+
await send({"type": "http.response.body", "body": body})
|
|
143
|
+
return
|
|
144
|
+
if not isinstance(decoded_payload, dict):
|
|
145
|
+
body = json.dumps(
|
|
146
|
+
{"ok": False, "error": "JSON request body must be an object"},
|
|
147
|
+
ensure_ascii=False,
|
|
148
|
+
sort_keys=True,
|
|
149
|
+
).encode("utf-8")
|
|
150
|
+
await send({"type": "http.response.start", "status": 400, "headers": [(b"content-type", b"application/json")]})
|
|
151
|
+
await send({"type": "http.response.body", "body": body})
|
|
152
|
+
return
|
|
153
|
+
payload = decoded_payload
|
|
154
|
+
payload = api_auth.payload_with_auth(
|
|
155
|
+
payload,
|
|
156
|
+
authorization=headers.get("authorization", ""),
|
|
157
|
+
idempotency_key=headers.get("idempotency-key", ""),
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
raw_query = scope.get("query_string", b"").decode("utf-8")
|
|
161
|
+
except UnicodeDecodeError:
|
|
162
|
+
raw_query = ""
|
|
163
|
+
query = parse_qs(raw_query, keep_blank_values=True)
|
|
164
|
+
status, response = await asyncio.to_thread(
|
|
165
|
+
self._handler(),
|
|
166
|
+
self.state.db_path,
|
|
167
|
+
method,
|
|
168
|
+
path,
|
|
169
|
+
payload,
|
|
170
|
+
{key: values[-1] if values else "" for key, values in query.items()},
|
|
171
|
+
)
|
|
172
|
+
await self._send_json(
|
|
173
|
+
send,
|
|
174
|
+
status,
|
|
175
|
+
response,
|
|
176
|
+
if_none_match=headers.get("if-none-match", ""),
|
|
177
|
+
allow_304=method == "GET",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
async def _send_json(
|
|
182
|
+
send: Any,
|
|
183
|
+
status: int,
|
|
184
|
+
response: dict[str, Any],
|
|
185
|
+
*,
|
|
186
|
+
if_none_match: str = "",
|
|
187
|
+
allow_304: bool = False,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Serialize *response* with a §18 server-side ETag.
|
|
190
|
+
|
|
191
|
+
When the caller allows conditional GET and the client's
|
|
192
|
+
``If-None-Match`` matches the computed ETag, a body-less 304 is sent.
|
|
193
|
+
"""
|
|
194
|
+
body = json.dumps(response, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
195
|
+
etag = compute_etag(body)
|
|
196
|
+
# A conditional GET only revalidates a *successful* representation:
|
|
197
|
+
# never 304 an error body (e.g. a 404 for If-None-Match: *).
|
|
198
|
+
if allow_304 and status == 200 and if_none_match and etag_matches(if_none_match, etag):
|
|
199
|
+
await send(
|
|
200
|
+
{
|
|
201
|
+
"type": "http.response.start",
|
|
202
|
+
"status": 304,
|
|
203
|
+
"headers": [(b"etag", etag.encode("ascii"))],
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
await send({"type": "http.response.body", "body": b""})
|
|
207
|
+
return
|
|
208
|
+
await send(
|
|
209
|
+
{
|
|
210
|
+
"type": "http.response.start",
|
|
211
|
+
"status": status,
|
|
212
|
+
"headers": [
|
|
213
|
+
(b"content-type", b"application/json"),
|
|
214
|
+
(b"etag", etag.encode("ascii")),
|
|
215
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP handler modules for marketplace API domains."""
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Agent API handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from shopping_cli.agents import merchant_agent
|
|
10
|
+
from shopping_cli.api import auth as api_auth
|
|
11
|
+
from shopping_cli.api.handlers.common import (
|
|
12
|
+
DEFAULT_RESULT_LIMIT,
|
|
13
|
+
non_negative_whole_int,
|
|
14
|
+
positive_whole_int,
|
|
15
|
+
require_field,
|
|
16
|
+
result_limit,
|
|
17
|
+
result_offset,
|
|
18
|
+
)
|
|
19
|
+
from shopping_cli.config import agent_stale_ttl_seconds_from
|
|
20
|
+
from shopping_cli.core.conversations import conversation_summary
|
|
21
|
+
from shopping_cli.core.errors import AuthError, ValidationError
|
|
22
|
+
from shopping_cli.core.harness import (
|
|
23
|
+
abandon_agent_message,
|
|
24
|
+
abandon_stale_agent_messages,
|
|
25
|
+
agent_message_process_summary,
|
|
26
|
+
claim_agent_message,
|
|
27
|
+
complete_agent_message,
|
|
28
|
+
fail_agent_message,
|
|
29
|
+
)
|
|
30
|
+
from shopping_cli.db.session import db_session
|
|
31
|
+
from shopping_cli.services import agents as agent_service
|
|
32
|
+
from shopping_cli.services import tokens as token_service
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _payload_token(payload: dict[str, Any]) -> str:
|
|
36
|
+
return api_auth.payload_token(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _positive_whole_seconds(value: Any, field_name: str) -> int | None:
|
|
40
|
+
if value in (None, ""):
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
raise ValidationError(f"{field_name} must be a whole number")
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
seconds = value
|
|
46
|
+
elif isinstance(value, float):
|
|
47
|
+
if not math.isfinite(value) or not value.is_integer():
|
|
48
|
+
raise ValidationError(f"{field_name} must be a whole number")
|
|
49
|
+
seconds = int(value)
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
seconds = int(str(value).strip())
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise ValidationError(f"{field_name} must be a whole number") from exc
|
|
55
|
+
if seconds <= 0:
|
|
56
|
+
raise ValidationError(f"{field_name} must be greater than 0")
|
|
57
|
+
return seconds
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_merchant_agent_id(merchant_id: str) -> str:
|
|
61
|
+
return token_service.default_merchant_agent_id(merchant_id)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def agent_heartbeat(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
with db_session(db_path) as conn:
|
|
66
|
+
merchant_id = str(require_field(payload, "merchant_id"))
|
|
67
|
+
agent_id = default_merchant_agent_id(merchant_id)
|
|
68
|
+
token_service.require_agent_or_merchant_token(conn, merchant_id, agent_id, _payload_token(payload))
|
|
69
|
+
agent = merchant_agent.heartbeat(
|
|
70
|
+
conn,
|
|
71
|
+
merchant_id=merchant_id,
|
|
72
|
+
status=str(payload.get("status") or "online"),
|
|
73
|
+
capabilities=payload.get("capabilities"),
|
|
74
|
+
pid=non_negative_whole_int(payload.get("pid"), "pid"),
|
|
75
|
+
version=str(payload.get("version") or ""),
|
|
76
|
+
last_error=str(payload.get("last_error") or ""),
|
|
77
|
+
checked_count=non_negative_whole_int(payload.get("checked_count"), "checked_count"),
|
|
78
|
+
replied_count=non_negative_whole_int(payload.get("replied_count"), "replied_count"),
|
|
79
|
+
)
|
|
80
|
+
return {"ok": True, "agent": agent}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
with db_session(db_path) as conn:
|
|
85
|
+
merchant_id = str(require_field(payload, "merchant_id"))
|
|
86
|
+
token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
|
|
87
|
+
return agent_service.issue_agent_token_for_merchant(
|
|
88
|
+
conn,
|
|
89
|
+
merchant_id,
|
|
90
|
+
agent_id=str(payload.get("agent_id") or ""),
|
|
91
|
+
ttl_seconds=payload.get("ttl_seconds"),
|
|
92
|
+
positive_whole_seconds=_positive_whole_seconds,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def list_agent_tokens(
|
|
97
|
+
db_path: str | Path,
|
|
98
|
+
payload: dict[str, Any],
|
|
99
|
+
merchant_id: str = "",
|
|
100
|
+
limit: Any = DEFAULT_RESULT_LIMIT,
|
|
101
|
+
offset: Any = 0,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
with db_session(db_path) as conn:
|
|
104
|
+
resolved_merchant_id = str(merchant_id or payload.get("merchant_id") or "")
|
|
105
|
+
if not resolved_merchant_id:
|
|
106
|
+
raise ValidationError("merchant_id is required")
|
|
107
|
+
token_service.require_merchant_token(conn, resolved_merchant_id, _payload_token(payload))
|
|
108
|
+
return agent_service.list_agent_tokens(
|
|
109
|
+
conn,
|
|
110
|
+
resolved_merchant_id,
|
|
111
|
+
limit=result_limit(limit),
|
|
112
|
+
offset=result_offset(offset),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def revoke_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
with db_session(db_path) as conn:
|
|
118
|
+
merchant_id = str(require_field(payload, "merchant_id"))
|
|
119
|
+
token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
|
|
120
|
+
return agent_service.revoke_agent_token(
|
|
121
|
+
conn,
|
|
122
|
+
merchant_id,
|
|
123
|
+
token=payload.get("token"),
|
|
124
|
+
token_prefix=payload.get("token_prefix"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def rotate_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
with db_session(db_path) as conn:
|
|
130
|
+
merchant_id = str(require_field(payload, "merchant_id"))
|
|
131
|
+
token_service.require_merchant_token(conn, merchant_id, _payload_token(payload))
|
|
132
|
+
return agent_service.rotate_agent_token(
|
|
133
|
+
conn,
|
|
134
|
+
merchant_id,
|
|
135
|
+
token=payload.get("token"),
|
|
136
|
+
token_prefix=payload.get("token_prefix"),
|
|
137
|
+
ttl_seconds=payload.get("ttl_seconds"),
|
|
138
|
+
positive_whole_seconds=_positive_whole_seconds,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def require_agent_payload(conn: Any, payload: dict[str, Any]) -> tuple[str, str]:
|
|
143
|
+
merchant_id = str(require_field(payload, "merchant_id"))
|
|
144
|
+
agent_id = str(payload.get("agent_id") or default_merchant_agent_id(merchant_id))
|
|
145
|
+
token_service.require_agent_or_merchant_token(conn, merchant_id, agent_id, _payload_token(payload))
|
|
146
|
+
return merchant_id, agent_id
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def require_agent_conversation(conn: Any, merchant_id: str, conversation_id: str) -> dict[str, Any]:
|
|
150
|
+
conversation = conversation_summary(conn, conversation_id)
|
|
151
|
+
if conversation["merchant_id"] != merchant_id:
|
|
152
|
+
raise AuthError(f"Merchant {merchant_id} cannot access conversation {conversation_id}")
|
|
153
|
+
return conversation
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def require_message_in_conversation(conn: Any, conversation_id: str, message_id: int) -> None:
|
|
157
|
+
agent_service.validate_buyer_message_for_claim(conn, conversation_id, message_id)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def require_agent_process_scope(conn: Any, merchant_id: str, agent_id: str, message_id: int) -> dict[str, Any]:
|
|
161
|
+
process = agent_message_process_summary(conn, agent_id, message_id)
|
|
162
|
+
require_agent_conversation(conn, merchant_id, process["conversation_id"])
|
|
163
|
+
return process
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def claim_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
with db_session(db_path) as conn:
|
|
168
|
+
merchant_id, agent_id = require_agent_payload(conn, payload)
|
|
169
|
+
conversation_id = str(require_field(payload, "conversation_id"))
|
|
170
|
+
require_agent_conversation(conn, merchant_id, conversation_id)
|
|
171
|
+
message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
|
|
172
|
+
require_message_in_conversation(conn, conversation_id, message_id)
|
|
173
|
+
# 回合/乱序防护(与 negotiation claim 一致):只能 claim 处于
|
|
174
|
+
# waiting_merchant 且是**最新**买家消息——否则回答会落在旧问题上,
|
|
175
|
+
# 更新的买家消息还会被再次派发(跨消息跳答)。
|
|
176
|
+
conversation = conversation_summary(conn, conversation_id)
|
|
177
|
+
if str(conversation["status"] or "") != "waiting_merchant":
|
|
178
|
+
raise ConflictError(
|
|
179
|
+
f"Conversation {conversation_id} is not waiting for the merchant agent "
|
|
180
|
+
f"(status={conversation['status']!r})"
|
|
181
|
+
)
|
|
182
|
+
latest_row = conn.execute(
|
|
183
|
+
"select id from messages where conversation_id = ? and sender = 'buyer' "
|
|
184
|
+
"order by id desc limit 1",
|
|
185
|
+
(conversation_id,),
|
|
186
|
+
).fetchone()
|
|
187
|
+
if latest_row is None or int(latest_row["id"]) != message_id:
|
|
188
|
+
raise ConflictError(f"Message {message_id} is not the latest buyer message")
|
|
189
|
+
claim = claim_agent_message(
|
|
190
|
+
conn,
|
|
191
|
+
agent_id,
|
|
192
|
+
conversation_id,
|
|
193
|
+
message_id,
|
|
194
|
+
str(require_field(payload, "idempotency_key")),
|
|
195
|
+
)
|
|
196
|
+
return {"ok": True, "claim": claim}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def complete_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
with db_session(db_path) as conn:
|
|
201
|
+
merchant_id, agent_id = require_agent_payload(conn, payload)
|
|
202
|
+
message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
|
|
203
|
+
require_agent_process_scope(conn, merchant_id, agent_id, message_id)
|
|
204
|
+
process = complete_agent_message(conn, agent_id, message_id)
|
|
205
|
+
return {"ok": True, "process": process}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def fail_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
209
|
+
with db_session(db_path) as conn:
|
|
210
|
+
merchant_id, agent_id = require_agent_payload(conn, payload)
|
|
211
|
+
message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
|
|
212
|
+
require_agent_process_scope(conn, merchant_id, agent_id, message_id)
|
|
213
|
+
process = fail_agent_message(conn, agent_id, message_id, str(payload.get("error") or "agent failure"))
|
|
214
|
+
return {"ok": True, "process": process}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def abandon_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
with db_session(db_path) as conn:
|
|
219
|
+
merchant_id, agent_id = require_agent_payload(conn, payload)
|
|
220
|
+
message_id = positive_whole_int(require_field(payload, "message_id"), "message_id")
|
|
221
|
+
require_agent_process_scope(conn, merchant_id, agent_id, message_id)
|
|
222
|
+
process = abandon_agent_message(conn, agent_id, message_id, str(payload.get("error") or "agent abandoned claim"))
|
|
223
|
+
return {"ok": True, "process": process}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def abandon_stale_messages(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
with db_session(db_path) as conn:
|
|
228
|
+
_merchant_id, agent_id = require_agent_payload(conn, payload)
|
|
229
|
+
abandoned = abandon_stale_agent_messages(
|
|
230
|
+
conn,
|
|
231
|
+
agent_id,
|
|
232
|
+
stale_after_seconds=(
|
|
233
|
+
_positive_whole_seconds(payload.get("stale_after_seconds", 300), "stale_after_seconds") or 300
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
return {"ok": True, "abandoned": abandoned}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def list_agents(
|
|
240
|
+
db_path: str | Path,
|
|
241
|
+
payload: dict[str, Any],
|
|
242
|
+
owner_id: str = "",
|
|
243
|
+
limit: Any = DEFAULT_RESULT_LIMIT,
|
|
244
|
+
offset: Any = 0,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
with db_session(db_path) as conn:
|
|
247
|
+
scoped_owner_id = str(owner_id or "")
|
|
248
|
+
if scoped_owner_id:
|
|
249
|
+
token_service.require_merchant_read_token(conn, scoped_owner_id, _payload_token(payload))
|
|
250
|
+
else:
|
|
251
|
+
token_row = token_service.require_api_token(conn, _payload_token(payload), "agent read token required")
|
|
252
|
+
if token_row["role"] not in {"merchant", "agent"} or not token_row["merchant_id"]:
|
|
253
|
+
raise AuthError("invalid agent read token")
|
|
254
|
+
scoped_owner_id = str(token_row["merchant_id"])
|
|
255
|
+
return {
|
|
256
|
+
"ok": True,
|
|
257
|
+
"agents": agent_service.list_agent_summaries(
|
|
258
|
+
conn,
|
|
259
|
+
owner_id=scoped_owner_id,
|
|
260
|
+
limit=result_limit(limit),
|
|
261
|
+
offset=result_offset(offset),
|
|
262
|
+
include_stale=True,
|
|
263
|
+
stale_ttl_seconds=agent_stale_ttl_seconds_from(),
|
|
264
|
+
),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_agent(db_path: str | Path, agent_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
with db_session(db_path) as conn:
|
|
270
|
+
summary = agent_service.get_agent_summary(
|
|
271
|
+
conn,
|
|
272
|
+
agent_id,
|
|
273
|
+
include_stale=True,
|
|
274
|
+
stale_ttl_seconds=agent_stale_ttl_seconds_from(),
|
|
275
|
+
)
|
|
276
|
+
token_service.require_merchant_read_token(conn, summary["owner_id"], _payload_token(payload))
|
|
277
|
+
return {"ok": True, "agent": summary}
|