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,127 @@
1
+ """ERP 同步 API handler(shopping-cli v0.3 §3/#3;MVP #3 接线)。
2
+
3
+ POST /v1/merchant/erp/sync —— admin 或 merchant token 触发的 ERP 商品同步
4
+ (写面鉴权与 /products 一致;结果 = ErpSyncReport,含 fetched/upserted/
5
+ conflicts/errors;网络/结构错误 fail-closed 返回 200+ok:false 信封)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from shopping_cli.api import auth as api_auth
17
+ from shopping_cli.core.catalog import require_merchant
18
+ from shopping_cli.core.errors import PermissionDenied, RateLimitError, ValidationError
19
+ from shopping_cli.data_sources.erp_source import (
20
+ ErpSourceError,
21
+ ErpSyncConfig,
22
+ sync_erp_products,
23
+ )
24
+ from shopping_cli.db.session import db_session
25
+ from shopping_cli.services import tokens as token_service
26
+
27
+ # per-merchant 同步限流:每次同步含无界网络拉取(有分页上限兜底),并发
28
+ # 同步还会竞争同一批行(last-write-wins)。进程内固定窗口即可——单进程
29
+ # 部署(FastAPI threadpool + fallback ASGI 同一进程)。
30
+ _ERP_SYNC_RATE_LIMIT_PER_MINUTE = 3
31
+ _ERP_SYNC_WINDOW_SECONDS = 60
32
+ _erp_sync_lock = threading.Lock()
33
+ _erp_sync_buckets: dict[str, tuple[float, int]] = {}
34
+
35
+
36
+ def _enforce_erp_sync_rate_limit(actor_key: str) -> None:
37
+ now = time.monotonic()
38
+ with _erp_sync_lock:
39
+ window_start, count = _erp_sync_buckets.get(actor_key, (0.0, 0))
40
+ if now - window_start >= _ERP_SYNC_WINDOW_SECONDS:
41
+ window_start, count = now, 0
42
+ if count >= _ERP_SYNC_RATE_LIMIT_PER_MINUTE:
43
+ raise RateLimitError(
44
+ f"erp sync rate limit exceeded ({_ERP_SYNC_RATE_LIMIT_PER_MINUTE}/minute)"
45
+ )
46
+ _erp_sync_buckets[actor_key] = (window_start, count + 1)
47
+
48
+
49
+ def _bounded_int(payload: dict[str, Any], key: str, default: int, lo: int, hi: int) -> int:
50
+ """读整数参数;非数字输入 fail-closed 抛 ValidationError(不落到 500)。"""
51
+ raw = payload.get(key)
52
+ if raw is None or raw == "":
53
+ return default
54
+ try:
55
+ value = int(raw)
56
+ except (TypeError, ValueError) as exc:
57
+ raise ValidationError(f"{key} must be an integer") from exc
58
+ return max(lo, min(value, hi))
59
+
60
+
61
+ def sync_erp(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
62
+ """POST /v1/merchant/erp/sync —— 手动触发 ERP 同步(同步本身可重复执行)。"""
63
+ base_url = str(payload.get("base_url") or "").strip()
64
+ if not base_url:
65
+ raise ValidationError("base_url is required")
66
+
67
+ with db_session(db_path) as conn:
68
+ # 鉴权:admin token 或 merchant token(写面,与 /products 一致)。
69
+ # admin 判定先行(仅探测 token 有效性,无副作用),再进入各自分支,
70
+ # 避免 admin 分支的校验错误被误吞进 merchant 分支。
71
+ try:
72
+ api_auth.require_admin_token(payload)
73
+ is_admin = True
74
+ except Exception:
75
+ is_admin = False
76
+
77
+ actor: str
78
+ allowed_merchant_id = ""
79
+ default_merchant_id = str(payload.get("default_merchant_id") or "").strip()
80
+ if is_admin:
81
+ actor = "admin"
82
+ # admin 可任意指定默认归属,但必须真实存在(FK 防护,避免裸
83
+ # IntegrityError 落到 500)。
84
+ if default_merchant_id:
85
+ require_merchant(conn, default_merchant_id)
86
+ else:
87
+ merchant_id = str(payload.get("merchant_id") or "").strip()
88
+ if not merchant_id:
89
+ raise PermissionDenied("merchant_id (or admin_token) is required for ERP sync")
90
+ token_service.require_merchant_token(
91
+ conn, merchant_id, api_auth.payload_token(payload)
92
+ )
93
+ require_merchant(conn, merchant_id)
94
+ actor = f"merchant:{merchant_id}"
95
+ # 跨租户防护:merchant-token 调用者只能写入自己名下的行;调用方
96
+ # 传入其他 default_merchant_id 直接拒绝(fail-closed,不静默覆盖)。
97
+ if default_merchant_id and default_merchant_id != merchant_id:
98
+ raise ValidationError(
99
+ f"merchant token callers may only sync into their own merchant "
100
+ f"({merchant_id}); got default_merchant_id={default_merchant_id!r}"
101
+ )
102
+ default_merchant_id = merchant_id
103
+ allowed_merchant_id = merchant_id
104
+
105
+ # per-actor 限流:每个 merchant/admin 每分钟最多 3 次同步。
106
+ _enforce_erp_sync_rate_limit(actor)
107
+
108
+ config = ErpSyncConfig(
109
+ base_url=base_url,
110
+ auth_token=str(payload.get("auth_token") or "").strip(),
111
+ timeout_seconds=_bounded_int(payload, "timeout_seconds", 15, 1, 60),
112
+ page_size=_bounded_int(payload, "page_size", 100, 1, 500),
113
+ default_merchant_id=default_merchant_id,
114
+ allowed_merchant_id=allowed_merchant_id,
115
+ )
116
+ try:
117
+ report = sync_erp_products(conn, config)
118
+ except (ErpSourceError, OSError) as exc:
119
+ # ERP 网络/结构失败统一 fail-closed 信封(200 + ok:false)
120
+ return {"ok": False, "error": str(exc), "actor": actor}
121
+ except sqlite3.IntegrityError as exc:
122
+ # 数据完整性失败兜底:不落到 500,转为 4xx。
123
+ raise ValidationError(f"ERP sync failed integrity check: {exc}") from exc
124
+ result = report.as_dict()
125
+ result["ok"] = not result["errors"] and not result["conflicts"]
126
+ result["actor"] = actor
127
+ return result
@@ -0,0 +1,293 @@
1
+ """Human review 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, positive_whole_int, result_limit, result_offset
10
+ from shopping_cli.core.conversations import add_flag, append_message, conversation_summary
11
+ from shopping_cli.core.errors import AuthError, ConflictError, NotFoundError
12
+ from shopping_cli.core.harness import append_audit_event, next_actor_for_review_reason, next_actor_for_status
13
+ from shopping_cli.db.session import db_session, now_iso
14
+ from shopping_cli.services import conversations as conversation_service
15
+ from shopping_cli.services import human_review as human_review_service
16
+ from shopping_cli.services import tokens as token_service
17
+
18
+
19
+ def _payload_token(payload: dict[str, Any]) -> str:
20
+ return api_auth.payload_token(payload)
21
+
22
+
23
+ def human_review_sender(payload: dict[str, Any]) -> str:
24
+ return human_review_service.human_review_sender(payload)
25
+
26
+
27
+ def review_summary(conn: Any, flag_row: Any) -> dict[str, Any]:
28
+ row_keys = set(flag_row.keys()) if hasattr(flag_row, "keys") else set()
29
+ if {"merchant_id", "buyer_id"}.issubset(row_keys):
30
+ merchant_id = flag_row["merchant_id"]
31
+ buyer_id = flag_row["buyer_id"]
32
+ else:
33
+ conversation = conversation_summary(conn, flag_row["conversation_id"])
34
+ merchant_id = conversation["merchant_id"]
35
+ buyer_id = conversation["buyer_id"]
36
+ return {
37
+ "id": flag_row["id"],
38
+ "conversation_id": flag_row["conversation_id"],
39
+ "merchant_id": merchant_id,
40
+ "buyer_id": buyer_id,
41
+ "sku": flag_row["sku"],
42
+ "reason": flag_row["reason"],
43
+ "severity": flag_row["severity"],
44
+ "created_at": flag_row["created_at"],
45
+ "resolved_at": flag_row["resolved_at"] or None,
46
+ "resolution": flag_row["resolution"],
47
+ "resolved_by": flag_row["resolved_by"],
48
+ }
49
+
50
+
51
+ def human_review_queue(
52
+ db_path: str | Path,
53
+ payload: dict[str, Any],
54
+ merchant_id: str = "",
55
+ limit: Any = DEFAULT_RESULT_LIMIT,
56
+ offset: Any = 0,
57
+ ) -> dict[str, Any]:
58
+ if not merchant_id:
59
+ raise AuthError("merchant_id is required for human-review queue")
60
+ with db_session(db_path) as conn:
61
+ token_service.require_merchant_read_token(conn, merchant_id, _payload_token(payload))
62
+ rows = human_review_service.list_unresolved_reviews(
63
+ conn,
64
+ merchant_id,
65
+ limit=result_limit(limit),
66
+ offset=result_offset(offset),
67
+ )
68
+ return {"ok": True, "reviews": [review_summary(conn, row) for row in rows]}
69
+
70
+
71
+ def human_review_row(conn: Any, review_id: str | int) -> Any:
72
+ return human_review_service.human_review_row(conn, review_id, positive_whole_int)
73
+
74
+
75
+ def get_human_review(db_path: str | Path, review_id: str | int, payload: dict[str, Any]) -> dict[str, Any]:
76
+ with db_session(db_path) as conn:
77
+ row = human_review_row(conn, review_id)
78
+ review = review_summary(conn, row)
79
+ token_service.require_merchant_read_token(conn, review["merchant_id"], _payload_token(payload))
80
+ return {"ok": True, "review": review, "conversation": conversation_summary(conn, review["conversation_id"])}
81
+
82
+
83
+ def create_human_review(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
84
+ with db_session(db_path) as conn:
85
+ conversation = conversation_summary(conn, conversation_id)
86
+ token_row = token_service.require_api_token(conn, _payload_token(payload), "merchant or agent token required")
87
+ if token_row["merchant_id"] != conversation["merchant_id"] or token_row["role"] not in {"merchant", "agent"}:
88
+ raise AuthError("invalid merchant or agent token")
89
+ actor = str(token_row["agent_id"] or token_row["merchant_id"])
90
+ review = add_flag(
91
+ conn,
92
+ conversation_id,
93
+ reason=str(payload.get("reason") or "human_required"),
94
+ severity=str(payload.get("severity") or "review"),
95
+ sku=conversation.get("sku") or "",
96
+ )
97
+ # add_flag already transitions the conversation to human_required with
98
+ # a rowcount-guarded UPDATE — no need for a separate status update here.
99
+ append_audit_event(
100
+ conn,
101
+ conversation_id,
102
+ actor,
103
+ "conversation_routed",
104
+ {"status": "human_required", "next_actor": next_actor_for_status("human_required", review["reason"]), "reason": review["reason"]},
105
+ )
106
+ row = human_review_service.human_review_row(conn, review["id"], positive_whole_int)
107
+ return {
108
+ "ok": True,
109
+ "review": review_summary(conn, row),
110
+ "conversation": conversation_summary(conn, conversation_id),
111
+ }
112
+
113
+
114
+ def _require_resolver_identity(
115
+ conn: Any,
116
+ conversation: dict[str, Any],
117
+ reasons: list[str],
118
+ payload: dict[str, Any],
119
+ ) -> str:
120
+ """H7: operator 路由的 flag(suspicious_content)只能由 admin 身份销案。
121
+
122
+ merchant 是"被审查方"——用自家 merchant token 自销 operator 仲裁的
123
+ flag 会完全绕过人工审查控制。普通 flag 仍走 merchant token。
124
+ 返回解析者 actor 标识。
125
+ """
126
+ if any(
127
+ next_actor_for_review_reason(str(reason or "")) == "operator" for reason in reasons
128
+ ):
129
+ api_auth.require_admin_token(payload)
130
+ return "admin"
131
+ token_row = token_service.require_merchant_token(conn, conversation["merchant_id"], _payload_token(payload))
132
+ return str(token_row["agent_id"] or token_row["merchant_id"]) if token_row is not None else conversation["merchant_id"]
133
+
134
+
135
+ def resolve_human_review_item(db_path: str | Path, review_id: str | int, payload: dict[str, Any]) -> dict[str, Any]:
136
+ action = human_review_service.validate_action(str(payload.get("action") or "reply"))
137
+ sender = human_review_sender(payload)
138
+ with db_session(db_path) as conn:
139
+ row = human_review_row(conn, review_id)
140
+ if row["resolved_at"]:
141
+ raise ConflictError(f"Human review already resolved: {review_id}")
142
+ conversation_id = row["conversation_id"]
143
+ conversation = conversation_summary(conn, conversation_id)
144
+ actor = _require_resolver_identity(conn, conversation, [str(row["reason"] or "")], payload)
145
+ if conversation["status"] == "closed":
146
+ raise ConflictError(f"Conversation {conversation_id} is closed")
147
+ now = now_iso()
148
+ rowcount = human_review_service.resolve_review(
149
+ conn,
150
+ int(review_id),
151
+ action=action,
152
+ sender=actor,
153
+ now=now,
154
+ )
155
+ if rowcount != 1:
156
+ raise ConflictError(f"Human review already resolved: {review_id}")
157
+ remaining_reasons = human_review_service.remaining_unresolved_reviews(conn, conversation_id)
158
+ remaining = len(remaining_reasons)
159
+ remaining_reason = remaining_reasons[0] if remaining_reasons else ""
160
+ status = "human_required" if remaining else ("closed" if action == "close" else "waiting_buyer")
161
+ status_reason = remaining_reason if status == "human_required" else str(row["reason"] or "")
162
+ next_actor = next_actor_for_status(status, status_reason if status == "human_required" else "")
163
+ if payload.get("text"):
164
+ append_message(
165
+ conn,
166
+ conversation_id,
167
+ sender=sender,
168
+ intent=str(payload.get("intent") or "support"),
169
+ text=str(payload["text"]),
170
+ structured_payload={
171
+ "resolution": action,
172
+ "source_id": actor,
173
+ "review_id": int(review_id),
174
+ "reason": status_reason,
175
+ "resolved_reason": row["reason"],
176
+ },
177
+ status=status,
178
+ )
179
+ else:
180
+ human_review_service.update_conversation_status(
181
+ conn,
182
+ conversation_id,
183
+ status=status,
184
+ next_actor=next_actor,
185
+ sender=actor,
186
+ expected_status="human_required",
187
+ # 非 human_required 目标态必须无未决 flag(防并发 add_flag
188
+ # 竞态把会话带 flag 推进到 waiting_buyer/closed);human_required
189
+ # 时剩余未决 flag 是正常状态,不能 reject。
190
+ reject_if_unresolved=(status != "human_required"),
191
+ now=now,
192
+ )
193
+ append_audit_event(
194
+ conn,
195
+ conversation_id,
196
+ actor,
197
+ "human_review_resolved",
198
+ {
199
+ "review_id": int(review_id),
200
+ "resolution": action,
201
+ "status": status,
202
+ "next_actor": next_actor,
203
+ "remaining_unresolved_reviews": int(remaining or 0),
204
+ },
205
+ )
206
+ if status == "closed":
207
+ conversation_service.append_conversation_closed_audit(
208
+ conn,
209
+ conversation_id,
210
+ actor,
211
+ next_actor,
212
+ {"resolution": action, "review_id": int(review_id), "source": "human_review"},
213
+ )
214
+ review = review_summary(conn, human_review_row(conn, review_id))
215
+ rows = human_review_service.list_conversation_reviews(conn, conversation_id)
216
+ return {
217
+ "ok": True,
218
+ "review": review,
219
+ "reviews": [review_summary(conn, row) for row in rows],
220
+ "conversation": conversation_summary(conn, conversation_id),
221
+ }
222
+
223
+
224
+ def resolve_human_review(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
225
+ action = human_review_service.validate_action(str(payload.get("action") or "reply"))
226
+ sender = human_review_sender(payload)
227
+ status = "closed" if action == "close" else "waiting_buyer"
228
+ with db_session(db_path) as conn:
229
+ conversation = conversation_summary(conn, conversation_id)
230
+ unresolved_reasons = [
231
+ str(r["reason"] or "")
232
+ for r in human_review_service.list_conversation_reviews(conn, conversation_id)
233
+ if not str(r["resolved_at"] or "")
234
+ ]
235
+ actor = _require_resolver_identity(conn, conversation, unresolved_reasons, payload)
236
+ if conversation["status"] == "closed":
237
+ raise ConflictError(f"Conversation {conversation_id} is closed")
238
+ now = now_iso()
239
+ rowcount = human_review_service.resolve_all_conversation_reviews(
240
+ conn,
241
+ conversation_id,
242
+ action=action,
243
+ sender=actor,
244
+ now=now,
245
+ )
246
+ if rowcount == 0:
247
+ raise NotFoundError(f"No unresolved human reviews for conversation: {conversation_id}")
248
+ next_actor = next_actor_for_status(status)
249
+ if payload.get("text"):
250
+ append_message(
251
+ conn,
252
+ conversation_id,
253
+ sender=sender,
254
+ intent=str(payload.get("intent") or "support"),
255
+ text=str(payload["text"]),
256
+ structured_payload={"resolution": action, "source_id": actor},
257
+ status=status,
258
+ )
259
+ else:
260
+ human_review_service.update_conversation_status(
261
+ conn,
262
+ conversation_id,
263
+ status=status,
264
+ next_actor=next_actor,
265
+ sender=actor,
266
+ expected_status="human_required",
267
+ # 非 human_required 目标态必须无未决 flag(防并发 add_flag
268
+ # 竞态把会话带 flag 推进到 waiting_buyer/closed);human_required
269
+ # 时剩余未决 flag 是正常状态,不能 reject。
270
+ reject_if_unresolved=(status != "human_required"),
271
+ now=now,
272
+ )
273
+ append_audit_event(
274
+ conn,
275
+ conversation_id,
276
+ actor,
277
+ "human_review_resolved",
278
+ {"resolution": action, "status": status, "next_actor": next_actor},
279
+ )
280
+ if status == "closed":
281
+ conversation_service.append_conversation_closed_audit(
282
+ conn,
283
+ conversation_id,
284
+ actor,
285
+ next_actor,
286
+ {"resolution": action, "source": "human_review"},
287
+ )
288
+ rows = human_review_service.list_conversation_reviews(conn, conversation_id)
289
+ return {
290
+ "ok": True,
291
+ "reviews": [review_summary(conn, row) for row in rows],
292
+ "conversation": conversation_summary(conn, conversation_id),
293
+ }
@@ -0,0 +1,42 @@
1
+ """Listing 投影只读 API(shopping-cli v0.3 §14;Merchant Kiwi-compatible)。
2
+
3
+ - GET /v1/merchant/listings/projections —— 可发布投影列表(public-only);
4
+ - GET /v1/merchant/listings/{sku}/projection —— 单条投影。
5
+
6
+ 公开读免鉴权(沿用 products 现状);发布动作只经 CLI 不进 API(评审:
7
+ 写面保持 push-first 单向,API 不暴露发布副作用)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from shopping_cli.core.errors import NotFoundError, ValidationError
16
+ from shopping_cli.db.session import db_session
17
+ from shopping_cli.listings.projection import (
18
+ ProjectionError,
19
+ list_publishable_listings,
20
+ project_product_listing,
21
+ )
22
+
23
+
24
+ def list_listing_projections(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
25
+ """GET /v1/merchant/listings/projections —— 可发布投影(public-only)。"""
26
+ merchant_id = str(query.get("merchant_id") or "").strip()
27
+ with db_session(db_path) as conn:
28
+ projections = list_publishable_listings(conn, merchant_id=merchant_id)
29
+ return {"ok": True, "results": projections, "count": len(projections)}
30
+
31
+
32
+ def get_listing_projection(db_path: str | Path, sku: str) -> dict[str, Any]:
33
+ """GET /v1/merchant/listings/{sku}/projection —— 单条投影。"""
34
+ sku = str(sku).strip()
35
+ if not sku:
36
+ raise ValidationError("sku is required")
37
+ with db_session(db_path) as conn:
38
+ try:
39
+ projection = project_product_listing(conn, sku)
40
+ except ProjectionError as exc:
41
+ raise NotFoundError(str(exc)) from exc
42
+ return {"ok": True, "projection": projection}
@@ -0,0 +1,121 @@
1
+ """shopping.negotiation/0.1 API handlers.
2
+
3
+ Thin adapters over services.negotiation; both the FastAPI app and the
4
+ fallback ASGI router dispatch here, so no business logic is duplicated.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from shopping_cli.api import auth as api_auth
13
+ from shopping_cli.api.handlers.common import positive_whole_int, require_field
14
+ from shopping_cli.core import negotiation as protocol
15
+ from shopping_cli.db.session import db_session
16
+ from shopping_cli.services import negotiation as negotiation_service
17
+
18
+
19
+ def _actor(conn: Any, payload: dict[str, Any]) -> negotiation_service.NegotiationActor:
20
+ return negotiation_service.require_negotiation_actor(conn, api_auth.payload_token(payload))
21
+
22
+
23
+ def capabilities(db_path: str | Path) -> dict[str, Any]:
24
+ return {"ok": True, "capabilities": protocol.capabilities_report()}
25
+
26
+
27
+ def pending_messages(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
28
+ with db_session(db_path) as conn:
29
+ actor = _actor(conn, payload)
30
+ pending = negotiation_service.list_pending_messages(conn, actor)
31
+ return {"ok": True, "role": actor.role, "owner_id": actor.owner_id, "pending": pending}
32
+
33
+
34
+ def claim_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
35
+ with db_session(db_path) as conn:
36
+ actor = _actor(conn, payload)
37
+ claim = negotiation_service.claim_message(
38
+ conn,
39
+ actor,
40
+ str(require_field(payload, "conversation_id")),
41
+ positive_whole_int(require_field(payload, "message_id"), "message_id"),
42
+ str(require_field(payload, "idempotency_key")),
43
+ )
44
+ return {"ok": True, "claim": claim}
45
+
46
+
47
+ def get_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str, Any] | None = None) -> dict[str, Any]:
48
+ query = query or {}
49
+ with db_session(db_path) as conn:
50
+ actor = _actor(conn, payload)
51
+ snapshot = negotiation_service.build_snapshot(
52
+ conn,
53
+ actor,
54
+ str(query.get("conversation_id") or require_field(payload, "conversation_id")),
55
+ positive_whole_int(query.get("message_id") or require_field(payload, "message_id"), "message_id"),
56
+ )
57
+ return {"ok": True, "snapshot": snapshot}
58
+
59
+
60
+ def submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
61
+ with db_session(db_path) as conn:
62
+ actor = _actor(conn, payload)
63
+ policy_result = negotiation_service.submit_decision(
64
+ conn,
65
+ actor,
66
+ require_field(payload, "decision"),
67
+ str(require_field(payload, "idempotency_key")),
68
+ )
69
+ return {"ok": True, "policy_result": policy_result}
70
+
71
+
72
+ def complete_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
73
+ with db_session(db_path) as conn:
74
+ actor = _actor(conn, payload)
75
+ process = negotiation_service.complete_claim(
76
+ conn, actor, positive_whole_int(require_field(payload, "message_id"), "message_id")
77
+ )
78
+ return {"ok": True, "process": process}
79
+
80
+
81
+ def fail_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
82
+ with db_session(db_path) as conn:
83
+ actor = _actor(conn, payload)
84
+ process = negotiation_service.fail_claim(
85
+ conn,
86
+ actor,
87
+ positive_whole_int(require_field(payload, "message_id"), "message_id"),
88
+ str(payload.get("error") or "agent failure"),
89
+ )
90
+ return {"ok": True, "process": process}
91
+
92
+
93
+ def abandon_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
94
+ with db_session(db_path) as conn:
95
+ actor = _actor(conn, payload)
96
+ process = negotiation_service.abandon_claim(
97
+ conn,
98
+ actor,
99
+ positive_whole_int(require_field(payload, "message_id"), "message_id"),
100
+ str(payload.get("error") or "agent abandoned claim"),
101
+ )
102
+ return {"ok": True, "process": process}
103
+
104
+
105
+ def heartbeat_claims(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
106
+ with db_session(db_path) as conn:
107
+ actor = _actor(conn, payload)
108
+ message_id = payload.get("message_id")
109
+ heartbeat = negotiation_service.heartbeat_claims(
110
+ conn,
111
+ actor,
112
+ None if message_id is None else positive_whole_int(message_id, "message_id"),
113
+ )
114
+ return {"ok": True, "heartbeat": heartbeat}
115
+
116
+
117
+ def abandon_stale_claims(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
118
+ with db_session(db_path) as conn:
119
+ actor = _actor(conn, payload)
120
+ stale = negotiation_service.abandon_stale_claims(conn, actor, payload.get("ttl_seconds"))
121
+ return {"ok": True, "stale": stale}