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