shopping-cli 3.2.0__tar.gz → 3.2.3__tar.gz
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-3.2.0/shopping_cli.egg-info → shopping_cli-3.2.3}/PKG-INFO +15 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/README.md +14 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/pyproject.toml +1 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/__init__.py +1 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/negotiation.py +13 -8
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli.py +2 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_adapter_commands.py +7 -2
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_common.py +10 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/kiwi-contracts.lock.json +1 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/channels.py +15 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/csv_excel_source.py +121 -14
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/migrations.py +25 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/models.py +11 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/listings/projection.py +7 -20
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation.py +57 -2
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/tokens.py +1 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3/shopping_cli.egg-info}/PKG-INFO +15 -1
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/LICENSE +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/MANIFEST.in +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/setup.cfg +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/agent_cli.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/buyer_cli.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_agent.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_daemon.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_state_helpers.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/tools.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/app.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/auth.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/error_handlers.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/error_response.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/fallback_asgi.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/fastapi_routes.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/agents.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/audit.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/buyer.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/catalog.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/common.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/conversations.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/erp.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/human_review.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/listings_projection.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/idempotency.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/limits.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/request_dispatch.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/request_limits.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_matching.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_registry.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_table.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_agents.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_conversations.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_marketplace.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_merchants.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/server.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_agent_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_buyer_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_catalog_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_conversation_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_erp_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_listing_commands.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/adapters.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/data_source.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/config.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_scoring.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_text.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_views.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/conversations.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/delivery.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/errors.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/harness.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/limits.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/negotiation.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/negotiation_contracts.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/policies.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/risk.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/tokens.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/adapter.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/erp_source.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/provenance.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/session.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/http_client.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/listings/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/__init__.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/agents.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/audit.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/buyer_bootstrap.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/conversations.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/human_review.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_gates.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_message.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_result.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_snapshot.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/SOURCES.txt +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/dependency_links.txt +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/entry_points.txt +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/requires.txt +0 -0
- {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shopping-cli
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.3
|
|
4
4
|
Summary: Standalone local-commerce AI consultation marketplace runtime
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -310,6 +310,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
|
|
|
310
310
|
|
|
311
311
|
Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
|
|
312
312
|
|
|
313
|
+
For a single-merchant deployment, import commands can read the database and
|
|
314
|
+
merchant identity from the environment instead of repeating tenant flags:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
|
|
318
|
+
export SHOPPING_MERCHANT_ID="your-merchant-id"
|
|
319
|
+
|
|
320
|
+
shopping-cli import-csv-excel --file products.csv
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`KIWI_MERCHANT_ID` is accepted as a compatibility alias for
|
|
324
|
+
`SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
|
|
325
|
+
precedence over environment variables.
|
|
326
|
+
|
|
313
327
|
## Channel Ingress
|
|
314
328
|
|
|
315
329
|
External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
|
|
@@ -87,6 +87,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
|
|
|
87
87
|
|
|
88
88
|
Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
|
|
89
89
|
|
|
90
|
+
For a single-merchant deployment, import commands can read the database and
|
|
91
|
+
merchant identity from the environment instead of repeating tenant flags:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
|
|
95
|
+
export SHOPPING_MERCHANT_ID="your-merchant-id"
|
|
96
|
+
|
|
97
|
+
shopping-cli import-csv-excel --file products.csv
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`KIWI_MERCHANT_ID` is accepted as a compatibility alias for
|
|
101
|
+
`SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
|
|
102
|
+
precedence over environment variables.
|
|
103
|
+
|
|
90
104
|
## Channel Ingress
|
|
91
105
|
|
|
92
106
|
External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
|
|
@@ -20,9 +20,13 @@ from shopping_cli.db.session import db_session
|
|
|
20
20
|
from shopping_cli.services import negotiation as negotiation_service
|
|
21
21
|
|
|
22
22
|
# 审查 S-M2:merchant/agent/negotiation/human-review 端点此前均无限流——已泄露/
|
|
23
|
-
# 低熵 token
|
|
24
|
-
#
|
|
25
|
-
#
|
|
23
|
+
# 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加固定窗口
|
|
24
|
+
# 限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖(调用时
|
|
25
|
+
# 读取,便于测试注入)。
|
|
26
|
+
# 审查 S-M2 补强:限流键从 per-owner(buyer 的 owner_id 客户端声明,轮换
|
|
27
|
+
# buyer_id 即绕过)改为 per-token(token_hash,服务端派生)——同凭证无论声明
|
|
28
|
+
# 什么 buyer_id 都共享一个桶;轮换新 token 受 S-M1 bootstrap 全局桶(铸造速率)
|
|
29
|
+
# 封顶。merchant/agent 单 token 部署下语义不变。
|
|
26
30
|
_NEGOTIATION_DECISION_WINDOW_SECONDS = 60
|
|
27
31
|
_DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE = 600
|
|
28
32
|
_decision_buckets: dict[str, tuple[float, int]] = {}
|
|
@@ -38,18 +42,18 @@ def _decision_rate_limit_per_minute() -> int:
|
|
|
38
42
|
return value if value > 0 else _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE
|
|
39
43
|
|
|
40
44
|
|
|
41
|
-
def _enforce_decision_rate_limit(
|
|
45
|
+
def _enforce_decision_rate_limit(credential_key: str) -> None:
|
|
42
46
|
limit = _decision_rate_limit_per_minute()
|
|
43
47
|
now = time.monotonic()
|
|
44
48
|
with _decision_buckets_lock:
|
|
45
|
-
window_start, count = _decision_buckets.get(
|
|
49
|
+
window_start, count = _decision_buckets.get(credential_key, (0.0, 0))
|
|
46
50
|
if now - window_start >= _NEGOTIATION_DECISION_WINDOW_SECONDS:
|
|
47
51
|
window_start, count = now, 0
|
|
48
52
|
if count >= limit:
|
|
49
53
|
raise RateLimitError(
|
|
50
54
|
f"negotiation decision rate limit exceeded ({limit}/minute)"
|
|
51
55
|
)
|
|
52
|
-
_decision_buckets[
|
|
56
|
+
_decision_buckets[credential_key] = (window_start, count + 1)
|
|
53
57
|
|
|
54
58
|
|
|
55
59
|
def _actor(conn: Any, payload: dict[str, Any]) -> negotiation_service.NegotiationActor:
|
|
@@ -96,8 +100,9 @@ def get_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str,
|
|
|
96
100
|
def submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
97
101
|
with db_session(db_path) as conn:
|
|
98
102
|
actor = _actor(conn, payload)
|
|
99
|
-
# 审查 S-M2
|
|
100
|
-
|
|
103
|
+
# 审查 S-M2:固定窗口限流(决策提交=磋商消息追加,滥用最直接)。键用
|
|
104
|
+
# 服务端派生的 token_hash(per-token),空值兜底 owner_id。
|
|
105
|
+
_enforce_decision_rate_limit(actor.token_hash or actor.owner_id)
|
|
101
106
|
policy_result = negotiation_service.submit_decision(
|
|
102
107
|
conn,
|
|
103
108
|
actor,
|
|
@@ -867,7 +867,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
867
867
|
"import-csv-excel", help="Import product facts from CSV / Excel (.xlsx) into the local store"
|
|
868
868
|
)
|
|
869
869
|
import_csv.add_argument("--file", required=True, help="Path to .csv / .tsv / .xlsx file")
|
|
870
|
-
import_csv.add_argument("--merchant", default="", dest="merchant", help="Owning merchant id (
|
|
870
|
+
import_csv.add_argument("--merchant", default="", dest="merchant", help="Owning merchant id (or SHOPPING_MERCHANT_ID / KIWI_MERCHANT_ID)")
|
|
871
|
+
import_csv.add_argument("--default-merchant", default="", dest="default_merchant", help=argparse.SUPPRESS)
|
|
871
872
|
import_csv.add_argument("--allowed-merchant", default="", dest="allowed_merchant", help="Tenant boundary: only rows of this merchant are written")
|
|
872
873
|
import_csv.add_argument("--format", choices=["text", "json"], default="text")
|
|
873
874
|
import_csv.set_defaults(func=cmd_import_csv_excel)
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
|
+
import os
|
|
10
11
|
|
|
11
12
|
from shopping_cli.cli_common import db_path_from_args, emit
|
|
12
13
|
from shopping_cli.data_sources import csv_excel_source # noqa: F401 # 导入即注册适配器
|
|
13
14
|
from shopping_cli.data_sources.adapter import (
|
|
14
|
-
AdapterError,
|
|
15
15
|
SyncContext,
|
|
16
16
|
registered_adapters,
|
|
17
17
|
run,
|
|
@@ -30,7 +30,12 @@ def cmd_adapters_list(args: argparse.Namespace) -> None:
|
|
|
30
30
|
def cmd_import_csv_excel(args: argparse.Namespace) -> None:
|
|
31
31
|
"""运行 CSV/Excel 适配器导入本地 products 表。"""
|
|
32
32
|
db_path = db_path_from_args(args)
|
|
33
|
-
merchant_id =
|
|
33
|
+
merchant_id = (
|
|
34
|
+
str(args.merchant or "").strip()
|
|
35
|
+
or str(getattr(args, "default_merchant", "")).strip()
|
|
36
|
+
or str(os.environ.get("SHOPPING_MERCHANT_ID") or "").strip()
|
|
37
|
+
or str(os.environ.get("KIWI_MERCHANT_ID") or "").strip()
|
|
38
|
+
)
|
|
34
39
|
if not merchant_id:
|
|
35
40
|
raise SystemExit("--merchant is required (FK 归属防护)")
|
|
36
41
|
with db_session(db_path) as conn:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import math
|
|
8
|
+
import os
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -132,4 +133,12 @@ def tcp_port(value: str) -> int:
|
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
def db_path_from_args(args: argparse.Namespace) -> Path:
|
|
135
|
-
|
|
136
|
+
configured = (
|
|
137
|
+
getattr(args, "agent_db", None)
|
|
138
|
+
or getattr(args, "db", None)
|
|
139
|
+
or getattr(args, "data", None)
|
|
140
|
+
or os.environ.get("SHOPPING_DB_PATH")
|
|
141
|
+
or os.environ.get("SHOPPING_DB")
|
|
142
|
+
or DEFAULT_DB_PATH
|
|
143
|
+
)
|
|
144
|
+
return Path(configured).expanduser()
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"lock_version": 1,
|
|
3
3
|
"source_repository": "harrylabsj/kiwi",
|
|
4
4
|
"source_commit": "009bc25cdae2668c8fd19ccedcc4e0c64a34f6be",
|
|
5
|
-
"bundle_sha256": "
|
|
5
|
+
"bundle_sha256": "2350ecaacebb791cd980cc2886511359e356378a6f18cdecc1ac6331c31b0e70",
|
|
6
6
|
"contracts_manifest": "contracts/manifest.json"
|
|
7
7
|
}
|
|
@@ -168,7 +168,7 @@ def _begin_channel_ingress(
|
|
|
168
168
|
return _existing_ingress_response(conn, row, buyer_id, channel)
|
|
169
169
|
# stale_at 非空 → 落 INSERT ON CONFLICT 覆盖旧行(重处理幂等)
|
|
170
170
|
try:
|
|
171
|
-
conn.execute(
|
|
171
|
+
cursor = conn.execute(
|
|
172
172
|
"""
|
|
173
173
|
insert into channel_message_ingresses(
|
|
174
174
|
channel, external_user_id, external_message_id, status,
|
|
@@ -181,6 +181,7 @@ def _begin_channel_ingress(
|
|
|
181
181
|
message_id = excluded.message_id,
|
|
182
182
|
stale_at = excluded.stale_at,
|
|
183
183
|
updated_at = excluded.updated_at
|
|
184
|
+
where channel_message_ingresses.stale_at != ''
|
|
184
185
|
""",
|
|
185
186
|
(channel, external_user_id, external_message_id, PROCESSING_STATUS, now, now),
|
|
186
187
|
)
|
|
@@ -195,6 +196,19 @@ def _begin_channel_ingress(
|
|
|
195
196
|
if row is not None:
|
|
196
197
|
return _existing_ingress_response(conn, row, buyer_id, channel)
|
|
197
198
|
raise
|
|
199
|
+
if cursor.rowcount == 0:
|
|
200
|
+
# 并发重复:另一请求已先落 fresh processing 行(stale_at 为空),DO UPDATE
|
|
201
|
+
# 被 WHERE 跳过、rowcount=0 → 我们未抢到写权,回查走冲突/幂等分支。
|
|
202
|
+
row = conn.execute(
|
|
203
|
+
"""
|
|
204
|
+
select * from channel_message_ingresses
|
|
205
|
+
where channel = ? and external_user_id = ? and external_message_id = ?
|
|
206
|
+
""",
|
|
207
|
+
(channel, external_user_id, external_message_id),
|
|
208
|
+
).fetchone()
|
|
209
|
+
if row is not None:
|
|
210
|
+
return _existing_ingress_response(conn, row, buyer_id, channel)
|
|
211
|
+
raise
|
|
198
212
|
return None
|
|
199
213
|
|
|
200
214
|
|
|
@@ -23,7 +23,7 @@ import math
|
|
|
23
23
|
import sqlite3
|
|
24
24
|
import zipfile
|
|
25
25
|
import xml.etree.ElementTree as ET
|
|
26
|
-
from dataclasses import dataclass
|
|
26
|
+
from dataclasses import dataclass
|
|
27
27
|
from datetime import datetime, timedelta, timezone
|
|
28
28
|
from decimal import Decimal, InvalidOperation
|
|
29
29
|
from pathlib import Path
|
|
@@ -45,6 +45,17 @@ SOURCE_CSV_EXCEL = "csv_excel"
|
|
|
45
45
|
|
|
46
46
|
_XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
47
47
|
|
|
48
|
+
# Import limits are deliberately conservative: CSV/XLSX are operator-supplied
|
|
49
|
+
# files, so parsing must not turn an accidental or malicious workbook into an
|
|
50
|
+
# unbounded memory/CPU allocation (zip bombs, giant shared strings, or millions
|
|
51
|
+
# of rows). These are wire/data-source limits, not database limits.
|
|
52
|
+
MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024
|
|
53
|
+
MAX_IMPORT_ROWS = 100_000
|
|
54
|
+
MAX_IMPORT_COLUMNS = 256
|
|
55
|
+
MAX_IMPORT_CELL_CHARS = 16_384
|
|
56
|
+
MAX_XLSX_ENTRIES = 256
|
|
57
|
+
MAX_XLSX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
|
|
58
|
+
|
|
48
59
|
|
|
49
60
|
@dataclass(frozen=True)
|
|
50
61
|
class CsvExcelSyncConfig:
|
|
@@ -75,6 +86,12 @@ def read_rows(path: str) -> list[dict[str, str]]:
|
|
|
75
86
|
p = Path(path)
|
|
76
87
|
if not p.is_file():
|
|
77
88
|
raise AdapterError(f"file not found: {path}")
|
|
89
|
+
try:
|
|
90
|
+
size = p.stat().st_size
|
|
91
|
+
except OSError as exc:
|
|
92
|
+
raise AdapterError(f"cannot stat import file: {path}") from exc
|
|
93
|
+
if size > MAX_IMPORT_FILE_BYTES:
|
|
94
|
+
raise AdapterError(f"import file exceeds {MAX_IMPORT_FILE_BYTES} bytes")
|
|
78
95
|
if p.suffix.lower() == ".xlsx":
|
|
79
96
|
return _read_xlsx_rows(p)
|
|
80
97
|
if p.suffix.lower() in {".csv", ".tsv", ""}:
|
|
@@ -85,33 +102,97 @@ def read_rows(path: str) -> list[dict[str, str]]:
|
|
|
85
102
|
def _read_csv_rows(path: Path) -> list[dict[str, str]]:
|
|
86
103
|
try:
|
|
87
104
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
105
|
+
# csv's parser otherwise accepts arbitrarily large individual fields.
|
|
106
|
+
csv.field_size_limit(MAX_IMPORT_CELL_CHARS)
|
|
88
107
|
reader = csv.DictReader(f)
|
|
89
|
-
|
|
108
|
+
header = reader.fieldnames
|
|
109
|
+
if header is None:
|
|
110
|
+
return []
|
|
111
|
+
if len(header) > MAX_IMPORT_COLUMNS:
|
|
112
|
+
raise AdapterError(f"CSV has more than {MAX_IMPORT_COLUMNS} columns")
|
|
113
|
+
if any(len(str(name or "")) > MAX_IMPORT_CELL_CHARS for name in header):
|
|
114
|
+
raise AdapterError(f"CSV header exceeds {MAX_IMPORT_CELL_CHARS} characters")
|
|
115
|
+
rows: list[dict[str, str]] = []
|
|
116
|
+
for index, row in enumerate(reader, start=1):
|
|
117
|
+
if index > MAX_IMPORT_ROWS:
|
|
118
|
+
raise AdapterError(f"CSV exceeds {MAX_IMPORT_ROWS} data rows")
|
|
119
|
+
if len(row) > MAX_IMPORT_COLUMNS:
|
|
120
|
+
raise AdapterError(f"CSV row {index} has more than {MAX_IMPORT_COLUMNS} columns")
|
|
121
|
+
normalized: dict[str, str] = {}
|
|
122
|
+
for key, value in row.items():
|
|
123
|
+
if key is None:
|
|
124
|
+
raise AdapterError(f"CSV row {index} has too many columns")
|
|
125
|
+
text = str(value or "")
|
|
126
|
+
if len(text) > MAX_IMPORT_CELL_CHARS:
|
|
127
|
+
raise AdapterError(f"CSV row {index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
|
|
128
|
+
normalized[str(key)] = text
|
|
129
|
+
rows.append(normalized)
|
|
90
130
|
except (UnicodeDecodeError, csv.Error) as exc:
|
|
91
131
|
raise AdapterError(f"invalid CSV: {exc}") from exc
|
|
92
132
|
return rows
|
|
93
133
|
|
|
94
134
|
|
|
135
|
+
def _xlsx_cell_column(ref: str | None, row_index: int, cell_pos: int) -> int:
|
|
136
|
+
"""解析单元格 ``r`` 属性(如 ``C2``)→ 0 基列号。
|
|
137
|
+
|
|
138
|
+
Excel 常省略空单元格(留白/样式单元格/合并后空列),按文档顺序对齐会让
|
|
139
|
+
后续有值单元格左移、静默写坏 price/stock(审查 S-M5)。因此以 ``r`` 属性
|
|
140
|
+
的列字母定位(A=0 … ZZ=701),缺列由调用方补 ``""``。无 ``r`` 属性
|
|
141
|
+
(少见)回退文档顺序位置。
|
|
142
|
+
"""
|
|
143
|
+
if ref:
|
|
144
|
+
letters = "".join(ch for ch in ref if ch.isalpha()).upper()
|
|
145
|
+
if letters:
|
|
146
|
+
col = 0
|
|
147
|
+
for ch in letters:
|
|
148
|
+
col = col * 26 + (ord(ch) - ord("A") + 1)
|
|
149
|
+
if col >= 1:
|
|
150
|
+
return col - 1
|
|
151
|
+
# 无 r / 纯数字引用:回退文档顺序位置(保持既有行为)。
|
|
152
|
+
return cell_pos - 1
|
|
153
|
+
|
|
154
|
+
|
|
95
155
|
def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) -> list[list[str]]:
|
|
96
156
|
root = ET.fromstring(zf.read(sheet_path))
|
|
97
157
|
ns = _XLSX_NS
|
|
98
158
|
rows: list[list[str]] = []
|
|
99
|
-
for row in root.iter(f"{{{ns}}}row"):
|
|
159
|
+
for row_index, row in enumerate(root.iter(f"{{{ns}}}row"), start=1):
|
|
160
|
+
if row_index > MAX_IMPORT_ROWS:
|
|
161
|
+
raise AdapterError(f"xlsx exceeds {MAX_IMPORT_ROWS} data rows")
|
|
100
162
|
cells: list[str] = []
|
|
101
|
-
for cell in row:
|
|
163
|
+
for cell_pos, cell in enumerate(row, start=1):
|
|
164
|
+
if cell_pos > MAX_IMPORT_COLUMNS:
|
|
165
|
+
raise AdapterError(f"xlsx row {row_index} has more than {MAX_IMPORT_COLUMNS} columns")
|
|
166
|
+
col = _xlsx_cell_column(cell.get("r"), row_index, cell_pos)
|
|
167
|
+
if col >= MAX_IMPORT_COLUMNS:
|
|
168
|
+
raise AdapterError(f"xlsx row {row_index} column {col + 1} exceeds {MAX_IMPORT_COLUMNS} columns")
|
|
169
|
+
# 按 r 属性定位列,跳过的空列补 ""(S-M5:空单元格不得左移错位)。
|
|
170
|
+
if col >= len(cells):
|
|
171
|
+
cells.extend("" for _ in range(col + 1 - len(cells)))
|
|
102
172
|
t = cell.get("t", "n")
|
|
103
173
|
v = cell.find(f"{{{ns}}}v")
|
|
174
|
+
value = ""
|
|
104
175
|
if t == "s" and v is not None:
|
|
105
|
-
|
|
106
|
-
|
|
176
|
+
try:
|
|
177
|
+
idx = int(v.text or "0")
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
raise AdapterError(
|
|
180
|
+
f"xlsx row {row_index} has invalid shared string index {v.text!r}"
|
|
181
|
+
) from exc
|
|
182
|
+
if idx < 0 or idx >= len(shared):
|
|
183
|
+
raise AdapterError(
|
|
184
|
+
f"xlsx row {row_index} shared string index {idx} out of range"
|
|
185
|
+
)
|
|
186
|
+
value = shared[idx]
|
|
107
187
|
elif t == "inlineStr":
|
|
108
188
|
is_el = cell.find(f"{{{ns}}}is")
|
|
109
|
-
text = ""
|
|
110
189
|
if is_el is not None:
|
|
111
|
-
|
|
112
|
-
cells.append(text)
|
|
190
|
+
value = "".join(tt.text or "" for tt in is_el.iter(f"{{{ns}}}t"))
|
|
113
191
|
elif v is not None:
|
|
114
|
-
|
|
192
|
+
value = str(v.text or "")
|
|
193
|
+
if len(value) > MAX_IMPORT_CELL_CHARS:
|
|
194
|
+
raise AdapterError(f"xlsx row {row_index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
|
|
195
|
+
cells[col] = value
|
|
115
196
|
rows.append(cells)
|
|
116
197
|
return rows
|
|
117
198
|
|
|
@@ -119,11 +200,22 @@ def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) ->
|
|
|
119
200
|
def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
|
|
120
201
|
try:
|
|
121
202
|
with zipfile.ZipFile(path) as zf:
|
|
203
|
+
infos = zf.infolist()
|
|
204
|
+
if len(infos) > MAX_XLSX_ENTRIES:
|
|
205
|
+
raise AdapterError(f"xlsx has more than {MAX_XLSX_ENTRIES} zip entries")
|
|
206
|
+
uncompressed = sum(info.file_size for info in infos)
|
|
207
|
+
if uncompressed > MAX_XLSX_UNCOMPRESSED_BYTES:
|
|
208
|
+
raise AdapterError(f"xlsx uncompressed content exceeds {MAX_XLSX_UNCOMPRESSED_BYTES} bytes")
|
|
122
209
|
shared: list[str] = []
|
|
123
210
|
if "xl/sharedStrings.xml" in zf.namelist():
|
|
124
211
|
root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
|
|
125
212
|
for si in root.iter(f"{{{_XLSX_NS}}}si"):
|
|
126
|
-
shared
|
|
213
|
+
if len(shared) >= MAX_IMPORT_ROWS * MAX_IMPORT_COLUMNS:
|
|
214
|
+
raise AdapterError("xlsx shared strings exceed the supported cell count")
|
|
215
|
+
value = "".join(t.text or "" for t in si.iter(f"{{{_XLSX_NS}}}t"))
|
|
216
|
+
if len(value) > MAX_IMPORT_CELL_CHARS:
|
|
217
|
+
raise AdapterError(f"xlsx shared string exceeds {MAX_IMPORT_CELL_CHARS} characters")
|
|
218
|
+
shared.append(value)
|
|
127
219
|
sheet_path = None
|
|
128
220
|
for name in ("xl/worksheets/sheet1.xml", "xl/worksheets/sheet.xml"):
|
|
129
221
|
if name in zf.namelist():
|
|
@@ -150,6 +242,21 @@ def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
|
|
|
150
242
|
# ---------------------------------------------------------------------------
|
|
151
243
|
|
|
152
244
|
|
|
245
|
+
_FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _neutralize_formula(value: str) -> str:
|
|
249
|
+
"""防 CSV/Excel 公式注入:以 = + - @ \\t \\r 开头的文本前缀单引号。
|
|
250
|
+
|
|
251
|
+
审查 L1:导入的文本字段若被下游重新导出为 CSV/Excel 并打开,公式前缀会被
|
|
252
|
+
执行(=cmd|... / @SUM(...) 等)。前缀 ' 让 Excel 把单元格视为文本,不破坏
|
|
253
|
+
合法数据(如 "-5% off")。
|
|
254
|
+
"""
|
|
255
|
+
if value.startswith(_FORMULA_INJECTION_PREFIXES):
|
|
256
|
+
return "'" + value
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
153
260
|
def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
|
|
154
261
|
"""CSV/Excel 行 → 本地 products 行(sku/title/price/stock 校验,fail-closed)。"""
|
|
155
262
|
sku = (raw.get("sku") or "").strip()
|
|
@@ -180,12 +287,12 @@ def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
|
|
|
180
287
|
raise AdapterError(f"row {index}: invalid stock {stock_raw!r}")
|
|
181
288
|
return {
|
|
182
289
|
"sku": sku,
|
|
183
|
-
"title": title,
|
|
290
|
+
"title": _neutralize_formula(title),
|
|
184
291
|
"price": price,
|
|
185
292
|
"stock": stock,
|
|
186
293
|
"currency": (raw.get("currency") or "").strip() or "CNY",
|
|
187
|
-
"category": (raw.get("category") or "").strip(),
|
|
188
|
-
"description": (raw.get("description") or "").strip(),
|
|
294
|
+
"category": _neutralize_formula((raw.get("category") or "").strip()),
|
|
295
|
+
"description": _neutralize_formula((raw.get("description") or "").strip()),
|
|
189
296
|
"merchant_id": (raw.get("merchant_id") or "").strip(),
|
|
190
297
|
}
|
|
191
298
|
|
|
@@ -10,7 +10,7 @@ from typing import Callable
|
|
|
10
10
|
|
|
11
11
|
from shopping_cli.core.tokens import is_sha256_digest, token_digest, token_prefix, token_suffix
|
|
12
12
|
|
|
13
|
-
CURRENT_SCHEMA_VERSION =
|
|
13
|
+
CURRENT_SCHEMA_VERSION = 25
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@dataclass(frozen=True)
|
|
@@ -506,6 +506,29 @@ def migration_023_remove_scheme_a_stub_merchants(conn: sqlite3.Connection) -> No
|
|
|
506
506
|
conn.execute("delete from merchants where id = ?", (merchant_id,))
|
|
507
507
|
|
|
508
508
|
|
|
509
|
+
def migration_025_negotiation_decision_idempotency(conn: sqlite3.Connection) -> None:
|
|
510
|
+
"""协商决策幂等账本(审查 H2 2026-08-16)。
|
|
511
|
+
|
|
512
|
+
``submit_decision`` 此前靠"先扫描 messages 再 append"判断幂等,并发相同
|
|
513
|
+
idempotency_key 的两个请求都能通过预读并各自写入决策消息(违反冻结协议
|
|
514
|
+
"同 key 重放绝不重复")。新增 (conversation_id, agent_id, idempotency_key)
|
|
515
|
+
唯一表,claim 时由唯一约束兜底并发——与 buyer_request_idempotency 同模式。
|
|
516
|
+
"""
|
|
517
|
+
conn.execute(
|
|
518
|
+
"""
|
|
519
|
+
create table if not exists negotiation_decision_idempotency (
|
|
520
|
+
conversation_id text not null,
|
|
521
|
+
agent_id text not null,
|
|
522
|
+
idempotency_key text not null,
|
|
523
|
+
message_id integer not null default 0,
|
|
524
|
+
decision_json text not null default '{}',
|
|
525
|
+
created_at text not null,
|
|
526
|
+
primary key (conversation_id, agent_id, idempotency_key)
|
|
527
|
+
)
|
|
528
|
+
"""
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
509
532
|
MIGRATIONS: tuple[Migration, ...] = (
|
|
510
533
|
Migration(1, "conversation_next_actor", migration_001_conversation_next_actor),
|
|
511
534
|
Migration(2, "agent_runtime_columns", migration_002_agent_runtime_columns),
|
|
@@ -524,6 +547,7 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|
|
524
547
|
Migration(22, "product_handoff_destination", migration_022_product_handoff_destination),
|
|
525
548
|
Migration(23, "remove_scheme_a_stub_merchants", migration_023_remove_scheme_a_stub_merchants),
|
|
526
549
|
Migration(24, "product_source_csv_excel", migration_024_product_source_csv_excel),
|
|
550
|
+
Migration(25, "negotiation_decision_idempotency", migration_025_negotiation_decision_idempotency),
|
|
527
551
|
)
|
|
528
552
|
|
|
529
553
|
|
|
@@ -233,6 +233,17 @@ SCHEMA = [
|
|
|
233
233
|
primary key (admin_token_hash, idempotency_key)
|
|
234
234
|
)
|
|
235
235
|
""",
|
|
236
|
+
"""
|
|
237
|
+
create table if not exists negotiation_decision_idempotency (
|
|
238
|
+
conversation_id text not null,
|
|
239
|
+
agent_id text not null,
|
|
240
|
+
idempotency_key text not null,
|
|
241
|
+
message_id integer not null default 0,
|
|
242
|
+
decision_json text not null default '{}',
|
|
243
|
+
created_at text not null,
|
|
244
|
+
primary key (conversation_id, agent_id, idempotency_key)
|
|
245
|
+
)
|
|
246
|
+
""",
|
|
236
247
|
|
|
237
248
|
]
|
|
238
249
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"""PublicListingProjection(shopping-cli v0.3 §14/§16;DoD #1-#3)。
|
|
2
2
|
|
|
3
3
|
public-only 白名单投影:把 products 行压缩为可公开的 discovery projection。
|
|
4
|
-
**绝不输出** cost / floor price / 私密库存 / credentials / 客户特定条款
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
discovery hint(v0.3 §14)——权威值以本地 products 行为准(v3.0 起
|
|
9
|
-
发布面已随 kiwi-catalog 子系统迁移至独立服务)。
|
|
4
|
+
**绝不输出** cost / floor price / 私密库存 / credentials / 客户特定条款 / 价格 /
|
|
5
|
+
促销 / 底价(v0.3 §16 不得输出清单;DoD #2 有回归测试锁定)。商品 listing 只携带
|
|
6
|
+
名称 + 分类/标签/区域等发现元数据,价格与供应事实以本地 products 行为准
|
|
7
|
+
(v3.0 起发布面已随 kiwi-catalog 子系统迁移至独立服务)。
|
|
10
8
|
"""
|
|
11
9
|
|
|
12
10
|
from __future__ import annotations
|
|
@@ -27,11 +25,6 @@ def _now_iso() -> str:
|
|
|
27
25
|
return datetime.now(timezone.utc).isoformat()
|
|
28
26
|
|
|
29
27
|
|
|
30
|
-
def _hint_price_range(price: float, currency: str) -> str:
|
|
31
|
-
"""price_range_hint:粗粒度公开价格提示(不是报价)。"""
|
|
32
|
-
return f"{currency} {price:.0f}"
|
|
33
|
-
|
|
34
|
-
|
|
35
28
|
def _product_row(conn: sqlite3.Connection, sku: str) -> dict[str, Any]:
|
|
36
29
|
row = conn.execute(
|
|
37
30
|
"select * from products where sku = ?", (str(sku).strip(),)
|
|
@@ -74,12 +67,6 @@ def project_product_listing(
|
|
|
74
67
|
"category": str(row.get("category") or ""),
|
|
75
68
|
"regions": [],
|
|
76
69
|
"tags": json.loads(row.get("tags_json") or "[]"),
|
|
77
|
-
"commercial_hints": {
|
|
78
|
-
"price_range_hint": _hint_price_range(float(row["price"]), str(row.get("currency") or "CNY")),
|
|
79
|
-
"availability_hint": "in_stock" if int(row.get("stock", 0)) > 0 else "out_of_stock",
|
|
80
|
-
"moq": 1,
|
|
81
|
-
"supports_bulk_quote": True,
|
|
82
|
-
},
|
|
83
70
|
}
|
|
84
71
|
# 每商品成交入口(KTH destination_ref):商家自行维护,publish 时同步进
|
|
85
72
|
# catalog listing 的 handoff_destination_ref。审查 S-M3:私有字段,缺省
|
|
@@ -90,14 +77,14 @@ def project_product_listing(
|
|
|
90
77
|
if description:
|
|
91
78
|
projection["summary"] = description
|
|
92
79
|
|
|
93
|
-
# provenance
|
|
94
|
-
#
|
|
80
|
+
# provenance 标注:只存在于 projection(Merchant Kiwi 可见),发布前由
|
|
81
|
+
# strip_provenance 剥离;商品不再携带价格/库存 hint(名称-only,v0.3 §16)。
|
|
95
82
|
projection["_provenance"] = {
|
|
96
83
|
"authority": authority,
|
|
97
84
|
"source_revision": projection["source_revision"],
|
|
98
85
|
"observed_at": str(row.get("observed_at") or now),
|
|
99
86
|
"fresh_until": str(row.get("fresh_until") or ""),
|
|
100
|
-
"note": "discovery
|
|
87
|
+
"note": "name-only discovery projection; authoritative facts in the local product record",
|
|
101
88
|
}
|
|
102
89
|
return projection
|
|
103
90
|
|
|
@@ -92,10 +92,14 @@ class NegotiationActor:
|
|
|
92
92
|
owner_id: str # buyer_id or merchant_id bound to the token
|
|
93
93
|
agent_id: str # claim identity used in agent_message_processes
|
|
94
94
|
conversation_id: str = "" # buyer tokens are bound to one conversation
|
|
95
|
+
token_hash: str = "" # 审查 S-M2 补强:token 摘要(服务端派生)——决策限流按
|
|
96
|
+
# 凭证(token)键控而非客户端声明的 owner_id,buyer 轮换 buyer_id 无法用
|
|
97
|
+
# 同一 token 重置限流桶。
|
|
95
98
|
|
|
96
99
|
|
|
97
100
|
def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> NegotiationActor:
|
|
98
101
|
row = token_service.require_api_token(conn, token, "negotiation token required")
|
|
102
|
+
token_hash = str(row["token_hash"] or "")
|
|
99
103
|
role = str(row["role"] or "")
|
|
100
104
|
if role == "buyer":
|
|
101
105
|
buyer_id = str(row["buyer_id"] or "")
|
|
@@ -107,6 +111,7 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
|
|
|
107
111
|
owner_id=buyer_id,
|
|
108
112
|
agent_id=protocol.buyer_agent_identity(buyer_id),
|
|
109
113
|
conversation_id=conversation_id,
|
|
114
|
+
token_hash=token_hash,
|
|
110
115
|
)
|
|
111
116
|
if role == "merchant":
|
|
112
117
|
merchant_id = str(row["merchant_id"] or "")
|
|
@@ -116,13 +121,14 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
|
|
|
116
121
|
role="merchant",
|
|
117
122
|
owner_id=merchant_id,
|
|
118
123
|
agent_id=token_service.default_merchant_agent_id(merchant_id),
|
|
124
|
+
token_hash=token_hash,
|
|
119
125
|
)
|
|
120
126
|
if role == "agent":
|
|
121
127
|
merchant_id = str(row["merchant_id"] or "")
|
|
122
128
|
agent_id = str(row["agent_id"] or "")
|
|
123
129
|
if not merchant_id or agent_id != token_service.default_merchant_agent_id(merchant_id):
|
|
124
130
|
raise AuthError("agent token cannot act as a negotiation merchant agent")
|
|
125
|
-
return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id)
|
|
131
|
+
return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id, token_hash=token_hash)
|
|
126
132
|
raise AuthError("token role cannot negotiate")
|
|
127
133
|
|
|
128
134
|
|
|
@@ -504,7 +510,10 @@ def _merchant_gate(
|
|
|
504
510
|
proposal = decision.get("proposal")
|
|
505
511
|
if decision["action"] in {"propose", "counter"} and proposal is None:
|
|
506
512
|
return _reject("missing_proposal", "propose/counter 必须携带结构化 proposal。")
|
|
507
|
-
|
|
513
|
+
# 审查 M7:此前直接 product_summary(sku) 取地板价,未校验商品归属本会话商家——
|
|
514
|
+
# SKU 复用/重建时会对错误商家的 automation_boundaries 做 floor/折扣判断。
|
|
515
|
+
# 换用 _load_conversation_product(带归属校验,fail-closed)。
|
|
516
|
+
product = _load_conversation_product(conn, conversation)
|
|
508
517
|
floor_str = merchant_agent._authorized_bargain_amount(product)
|
|
509
518
|
# 泄漏守卫对每个 merchant 决策运行——ask/decline 等无 proposal 的决策
|
|
510
519
|
# 也可能携带泄露底价的 public_message(此前该分支直接放行)。
|
|
@@ -697,6 +706,38 @@ def submit_decision(
|
|
|
697
706
|
sender = _decision_sender(actor.role)
|
|
698
707
|
status = _decision_status(actor.role, decision["action"])
|
|
699
708
|
structured_payload = _decision_structured_payload(actor.agent_id, actor.role, decision, idempotency_key)
|
|
709
|
+
# 幂等 claim:DB 唯一约束兜底并发(审查 H2)。两个并发相同 key 的请求只
|
|
710
|
+
# 有一个能插入成功;另一个在此拿到 IntegrityError,此时对方已提交,回查
|
|
711
|
+
# messages 走重放/冲突。与顶部 _find_decision_replay(服务"丢响应后回合已
|
|
712
|
+
# 推进再重试"的串行幂等)互补,共同覆盖并发 + 串行两条重试路径。
|
|
713
|
+
try:
|
|
714
|
+
conn.execute(
|
|
715
|
+
"""
|
|
716
|
+
insert into negotiation_decision_idempotency(
|
|
717
|
+
conversation_id, agent_id, idempotency_key, created_at
|
|
718
|
+
)
|
|
719
|
+
values (?, ?, ?, ?)
|
|
720
|
+
""",
|
|
721
|
+
(conversation_id, actor.agent_id, idempotency_key, now_iso()),
|
|
722
|
+
)
|
|
723
|
+
except sqlite3.IntegrityError:
|
|
724
|
+
replay = _find_decision_replay(conn, actor, conversation_id, idempotency_key)
|
|
725
|
+
if replay is not None:
|
|
726
|
+
if protocol.canonical_json(replay["decision"]) != protocol.canonical_json(decision):
|
|
727
|
+
raise IdempotencyConflict(
|
|
728
|
+
f"idempotency key {idempotency_key} was already used with a different decision"
|
|
729
|
+
)
|
|
730
|
+
refreshed = require_conversation(conn, conversation_id)
|
|
731
|
+
return _policy_result(
|
|
732
|
+
conversation_id,
|
|
733
|
+
"accepted",
|
|
734
|
+
protocol.snapshot_next_actor(str(refreshed["next_actor"] or "")),
|
|
735
|
+
list(decision["reason_codes"]),
|
|
736
|
+
"决策已接受并写入会话(幂等重放,未重复写入)。",
|
|
737
|
+
0,
|
|
738
|
+
message_id=int(replay["message_id"]),
|
|
739
|
+
)
|
|
740
|
+
raise IdempotencyConflict(f"idempotency key {idempotency_key} was already used")
|
|
700
741
|
message = append_message(
|
|
701
742
|
conn,
|
|
702
743
|
conversation_id,
|
|
@@ -706,6 +747,20 @@ def submit_decision(
|
|
|
706
747
|
structured_payload=structured_payload,
|
|
707
748
|
status=status,
|
|
708
749
|
)
|
|
750
|
+
conn.execute(
|
|
751
|
+
"""
|
|
752
|
+
update negotiation_decision_idempotency
|
|
753
|
+
set message_id = ?, decision_json = ?
|
|
754
|
+
where conversation_id = ? and agent_id = ? and idempotency_key = ?
|
|
755
|
+
""",
|
|
756
|
+
(
|
|
757
|
+
int(message["id"]),
|
|
758
|
+
protocol.canonical_json(decision),
|
|
759
|
+
conversation_id,
|
|
760
|
+
actor.agent_id,
|
|
761
|
+
idempotency_key,
|
|
762
|
+
),
|
|
763
|
+
)
|
|
709
764
|
if status == "closed":
|
|
710
765
|
append_conversation_closed_audit(conn, conversation_id, sender, "")
|
|
711
766
|
append_audit_event(
|
|
@@ -112,7 +112,7 @@ def require_api_token(conn: Any, token: Any, missing_error: str = "authorization
|
|
|
112
112
|
digest = token_digest(raw_token)
|
|
113
113
|
row = conn.execute(
|
|
114
114
|
"""
|
|
115
|
-
select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at
|
|
115
|
+
select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at, token_hash
|
|
116
116
|
from api_tokens
|
|
117
117
|
where token_hash = ?
|
|
118
118
|
""",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shopping-cli
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.3
|
|
4
4
|
Summary: Standalone local-commerce AI consultation marketplace runtime
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -310,6 +310,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
|
|
|
310
310
|
|
|
311
311
|
Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
|
|
312
312
|
|
|
313
|
+
For a single-merchant deployment, import commands can read the database and
|
|
314
|
+
merchant identity from the environment instead of repeating tenant flags:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
|
|
318
|
+
export SHOPPING_MERCHANT_ID="your-merchant-id"
|
|
319
|
+
|
|
320
|
+
shopping-cli import-csv-excel --file products.csv
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`KIWI_MERCHANT_ID` is accepted as a compatibility alias for
|
|
324
|
+
`SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
|
|
325
|
+
precedence over environment variables.
|
|
326
|
+
|
|
313
327
|
## Channel Ingress
|
|
314
328
|
|
|
315
329
|
External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_helpers.py
RENAMED
|
File without changes
|
{shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_result.py
RENAMED
|
File without changes
|
|
File without changes
|
{shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_snapshot_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|