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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Buyer and merchant agent helpers."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Console entry point for running a resident merchant agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from shopping_cli.agents import merchant_agent, merchant_daemon
|
|
9
|
+
from shopping_cli.cli_common import emit
|
|
10
|
+
from shopping_cli.config import DEFAULT_DB_PATH
|
|
11
|
+
from shopping_cli.db.session import db_session
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(description="Run a resident shopping-cli merchant agent.")
|
|
16
|
+
parser.add_argument("--db", default=str(DEFAULT_DB_PATH), help="SQLite database path")
|
|
17
|
+
parser.add_argument("--merchant", required=True)
|
|
18
|
+
parser.add_argument("--once", action="store_true")
|
|
19
|
+
parser.add_argument("--interval", type=float, default=3.0)
|
|
20
|
+
parser.add_argument("--format", choices=["text", "json"], default="text")
|
|
21
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
22
|
+
if not args.once:
|
|
23
|
+
merchant_daemon.run_forever(args.db, args.merchant, interval=args.interval)
|
|
24
|
+
return
|
|
25
|
+
with db_session(args.db) as conn:
|
|
26
|
+
result = merchant_agent.process_once(conn, args.merchant)
|
|
27
|
+
emit(result, args.format)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Deterministic buyer-side consultation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from shopping_cli.core.catalog import product_summary, search_products
|
|
9
|
+
from shopping_cli.core.conversations import append_message, conversation_summary, ensure_conversation
|
|
10
|
+
from shopping_cli.core.errors import NotFoundError, ValidationError
|
|
11
|
+
from shopping_cli.core.risk import infer_intent
|
|
12
|
+
|
|
13
|
+
MVP_WARNINGS = [
|
|
14
|
+
"MVP records consultation only; no order is created.",
|
|
15
|
+
"No stock is reserved by shopping-cli.",
|
|
16
|
+
"Payment, refund, escrow, and delivery-success handling are outside this version.",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def status_guidance(conversation: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
status = str(conversation.get("status") or "")
|
|
22
|
+
if status == "waiting_merchant":
|
|
23
|
+
return {
|
|
24
|
+
"pending": True,
|
|
25
|
+
"next_action": "Wait for merchant agent response.",
|
|
26
|
+
"status_hint": "merchant_agent_pending",
|
|
27
|
+
}
|
|
28
|
+
if status == "human_required":
|
|
29
|
+
return {
|
|
30
|
+
"pending": True,
|
|
31
|
+
"next_action": "Wait for merchant human review.",
|
|
32
|
+
"status_hint": "merchant_human_pending",
|
|
33
|
+
}
|
|
34
|
+
if status == "waiting_buyer":
|
|
35
|
+
return {
|
|
36
|
+
"pending": False,
|
|
37
|
+
"next_action": "Review the merchant reply and continue the consultation if needed.",
|
|
38
|
+
"status_hint": "buyer_turn",
|
|
39
|
+
}
|
|
40
|
+
if status == "closed":
|
|
41
|
+
return {
|
|
42
|
+
"pending": False,
|
|
43
|
+
"next_action": "Conversation is closed.",
|
|
44
|
+
"status_hint": "closed",
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
"pending": False,
|
|
48
|
+
"next_action": "Continue the consultation without creating orders or payments.",
|
|
49
|
+
"status_hint": "open",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def status_warnings(conversation: dict[str, Any]) -> list[str]:
|
|
54
|
+
status = str(conversation.get("status") or "")
|
|
55
|
+
if status == "waiting_merchant":
|
|
56
|
+
return ["Merchant agent response is pending; keep the conversation open."]
|
|
57
|
+
if status == "human_required":
|
|
58
|
+
return ["Merchant human review is required before any commitment."]
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ask(
|
|
63
|
+
conn: sqlite3.Connection,
|
|
64
|
+
buyer_id: str,
|
|
65
|
+
text: str,
|
|
66
|
+
city: str = "",
|
|
67
|
+
area: str = "",
|
|
68
|
+
limit: int = 3,
|
|
69
|
+
source_id: str = "buyer-cli",
|
|
70
|
+
host: str = "",
|
|
71
|
+
session_id: str = "",
|
|
72
|
+
reuse_open: bool = True,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
buyer_id = str(buyer_id or "").strip()
|
|
75
|
+
if not buyer_id:
|
|
76
|
+
raise ValidationError("buyer id is required")
|
|
77
|
+
candidates = search_products(conn, query=text, city=city, area=area, limit=limit)
|
|
78
|
+
if not candidates:
|
|
79
|
+
return {
|
|
80
|
+
"ok": True,
|
|
81
|
+
"buyer_id": buyer_id,
|
|
82
|
+
"candidates": [],
|
|
83
|
+
"conversation": None,
|
|
84
|
+
"warnings": ["No matching merchant or product found.", *MVP_WARNINGS],
|
|
85
|
+
"missing_facts": ["merchant", "product"],
|
|
86
|
+
}
|
|
87
|
+
selected = candidates[0]
|
|
88
|
+
conversation = ensure_conversation(conn, buyer_id, selected["merchant_id"], selected["sku"], reuse_open=reuse_open)
|
|
89
|
+
message = append_message(
|
|
90
|
+
conn,
|
|
91
|
+
conversation["id"],
|
|
92
|
+
"buyer",
|
|
93
|
+
infer_intent(text),
|
|
94
|
+
text,
|
|
95
|
+
structured_payload={
|
|
96
|
+
"city": city,
|
|
97
|
+
"area": area,
|
|
98
|
+
"selected_sku": selected["sku"],
|
|
99
|
+
"source_id": source_id or "buyer-cli",
|
|
100
|
+
"host": host or "",
|
|
101
|
+
"session_id": session_id or "",
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
summary = conversation_summary(conn, conversation["id"])
|
|
105
|
+
return {
|
|
106
|
+
"ok": True,
|
|
107
|
+
"buyer_id": buyer_id,
|
|
108
|
+
"candidates": candidates,
|
|
109
|
+
"selected": selected,
|
|
110
|
+
"conversation": summary,
|
|
111
|
+
"message": message,
|
|
112
|
+
"warnings": [*MVP_WARNINGS, *status_warnings(summary)],
|
|
113
|
+
**status_guidance(summary),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def summarize(conn: sqlite3.Connection, conversation_id: str) -> dict[str, Any]:
|
|
118
|
+
conversation = conversation_summary(conn, conversation_id)
|
|
119
|
+
option = conversation.get("product")
|
|
120
|
+
if option is None and conversation.get("sku"):
|
|
121
|
+
try:
|
|
122
|
+
option = product_summary(conn, conversation["sku"])
|
|
123
|
+
except NotFoundError:
|
|
124
|
+
option = None
|
|
125
|
+
missing_facts: list[str] = []
|
|
126
|
+
warnings = list(MVP_WARNINGS)
|
|
127
|
+
if option is None:
|
|
128
|
+
missing_facts.append("product")
|
|
129
|
+
else:
|
|
130
|
+
if not option["merchant"].get("contact"):
|
|
131
|
+
missing_facts.append("merchant contact")
|
|
132
|
+
if not option["delivery"].get("service_area"):
|
|
133
|
+
missing_facts.append("delivery rule")
|
|
134
|
+
if option["stock"] <= 0:
|
|
135
|
+
warnings.append("Product is out of stock.")
|
|
136
|
+
warnings.extend(status_warnings(conversation))
|
|
137
|
+
for flag in conversation["flags"]:
|
|
138
|
+
warnings.append(f"Human review flag: {flag['reason']}")
|
|
139
|
+
guidance = status_guidance(conversation)
|
|
140
|
+
return {
|
|
141
|
+
"ok": True,
|
|
142
|
+
"conversation": conversation,
|
|
143
|
+
"option": option,
|
|
144
|
+
"missing_facts": missing_facts,
|
|
145
|
+
"warnings": warnings,
|
|
146
|
+
**guidance,
|
|
147
|
+
"no_order_created": True,
|
|
148
|
+
"no_stock_reserved": True,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def record_intent(conn: sqlite3.Connection, conversation_id: str, intent: str, text: str) -> dict[str, Any]:
|
|
153
|
+
if intent not in {"purchase_intent", "quote_request"}:
|
|
154
|
+
raise ValidationError("--intent must be purchase_intent or quote_request")
|
|
155
|
+
message = append_message(conn, conversation_id, "buyer", intent, text, structured_payload={"source_id": "buyer-cli"})
|
|
156
|
+
conversation = conversation_summary(conn, conversation_id)
|
|
157
|
+
return {
|
|
158
|
+
"ok": True,
|
|
159
|
+
"message": message,
|
|
160
|
+
"conversation": conversation,
|
|
161
|
+
"warnings": [*MVP_WARNINGS, *status_warnings(conversation)],
|
|
162
|
+
**status_guidance(conversation),
|
|
163
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Resident deterministic merchant agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from shopping_cli import VERSION
|
|
11
|
+
from shopping_cli.agents.buyer_cli import MVP_WARNINGS
|
|
12
|
+
from shopping_cli.agents.tools import MerchantAgentTools, SQLiteMerchantAgentTools, record_heartbeat
|
|
13
|
+
from shopping_cli.core.errors import NotFoundError
|
|
14
|
+
from shopping_cli.core.harness import MAX_STALE_TTL_SECONDS, message_idempotency_key
|
|
15
|
+
from shopping_cli.core.risk import human_review_reason
|
|
16
|
+
from shopping_cli.core.limits import safe_non_negative_float as _safe_non_negative_float, safe_non_negative_int as _safe_non_negative_int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _positive_message_id(value: Any) -> int:
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
raise ValueError("buyer message id must be a positive integer")
|
|
22
|
+
try:
|
|
23
|
+
message_id = int(value)
|
|
24
|
+
except (OverflowError, TypeError, ValueError) as exc:
|
|
25
|
+
raise ValueError("buyer message id must be a positive integer") from exc
|
|
26
|
+
if message_id <= 0:
|
|
27
|
+
raise ValueError("buyer message id must be a positive integer")
|
|
28
|
+
return message_id
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _claim_ttl_seconds_from_env() -> int:
|
|
32
|
+
try:
|
|
33
|
+
seconds = int(os.environ.get("SHOPPING_AGENT_CLAIM_TTL_SECONDS") or "300")
|
|
34
|
+
except (OverflowError, TypeError, ValueError):
|
|
35
|
+
return 300
|
|
36
|
+
return seconds if 0 < seconds <= MAX_STALE_TTL_SECONDS else 300
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def heartbeat(
|
|
40
|
+
conn: sqlite3.Connection,
|
|
41
|
+
merchant_id: str,
|
|
42
|
+
status: str = "online",
|
|
43
|
+
capabilities: list[str] | None = None,
|
|
44
|
+
pid: int = 0,
|
|
45
|
+
version: str = VERSION,
|
|
46
|
+
last_error: str = "",
|
|
47
|
+
checked_count: int = 0,
|
|
48
|
+
replied_count: int = 0,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
return record_heartbeat(
|
|
51
|
+
conn,
|
|
52
|
+
merchant_id,
|
|
53
|
+
status=status,
|
|
54
|
+
capabilities=capabilities,
|
|
55
|
+
pid=pid,
|
|
56
|
+
version=version,
|
|
57
|
+
last_error=last_error,
|
|
58
|
+
checked_count=checked_count,
|
|
59
|
+
replied_count=replied_count,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def latest_buyer_message(conversation: dict[str, Any]) -> dict[str, Any] | None:
|
|
64
|
+
for message in reversed(conversation.get("messages", [])):
|
|
65
|
+
if message["sender"] == "buyer":
|
|
66
|
+
return message
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_agent_reply_after(conversation: dict[str, Any], buyer_message_id: int) -> bool:
|
|
71
|
+
for message in conversation.get("messages", []):
|
|
72
|
+
try:
|
|
73
|
+
message_id = _positive_message_id(message.get("id"))
|
|
74
|
+
except ValueError:
|
|
75
|
+
continue
|
|
76
|
+
if message_id <= buyer_message_id:
|
|
77
|
+
continue
|
|
78
|
+
if message["sender"] in {"merchant_agent", "merchant"}:
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _automation_boundaries(product: dict[str, Any]) -> str:
|
|
84
|
+
merchant_value = product.get("merchant")
|
|
85
|
+
merchant = cast(dict[str, Any], merchant_value) if isinstance(merchant_value, dict) else {}
|
|
86
|
+
return str(merchant.get("automation_boundaries") or product.get("automation_boundaries") or "").strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _authorized_bargain_amount(product: dict[str, Any]) -> str:
|
|
90
|
+
boundaries = _automation_boundaries(product)
|
|
91
|
+
if not boundaries:
|
|
92
|
+
return ""
|
|
93
|
+
lower = boundaries.lower()
|
|
94
|
+
if not any(term in lower or term in boundaries for term in ("discount", "lowest", "final price", "砍价", "优惠", "实付", "成交")):
|
|
95
|
+
return ""
|
|
96
|
+
title = str(product.get("title") or "").strip()
|
|
97
|
+
sku = str(product.get("sku") or "").strip()
|
|
98
|
+
segments: list[str] = []
|
|
99
|
+
for marker in (title, sku):
|
|
100
|
+
if marker and marker in boundaries:
|
|
101
|
+
start = boundaries.find(marker)
|
|
102
|
+
segments.append(boundaries[start : start + 140])
|
|
103
|
+
if "两款均适用" in boundaries or "all products" in lower:
|
|
104
|
+
segments.append(boundaries)
|
|
105
|
+
patterns = [
|
|
106
|
+
r"(?:实付|最低可成交价|最低价|成交价)[^\d]{0,16}(\d+(?:\.\d+)?)\s*(?:元|cny|CNY)?",
|
|
107
|
+
r"(?:lowest price|final price)[^\d]{0,16}(\d+(?:\.\d+)?)\s*(?:cny|CNY|元)?",
|
|
108
|
+
r"(\d+(?:\.\d+)?)\s*(?:元|cny|CNY)[^\n。;;]{0,16}(?:成交|可成交)",
|
|
109
|
+
]
|
|
110
|
+
for segment in segments:
|
|
111
|
+
for pattern in patterns:
|
|
112
|
+
match = re.search(pattern, segment, flags=re.IGNORECASE)
|
|
113
|
+
if match:
|
|
114
|
+
return match.group(1)
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_reply(
|
|
119
|
+
tools: MerchantAgentTools,
|
|
120
|
+
conversation: dict[str, Any],
|
|
121
|
+
buyer_message: dict[str, Any],
|
|
122
|
+
) -> tuple[str, bool, str]:
|
|
123
|
+
product = None
|
|
124
|
+
if conversation.get("sku"):
|
|
125
|
+
try:
|
|
126
|
+
product = tools.product_summary(conversation["sku"])
|
|
127
|
+
except (NotFoundError, SystemExit):
|
|
128
|
+
product = None
|
|
129
|
+
reason = human_review_reason(buyer_message["text"], product_found=product is not None)
|
|
130
|
+
if not reason and buyer_message.get("intent") in {"negotiate", "quote_request"}:
|
|
131
|
+
reason = "bargaining"
|
|
132
|
+
disclaimer = " ".join(MVP_WARNINGS)
|
|
133
|
+
if product is None:
|
|
134
|
+
return f"I need a merchant human to confirm which product this consultation refers to. {disclaimer}", True, reason
|
|
135
|
+
delivery = product["delivery"]
|
|
136
|
+
price = _safe_non_negative_float(product.get("price"))
|
|
137
|
+
stock = _safe_non_negative_int(product.get("stock"))
|
|
138
|
+
if not reason and stock <= 2:
|
|
139
|
+
reason = "low_stock"
|
|
140
|
+
if not reason and buyer_message["intent"] == "ask_delivery" and not delivery.get("service_area"):
|
|
141
|
+
reason = "unclear_delivery"
|
|
142
|
+
delivery_text = "delivery rule is missing"
|
|
143
|
+
if delivery.get("service_area"):
|
|
144
|
+
delivery_text = (
|
|
145
|
+
f"delivery area {delivery['service_area']}, ETA {_safe_non_negative_int(delivery.get('eta_minutes'))} minutes, "
|
|
146
|
+
f"fee {_safe_non_negative_float(delivery.get('fee')):.2f} {delivery['currency']}"
|
|
147
|
+
)
|
|
148
|
+
if reason == "bargaining":
|
|
149
|
+
# 议价底价是隐私——negotiation 路径以 _leaks_private_threshold 守护,
|
|
150
|
+
# resident 若直接报价(_authorized_bargain_amount)会把授权底价写进
|
|
151
|
+
# 公开会话。一致处理:议价请求一律路由人工审查,不自动报价。
|
|
152
|
+
return (
|
|
153
|
+
f"{product['title']} needs merchant human review for price negotiation. "
|
|
154
|
+
f"Catalog price {price:.2f} {product['currency']}, stock {stock}; {delivery_text}. {disclaimer}",
|
|
155
|
+
True,
|
|
156
|
+
"bargaining",
|
|
157
|
+
)
|
|
158
|
+
if reason:
|
|
159
|
+
return (
|
|
160
|
+
f"{product['title']} is listed at {price:.2f} {product['currency']} with "
|
|
161
|
+
f"{stock} in stock. This request needs merchant human review because: {reason}. {disclaimer}",
|
|
162
|
+
True,
|
|
163
|
+
reason,
|
|
164
|
+
)
|
|
165
|
+
return (
|
|
166
|
+
f"{product['title']} has stock {stock} and current price "
|
|
167
|
+
f"{price:.2f} {product['currency']}; {delivery_text}. {disclaimer}",
|
|
168
|
+
False,
|
|
169
|
+
"",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def process_once_with_tools(tools: MerchantAgentTools, merchant_id: str) -> dict[str, Any]:
|
|
174
|
+
agent = tools.heartbeat(merchant_id)
|
|
175
|
+
abandoned: list[dict[str, Any]] = []
|
|
176
|
+
if hasattr(tools, "abandon_stale_messages"):
|
|
177
|
+
abandoned = tools.abandon_stale_messages(agent["id"], stale_after_seconds=_claim_ttl_seconds_from_env())
|
|
178
|
+
conversations = tools.waiting_merchant_conversations(merchant_id)
|
|
179
|
+
replied: list[dict[str, Any]] = []
|
|
180
|
+
failed: list[dict[str, Any]] = []
|
|
181
|
+
for conversation in conversations:
|
|
182
|
+
buyer_message = latest_buyer_message(conversation)
|
|
183
|
+
if buyer_message is None:
|
|
184
|
+
continue
|
|
185
|
+
# Negotiation-protocol 消息由 kiwi merchant 运行时经 negotiation API
|
|
186
|
+
# 驱动——resident 抢 claim 会打断买家的谈判回合(协商方 claim 失败),
|
|
187
|
+
# 且 generate_reply 曾把授权底价写进公开会话。协商消息一律跳过。
|
|
188
|
+
if (buyer_message.get("structured_payload") or {}).get("protocol_version"):
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
buyer_message_id = _positive_message_id(buyer_message.get("id"))
|
|
192
|
+
except ValueError as exc:
|
|
193
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
194
|
+
tools.heartbeat(
|
|
195
|
+
merchant_id,
|
|
196
|
+
status="online",
|
|
197
|
+
last_error=error,
|
|
198
|
+
checked_count=len(conversations),
|
|
199
|
+
replied_count=len(replied),
|
|
200
|
+
)
|
|
201
|
+
failed.append({"conversation_id": conversation["id"], "message_id": 0, "error": error})
|
|
202
|
+
continue
|
|
203
|
+
if has_agent_reply_after(conversation, buyer_message_id):
|
|
204
|
+
continue
|
|
205
|
+
idempotency_key = message_idempotency_key(agent["id"], buyer_message_id)
|
|
206
|
+
claim = tools.claim_message(agent["id"], conversation["id"], buyer_message_id, idempotency_key)
|
|
207
|
+
if not claim.get("claimed"):
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
reply, needs_human, reason = generate_reply(tools, conversation, buyer_message)
|
|
211
|
+
status = "human_required" if needs_human else "waiting_buyer"
|
|
212
|
+
message = tools.append_message(
|
|
213
|
+
conversation["id"],
|
|
214
|
+
"merchant_agent",
|
|
215
|
+
buyer_message["intent"],
|
|
216
|
+
reply,
|
|
217
|
+
structured_payload={
|
|
218
|
+
"human_required": needs_human,
|
|
219
|
+
"reason": reason,
|
|
220
|
+
"source_id": agent["id"],
|
|
221
|
+
"processed_message_id": buyer_message_id,
|
|
222
|
+
"idempotency_key": idempotency_key,
|
|
223
|
+
},
|
|
224
|
+
status=status,
|
|
225
|
+
)
|
|
226
|
+
tools.complete_message(agent["id"], buyer_message_id)
|
|
227
|
+
flags = []
|
|
228
|
+
if needs_human:
|
|
229
|
+
flags.append(tools.add_flag(conversation["id"], reason or "human_required", sku=conversation.get("sku", "")))
|
|
230
|
+
tools.heartbeat(merchant_id, status="human_required")
|
|
231
|
+
replied.append(
|
|
232
|
+
{
|
|
233
|
+
"conversation_id": conversation["id"],
|
|
234
|
+
"message_id": message["id"],
|
|
235
|
+
"human_required": needs_human,
|
|
236
|
+
"reason": reason,
|
|
237
|
+
"flags": flags,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
except Exception as exc: # pragma: no cover - exercised through fake tools
|
|
241
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
242
|
+
tools.fail_message(agent["id"], buyer_message_id, error)
|
|
243
|
+
tools.heartbeat(
|
|
244
|
+
merchant_id,
|
|
245
|
+
status="online",
|
|
246
|
+
last_error=error,
|
|
247
|
+
checked_count=len(conversations),
|
|
248
|
+
replied_count=len(replied),
|
|
249
|
+
)
|
|
250
|
+
failed.append({"conversation_id": conversation["id"], "message_id": buyer_message_id, "error": error})
|
|
251
|
+
return {
|
|
252
|
+
"ok": True,
|
|
253
|
+
"merchant_id": merchant_id,
|
|
254
|
+
"agent": agent,
|
|
255
|
+
"checked": len(conversations),
|
|
256
|
+
"replied": replied,
|
|
257
|
+
"failed": failed,
|
|
258
|
+
"abandoned": abandoned,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def process_once(conn: sqlite3.Connection, merchant_id: str) -> dict[str, Any]:
|
|
263
|
+
return process_once_with_tools(SQLiteMerchantAgentTools(conn), merchant_id)
|