Spaces:
Running
Running
CartBrawl: cart items brawl for your wallet
Browse files- README.md +57 -6
- app.py +451 -0
- cartbrawl/__init__.py +0 -0
- cartbrawl/debate.py +157 -0
- cartbrawl/demo_data.py +167 -0
- cartbrawl/llm.py +73 -0
- cartbrawl/products.py +185 -0
- requirements.txt +4 -0
README.md
CHANGED
|
@@ -1,13 +1,64 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CartBrawl 购物车大乱斗
|
| 3 |
+
emoji: 🛒
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Your cart items come alive and brawl for your wallet
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# 🛒⚔️ CartBrawl 购物车大乱斗
|
| 15 |
+
|
| 16 |
+
**Paste shop links. Your cart comes alive — and fights for your wallet.**
|
| 17 |
+
|
| 18 |
+
Every item you add to the cart turns into a character with a personality. Each one
|
| 19 |
+
knows every rival's price, specs, and weak spots, and they debate, trash-talk and
|
| 20 |
+
counter-attack to convince *you* to buy *them*. You can interject ("I only care
|
| 21 |
+
about battery life, budget is $300"), force a final plea, and then hit the buy
|
| 22 |
+
button. The winner gloats; the losers cope. **The decision is always yours.**
|
| 23 |
+
|
| 24 |
+
## How it works
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
links / "name price" lines
|
| 28 |
+
│
|
| 29 |
+
▼
|
| 30 |
+
┌─────────────────────┐ ┌──────────────────────────────┐
|
| 31 |
+
│ Product extractor │ │ Modal · vLLM · Qwen3-30B-A3B │
|
| 32 |
+
│ JSON-LD → OpenGraph │ │ (OpenAI-compatible endpoint) │
|
| 33 |
+
│ → URL-slug fallback │ └──────────────┬───────────────┘
|
| 34 |
+
└─────────┬───────────┘ │
|
| 35 |
+
▼ ▼
|
| 36 |
+
cart dossiers ──────────► debate engine: personas → opening
|
| 37 |
+
(every item sees → attack → rebuttal → owner Q&A
|
| 38 |
+
every rival's specs) → closing pleas → verdict speeches
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
- **Model**: Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 (30.5B total ≤ 32B ✅, MoE 3B active),
|
| 42 |
+
served by vLLM on a Modal L40S.
|
| 43 |
+
- **Scraping**: schema.org JSON-LD first, OpenGraph meta second, URL-slug
|
| 44 |
+
inference as a last resort — a bot-walled link still joins the brawl, it just
|
| 45 |
+
has to bluff harder.
|
| 46 |
+
- **Anti-hallucination rule**: items may only attack with *real* numbers from
|
| 47 |
+
rival dossiers; no invented specs.
|
| 48 |
+
- Bilingual brawls: 中文 / English.
|
| 49 |
+
|
| 50 |
+
## Run locally
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
uv sync
|
| 54 |
+
export CARTBRAWL_LLM_URL=https://<your-modal-endpoint>.modal.run/v1
|
| 55 |
+
export CARTBRAWL_API_KEY=<your key>
|
| 56 |
+
uv run python app.py
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Deploy your own backend: `uv run modal deploy modal_server.py`
|
| 60 |
+
(needs a Modal secret `cartbrawl-api-key` with `CARTBRAWL_API_KEY`).
|
| 61 |
+
|
| 62 |
+
## Built for
|
| 63 |
+
|
| 64 |
+
Build Small Hackathon 2026 — Track 2 (Thousand Token Wood).
|
app.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CartBrawl 购物车大乱斗 — your cart items debate, trash-talk, and fight for your wallet.
|
| 2 |
+
|
| 3 |
+
Paste shop links (or plain "name price" lines), every item comes alive knowing
|
| 4 |
+
every rival's price and specs, then they brawl. You hold the buy button.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import html
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
|
| 16 |
+
# Local dev convenience: pick up the API key file if env isn't set.
|
| 17 |
+
if not os.environ.get("CARTBRAWL_API_KEY"):
|
| 18 |
+
_kf = Path(__file__).parent / ".cartbrawl_api_key"
|
| 19 |
+
if _kf.exists():
|
| 20 |
+
os.environ["CARTBRAWL_API_KEY"] = _kf.read_text().strip()
|
| 21 |
+
|
| 22 |
+
from cartbrawl import debate
|
| 23 |
+
from cartbrawl.demo_data import DEMO_CARTS
|
| 24 |
+
from cartbrawl.products import Product, fetch_product
|
| 25 |
+
|
| 26 |
+
OWNER = {"zh": "👑 主人", "en": "👑 The Owner"}
|
| 27 |
+
|
| 28 |
+
T = {
|
| 29 |
+
"zh": {
|
| 30 |
+
"header": (
|
| 31 |
+
"<div id='header'><h1>🛒⚔️ CartBrawl 购物车大乱斗</h1>"
|
| 32 |
+
"<p>贴上商品链接,让它们自己吵。它们互相知根知底,谁吵赢了你买谁——决定权永远在你手上。</p></div>"
|
| 33 |
+
),
|
| 34 |
+
"lang": "语言 / Language",
|
| 35 |
+
"url_label": "商品链接 / 商品描述(每行一个)",
|
| 36 |
+
"url_ph": "https://www.amazon.com/dp/B0BXYCS74H\nSony WH-1000XM5 $348\n三顿半咖啡 129元",
|
| 37 |
+
"add": "🛒 加入购物车",
|
| 38 |
+
"demo_label": "或者来一车演示商品",
|
| 39 |
+
"demo_btn": "🎁 装入演示购物车",
|
| 40 |
+
"clear": "🗑️ 清空",
|
| 41 |
+
"rounds": "互撕轮数(每轮 = 全员攻击 + 全员反杀)",
|
| 42 |
+
"brawl": "🔥 开战!",
|
| 43 |
+
"more": "⚔️ 再吵一轮",
|
| 44 |
+
"closing": "🙏 最后陈词",
|
| 45 |
+
"note_label": "插话(告诉它们你在乎什么)",
|
| 46 |
+
"note_ph": "我最在乎续航,而且预算只有 300 刀……",
|
| 47 |
+
"note_btn": "📣 朕说两句",
|
| 48 |
+
"verdict": "最终裁决:买谁?",
|
| 49 |
+
"buy": "💸 买它!",
|
| 50 |
+
"cart_empty": "🛒 购物车空空如也<br><small>贴几个商品链接,或者直接写「商品名 价格」</small>",
|
| 51 |
+
"arena_empty": "⚔️ 竞技场静悄悄……<br><small>购物车里放至少 2 个商品,然后点「开战!」</small>",
|
| 52 |
+
"badges": {"scraped": "已抓取", "inferred": "链接推断", "manual": "手动添加", "demo": "演示数据"},
|
| 53 |
+
"warn_empty": "先贴链接或写商品,比如:Sony XM5 $348",
|
| 54 |
+
"warn_two": "至少要 2 个商品才能打起来!",
|
| 55 |
+
"warn_pick": "先选一个赢家",
|
| 56 |
+
"souls": "🪄 正在给每个商品注入灵魂…",
|
| 57 |
+
"bought": "💸 主人按下了购买键:{name}!",
|
| 58 |
+
"over": "🏁 本场大乱斗结束。换一车货,再来一架?",
|
| 59 |
+
},
|
| 60 |
+
"en": {
|
| 61 |
+
"header": (
|
| 62 |
+
"<div id='header'><h1>🛒⚔️ CartBrawl</h1>"
|
| 63 |
+
"<p>Paste shop links. Your cart comes alive — every item knows its rivals' prices and specs, "
|
| 64 |
+
"and they brawl for your wallet. You hold the buy button.</p></div>"
|
| 65 |
+
),
|
| 66 |
+
"lang": "Language / 语言",
|
| 67 |
+
"url_label": "Product links / descriptions (one per line)",
|
| 68 |
+
"url_ph": "https://www.amazon.com/dp/B0BXYCS74H\nSony WH-1000XM5 $348\nAirPods Pro 2 $249",
|
| 69 |
+
"add": "🛒 Add to cart",
|
| 70 |
+
"demo_label": "…or load a demo cart",
|
| 71 |
+
"demo_btn": "🎁 Load demo cart",
|
| 72 |
+
"clear": "🗑️ Clear",
|
| 73 |
+
"rounds": "Brawl rounds (each = everyone attacks + everyone counters)",
|
| 74 |
+
"brawl": "🔥 FIGHT!",
|
| 75 |
+
"more": "⚔️ One more round",
|
| 76 |
+
"closing": "🙏 Closing pleas",
|
| 77 |
+
"note_label": "Interject (tell them what you care about)",
|
| 78 |
+
"note_ph": "I only care about battery life, and my budget is $300…",
|
| 79 |
+
"note_btn": "📣 Speak",
|
| 80 |
+
"verdict": "Final verdict: who gets bought?",
|
| 81 |
+
"buy": "💸 BUY IT!",
|
| 82 |
+
"cart_empty": "🛒 Your cart is empty<br><small>Paste some shop links, or just type “product name price”</small>",
|
| 83 |
+
"arena_empty": "⚔️ The arena is quiet…<br><small>Put at least 2 items in the cart, then hit FIGHT!</small>",
|
| 84 |
+
"badges": {"scraped": "scraped", "inferred": "from URL", "manual": "manual", "demo": "demo"},
|
| 85 |
+
"warn_empty": "Paste a link or type a product first, e.g.: Sony XM5 $348",
|
| 86 |
+
"warn_two": "Need at least 2 items to brawl!",
|
| 87 |
+
"warn_pick": "Pick a winner first",
|
| 88 |
+
"souls": "🪄 Breathing souls into your items…",
|
| 89 |
+
"bought": "💸 The owner hit BUY on: {name}!",
|
| 90 |
+
"over": "🏁 Brawl over. Load another cart and run it back?",
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
BADGE_COLOR = {"scraped": "#27ae60", "inferred": "#e67e22", "manual": "#7f8c8d", "demo": "#2980b9"}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _lang(choice: str) -> str:
|
| 98 |
+
return "zh" if choice.startswith("中") else "en"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def new_state() -> dict:
|
| 102 |
+
return {"products": [], "personas": None, "transcript": [], "log": [], "ui": "zh"}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------- rendering ----------
|
| 106 |
+
|
| 107 |
+
def render_cart(state: dict) -> str:
|
| 108 |
+
t = T[state.get("ui", "zh")]
|
| 109 |
+
products: list[Product] = state["products"]
|
| 110 |
+
if not products:
|
| 111 |
+
return f"<div class='cart-empty'>{t['cart_empty']}</div>"
|
| 112 |
+
cards = []
|
| 113 |
+
for i, p in enumerate(products):
|
| 114 |
+
emoji = debate.EMOJI_POOL[i % len(debate.EMOJI_POOL)]
|
| 115 |
+
color = debate.COLOR_POOL[i % len(debate.COLOR_POOL)]
|
| 116 |
+
badge = t["badges"].get(p.source, "?")
|
| 117 |
+
bcolor = BADGE_COLOR.get(p.source, "#999")
|
| 118 |
+
cards.append(
|
| 119 |
+
f"<div class='cart-card' style='border-left:4px solid {color}'>"
|
| 120 |
+
f"<span class='cart-emoji'>{emoji}</span>"
|
| 121 |
+
f"<div class='cart-body'><b>{html.escape(p.name)}</b><br>"
|
| 122 |
+
f"<span class='price'>{html.escape(p.price_label())}</span> "
|
| 123 |
+
f"<span class='badge' style='background:{bcolor}'>{badge}</span></div></div>"
|
| 124 |
+
)
|
| 125 |
+
return "<div class='cart-list'>" + "".join(cards) + "</div>"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def render_arena(state: dict) -> str:
|
| 129 |
+
t = T[state.get("ui", "zh")]
|
| 130 |
+
if not state["log"]:
|
| 131 |
+
return f"<div class='arena'><div class='arena-empty'>{t['arena_empty']}</div></div>"
|
| 132 |
+
bubbles = []
|
| 133 |
+
for e in state["log"]:
|
| 134 |
+
text = html.escape(e["text"]).replace("\n", "<br>")
|
| 135 |
+
if e["kind"] == "system":
|
| 136 |
+
bubbles.append(f"<div class='round-banner'>{text}</div>")
|
| 137 |
+
elif e["kind"] == "user":
|
| 138 |
+
bubbles.append(
|
| 139 |
+
f"<div class='bubble owner'><div class='who'>{e['who']}</div>{text}</div>"
|
| 140 |
+
)
|
| 141 |
+
else:
|
| 142 |
+
cursor = "<span class='cursor'>▌</span>" if e.get("live") else ""
|
| 143 |
+
bubbles.append(
|
| 144 |
+
f"<div class='bubble' style='border-color:{e['color']}'>"
|
| 145 |
+
f"<div class='who' style='color:{e['color']}'>{e['emoji']} {html.escape(e['who'])}</div>"
|
| 146 |
+
f"{text}{cursor}</div>"
|
| 147 |
+
)
|
| 148 |
+
# column-reverse keeps the scroll pinned to the newest bubble
|
| 149 |
+
return "<div class='arena'>" + "".join(reversed(bubbles)) + "</div>"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _verdict_update(state: dict):
|
| 153 |
+
names = [p.name for p in state["products"]]
|
| 154 |
+
return gr.update(choices=names, value=None, label=T[state.get("ui", "zh")]["verdict"])
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ---------- cart actions ----------
|
| 158 |
+
|
| 159 |
+
_PRICE_RE = re.compile(r"([¥$€£])\s*([\d][\d,.]*)|([\d][\d,.]*)\s*(元|块|刀|usd|rmb|eur)", re.I)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _parse_manual(line: str) -> Product:
|
| 163 |
+
m = _PRICE_RE.search(line)
|
| 164 |
+
price, currency = "", ""
|
| 165 |
+
if m:
|
| 166 |
+
if m.group(1):
|
| 167 |
+
sym = {"¥": "CNY", "$": "USD", "€": "EUR", "£": "GBP"}[m.group(1)]
|
| 168 |
+
price, currency = m.group(2), sym
|
| 169 |
+
else:
|
| 170 |
+
unit = m.group(4).lower()
|
| 171 |
+
price = m.group(3)
|
| 172 |
+
currency = "CNY" if unit in ("元", "块", "rmb") else ("USD" if unit in ("刀", "usd") else "EUR")
|
| 173 |
+
line = (line[: m.start()] + line[m.end():]).strip(" ,,-—")
|
| 174 |
+
return Product(name=line.strip()[:120] or "神秘商品", price=price, currency=currency, source="manual")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def add_items(text: str, state: dict):
|
| 178 |
+
t = T[state.get("ui", "zh")]
|
| 179 |
+
lines = [l.strip() for l in re.split(r"[\n,,]+", text or "") if l.strip()]
|
| 180 |
+
if not lines:
|
| 181 |
+
gr.Warning(t["warn_empty"])
|
| 182 |
+
return "", render_cart(state), render_arena(state), state, _verdict_update(state)
|
| 183 |
+
for line in lines:
|
| 184 |
+
if re.match(r"(https?://|www\.)|^\S+\.\w{2,}/", line):
|
| 185 |
+
state["products"].append(fetch_product(line))
|
| 186 |
+
else:
|
| 187 |
+
state["products"].append(_parse_manual(line))
|
| 188 |
+
state["products"] = state["products"][:8] # cap the brawl
|
| 189 |
+
state["personas"], state["transcript"], state["log"] = None, [], []
|
| 190 |
+
return "", render_cart(state), render_arena(state), state, _verdict_update(state)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def load_demo(name: str, state: dict):
|
| 194 |
+
ui = state.get("ui", "zh")
|
| 195 |
+
state.update(new_state())
|
| 196 |
+
state["ui"] = ui
|
| 197 |
+
state["products"] = [Product(**vars(p)) for p in DEMO_CARTS[name]]
|
| 198 |
+
return render_cart(state), render_arena(state), state, _verdict_update(state)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def clear_cart(state: dict):
|
| 202 |
+
ui = state.get("ui", "zh")
|
| 203 |
+
state.update(new_state())
|
| 204 |
+
state["ui"] = ui
|
| 205 |
+
return render_cart(state), render_arena(state), state, _verdict_update(state)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ---------- debate actions ----------
|
| 209 |
+
|
| 210 |
+
def _ensure_personas(state: dict, lang: str):
|
| 211 |
+
# regenerate when the debate language changes, or personas go stale
|
| 212 |
+
if not state["personas"] or state.get("persona_lang") != lang:
|
| 213 |
+
state["personas"] = debate.make_personas(state["products"], lang)
|
| 214 |
+
state["persona_lang"] = lang
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _run_speech(state: dict, speaker: Product, round_key: str, lang: str, **fmt):
|
| 218 |
+
persona = state["personas"][speaker.name]
|
| 219 |
+
entry = {
|
| 220 |
+
"kind": "speech", "who": speaker.name, "emoji": persona["emoji"],
|
| 221 |
+
"color": persona["color"], "text": "", "live": True,
|
| 222 |
+
}
|
| 223 |
+
state["log"].append(entry)
|
| 224 |
+
for delta in debate.speech(
|
| 225 |
+
speaker, state["products"], state["personas"], state["transcript"],
|
| 226 |
+
round_key, lang, **fmt,
|
| 227 |
+
):
|
| 228 |
+
entry["text"] += delta
|
| 229 |
+
yield
|
| 230 |
+
entry["live"] = False
|
| 231 |
+
state["transcript"].append((speaker.name, entry["text"]))
|
| 232 |
+
yield
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _banner(state: dict, text: str):
|
| 236 |
+
state["log"].append({"kind": "system", "who": "", "text": text})
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
ROUND_BANNERS = {
|
| 240 |
+
"zh": {"opening": "🎺 开场环节:闪亮登场", "attack": "⚔️ 互撕环节:火力全开",
|
| 241 |
+
"rebuttal": "🛡️ 反杀环节:谁也别想全身而退", "closing": "🙏 最后陈词:拉票时间"},
|
| 242 |
+
"en": {"opening": "🎺 Opening: grand entrances", "attack": "⚔️ Brawl: gloves off",
|
| 243 |
+
"rebuttal": "🛡️ Rebuttals: nobody walks away clean", "closing": "🙏 Closing pleas"},
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _rounds(state: dict, lang: str, keys: list[str]):
|
| 248 |
+
for key in keys:
|
| 249 |
+
_banner(state, ROUND_BANNERS[lang][key])
|
| 250 |
+
yield
|
| 251 |
+
for speaker in debate.round_order(state["products"]):
|
| 252 |
+
yield from _run_speech(state, speaker, key, lang)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _guard(state: dict) -> bool:
|
| 256 |
+
if len(state["products"]) < 2:
|
| 257 |
+
gr.Warning(T[state.get("ui", "zh")]["warn_two"])
|
| 258 |
+
return False
|
| 259 |
+
return True
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def start_brawl(state: dict, lang_choice: str, n_rounds: float):
|
| 263 |
+
if not _guard(state):
|
| 264 |
+
yield render_arena(state), state
|
| 265 |
+
return
|
| 266 |
+
lang = _lang(lang_choice)
|
| 267 |
+
state["transcript"], state["log"] = [], []
|
| 268 |
+
_banner(state, T[lang]["souls"])
|
| 269 |
+
yield render_arena(state), state
|
| 270 |
+
_ensure_personas(state, lang)
|
| 271 |
+
state["log"].pop() # remove the loading banner
|
| 272 |
+
keys = ["opening"] + ["attack", "rebuttal"] * int(n_rounds)
|
| 273 |
+
for _ in _rounds(state, lang, keys):
|
| 274 |
+
yield render_arena(state), state
|
| 275 |
+
yield render_arena(state), state
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def another_round(state: dict, lang_choice: str):
|
| 279 |
+
if not _guard(state):
|
| 280 |
+
yield render_arena(state), state
|
| 281 |
+
return
|
| 282 |
+
lang = _lang(lang_choice)
|
| 283 |
+
_ensure_personas(state, lang)
|
| 284 |
+
for _ in _rounds(state, lang, ["attack", "rebuttal"]):
|
| 285 |
+
yield render_arena(state), state
|
| 286 |
+
yield render_arena(state), state
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def closing_round(state: dict, lang_choice: str):
|
| 290 |
+
if not _guard(state):
|
| 291 |
+
yield render_arena(state), state
|
| 292 |
+
return
|
| 293 |
+
lang = _lang(lang_choice)
|
| 294 |
+
_ensure_personas(state, lang)
|
| 295 |
+
for _ in _rounds(state, lang, ["closing"]):
|
| 296 |
+
yield render_arena(state), state
|
| 297 |
+
yield render_arena(state), state
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def interject(note: str, state: dict, lang_choice: str):
|
| 301 |
+
note = (note or "").strip()
|
| 302 |
+
if not note or not _guard(state):
|
| 303 |
+
yield "", render_arena(state), state
|
| 304 |
+
return
|
| 305 |
+
lang = _lang(lang_choice)
|
| 306 |
+
_ensure_personas(state, lang)
|
| 307 |
+
state["log"].append({"kind": "user", "who": OWNER[lang], "text": note})
|
| 308 |
+
yield "", render_arena(state), state
|
| 309 |
+
for speaker in debate.round_order(state["products"]):
|
| 310 |
+
for _ in _run_speech(state, speaker, "user_reply", lang, note=note):
|
| 311 |
+
yield "", render_arena(state), state
|
| 312 |
+
yield "", render_arena(state), state
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def pick_winner(choice: str, state: dict, lang_choice: str):
|
| 316 |
+
lang = _lang(lang_choice)
|
| 317 |
+
if not _guard(state):
|
| 318 |
+
yield render_arena(state), state
|
| 319 |
+
return
|
| 320 |
+
if not choice:
|
| 321 |
+
gr.Warning(T[lang]["warn_pick"])
|
| 322 |
+
yield render_arena(state), state
|
| 323 |
+
return
|
| 324 |
+
_ensure_personas(state, lang)
|
| 325 |
+
_banner(state, T[lang]["bought"].format(name=choice))
|
| 326 |
+
yield render_arena(state), state
|
| 327 |
+
winner = next(p for p in state["products"] if p.name == choice)
|
| 328 |
+
for _ in _run_speech(state, winner, "win", lang):
|
| 329 |
+
yield render_arena(state), state
|
| 330 |
+
for loser in state["products"]:
|
| 331 |
+
if loser.name == choice:
|
| 332 |
+
continue
|
| 333 |
+
for _ in _run_speech(state, loser, "lose", lang, winner=choice):
|
| 334 |
+
yield render_arena(state), state
|
| 335 |
+
_banner(state, T[lang]["over"])
|
| 336 |
+
yield render_arena(state), state
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ---------- language switch ----------
|
| 340 |
+
|
| 341 |
+
def switch_lang(lang_choice: str, state: dict):
|
| 342 |
+
ui = _lang(lang_choice)
|
| 343 |
+
state["ui"] = ui
|
| 344 |
+
t = T[ui]
|
| 345 |
+
return (
|
| 346 |
+
state,
|
| 347 |
+
t["header"],
|
| 348 |
+
gr.update(label=t["url_label"], placeholder=t["url_ph"]),
|
| 349 |
+
gr.update(value=t["add"]),
|
| 350 |
+
gr.update(label=t["demo_label"]),
|
| 351 |
+
gr.update(value=t["demo_btn"]),
|
| 352 |
+
gr.update(value=t["clear"]),
|
| 353 |
+
gr.update(label=t["rounds"]),
|
| 354 |
+
gr.update(value=t["brawl"]),
|
| 355 |
+
gr.update(value=t["more"]),
|
| 356 |
+
gr.update(value=t["closing"]),
|
| 357 |
+
gr.update(label=t["note_label"], placeholder=t["note_ph"]),
|
| 358 |
+
gr.update(value=t["note_btn"]),
|
| 359 |
+
gr.update(label=t["verdict"]),
|
| 360 |
+
gr.update(value=t["buy"]),
|
| 361 |
+
render_cart(state),
|
| 362 |
+
render_arena(state),
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# ---------- UI ----------
|
| 367 |
+
|
| 368 |
+
CSS = """
|
| 369 |
+
.gradio-container {max-width: 1280px !important}
|
| 370 |
+
#header {text-align:center; padding: 18px 0 6px}
|
| 371 |
+
#header h1 {font-size: 2.1em; margin: 0;
|
| 372 |
+
background: linear-gradient(90deg,#e74c3c,#8e44ad,#2980b9);
|
| 373 |
+
-webkit-background-clip:text; -webkit-text-fill-color:transparent}
|
| 374 |
+
#header p {opacity:.75; margin:6px 0 0}
|
| 375 |
+
.cart-list {display:flex; flex-direction:column; gap:8px}
|
| 376 |
+
.cart-card {display:flex; gap:10px; align-items:center; padding:8px 10px;
|
| 377 |
+
border-radius:10px; background:var(--block-background-fill);
|
| 378 |
+
box-shadow:0 1px 4px rgba(0,0,0,.12)}
|
| 379 |
+
.cart-emoji {font-size:1.6em}
|
| 380 |
+
.cart-body {line-height:1.35; font-size:.92em}
|
| 381 |
+
.price {font-weight:700; color:#e67e22}
|
| 382 |
+
.badge {color:#fff; border-radius:8px; padding:1px 7px; font-size:.72em}
|
| 383 |
+
.cart-empty, .arena-empty {text-align:center; opacity:.6; padding:30px 10px}
|
| 384 |
+
.arena {height:600px; overflow-y:auto; display:flex; flex-direction:column-reverse;
|
| 385 |
+
gap:10px; padding:12px; border-radius:12px;
|
| 386 |
+
background:var(--background-fill-secondary)}
|
| 387 |
+
.bubble {border:2px solid #888; border-radius:14px; padding:9px 13px;
|
| 388 |
+
max-width:88%; background:var(--block-background-fill);
|
| 389 |
+
animation:pop .25s ease; line-height:1.5}
|
| 390 |
+
.bubble .who {font-weight:700; font-size:.85em; margin-bottom:3px}
|
| 391 |
+
.bubble.owner {align-self:flex-end; border-color:#f1c40f; background:rgba(241,196,15,.12)}
|
| 392 |
+
.round-banner {align-self:center; font-weight:700; opacity:.8; padding:4px 14px;
|
| 393 |
+
border-radius:20px; background:var(--background-fill-primary);
|
| 394 |
+
border:1px dashed var(--border-color-primary)}
|
| 395 |
+
.cursor {animation: blink 1s steps(1) infinite}
|
| 396 |
+
@keyframes blink {50% {opacity:0}}
|
| 397 |
+
@keyframes pop {from {transform:scale(.92); opacity:0}}
|
| 398 |
+
"""
|
| 399 |
+
|
| 400 |
+
_t = T["zh"]
|
| 401 |
+
|
| 402 |
+
with gr.Blocks(title="CartBrawl 购物车大乱斗") as demo:
|
| 403 |
+
header = gr.HTML(_t["header"])
|
| 404 |
+
state = gr.State(new_state)
|
| 405 |
+
with gr.Row():
|
| 406 |
+
with gr.Column(scale=2):
|
| 407 |
+
lang_choice = gr.Radio(["中文", "English"], value="中文", label=_t["lang"])
|
| 408 |
+
url_box = gr.Textbox(label=_t["url_label"], placeholder=_t["url_ph"], lines=3)
|
| 409 |
+
add_btn = gr.Button(_t["add"], variant="primary")
|
| 410 |
+
demo_pick = gr.Dropdown(list(DEMO_CARTS), label=_t["demo_label"], value=list(DEMO_CARTS)[0])
|
| 411 |
+
with gr.Row():
|
| 412 |
+
demo_btn = gr.Button(_t["demo_btn"])
|
| 413 |
+
clear_btn = gr.Button(_t["clear"])
|
| 414 |
+
cart_html = gr.HTML(render_cart(new_state()))
|
| 415 |
+
with gr.Column(scale=4):
|
| 416 |
+
arena_html = gr.HTML(render_arena(new_state()))
|
| 417 |
+
rounds = gr.Slider(1, 4, value=2, step=1, label=_t["rounds"])
|
| 418 |
+
with gr.Row():
|
| 419 |
+
brawl_btn = gr.Button(_t["brawl"], variant="primary")
|
| 420 |
+
more_btn = gr.Button(_t["more"])
|
| 421 |
+
closing_btn = gr.Button(_t["closing"])
|
| 422 |
+
with gr.Row():
|
| 423 |
+
note_box = gr.Textbox(label=_t["note_label"], placeholder=_t["note_ph"], scale=4)
|
| 424 |
+
note_btn = gr.Button(_t["note_btn"], scale=1)
|
| 425 |
+
with gr.Row():
|
| 426 |
+
verdict_pick = gr.Radio([], label=_t["verdict"], scale=4)
|
| 427 |
+
buy_btn = gr.Button(_t["buy"], variant="stop", scale=1)
|
| 428 |
+
|
| 429 |
+
cart_outs = [cart_html, arena_html, state, verdict_pick]
|
| 430 |
+
add_btn.click(add_items, [url_box, state], [url_box, *cart_outs])
|
| 431 |
+
url_box.submit(add_items, [url_box, state], [url_box, *cart_outs])
|
| 432 |
+
demo_btn.click(load_demo, [demo_pick, state], cart_outs)
|
| 433 |
+
clear_btn.click(clear_cart, [state], cart_outs)
|
| 434 |
+
|
| 435 |
+
brawl_btn.click(start_brawl, [state, lang_choice, rounds], [arena_html, state])
|
| 436 |
+
more_btn.click(another_round, [state, lang_choice], [arena_html, state])
|
| 437 |
+
closing_btn.click(closing_round, [state, lang_choice], [arena_html, state])
|
| 438 |
+
note_btn.click(interject, [note_box, state, lang_choice], [note_box, arena_html, state])
|
| 439 |
+
note_box.submit(interject, [note_box, state, lang_choice], [note_box, arena_html, state])
|
| 440 |
+
buy_btn.click(pick_winner, [verdict_pick, state, lang_choice], [arena_html, state])
|
| 441 |
+
|
| 442 |
+
lang_choice.change(
|
| 443 |
+
switch_lang,
|
| 444 |
+
[lang_choice, state],
|
| 445 |
+
[state, header, url_box, add_btn, demo_pick, demo_btn, clear_btn, rounds,
|
| 446 |
+
brawl_btn, more_btn, closing_btn, note_box, note_btn, verdict_pick, buy_btn,
|
| 447 |
+
cart_html, arena_html],
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
if __name__ == "__main__":
|
| 451 |
+
demo.launch(theme=gr.themes.Soft(), css=CSS)
|
cartbrawl/__init__.py
ADDED
|
File without changes
|
cartbrawl/debate.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The debate engine: items grow personalities and fight for the buy button."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
from collections.abc import Iterator
|
| 7 |
+
|
| 8 |
+
from .llm import chat_json, stream_chat
|
| 9 |
+
from .products import Product
|
| 10 |
+
|
| 11 |
+
EMOJI_POOL = ["🦊", "🐯", "🦅", "🐙", "🦁", "🐲", "🦜", "🐺", "🦄", "🐳"]
|
| 12 |
+
COLOR_POOL = ["#e74c3c", "#2980b9", "#27ae60", "#8e44ad", "#d35400",
|
| 13 |
+
"#16a085", "#c0392b", "#2c3e50", "#b8860b", "#cb4374"]
|
| 14 |
+
|
| 15 |
+
TRANSCRIPT_WINDOW = 14 # how many recent speeches each speaker sees
|
| 16 |
+
SPEECH_TOKENS = 220
|
| 17 |
+
|
| 18 |
+
DIRECTIVES = {
|
| 19 |
+
"zh": {
|
| 20 |
+
"opening": "现在是开场环节:闪亮登场,自我介绍,亮出你最强的卖点,顺便放话挑衅全场对手。",
|
| 21 |
+
"attack": "现在是互撕环节:点名攻击至少一个对手的具体弱点(价格、参数、定位都行),并拿你的优势做对比。要狠,但要风趣。",
|
| 22 |
+
"rebuttal": "你刚才被攻击了(看辩论记录)。化解针对你的质疑,然后漂亮地反杀回去。",
|
| 23 |
+
"closing": "最后陈词:做最后的拉票,深情或嚣张都行,给主人一个非买你不可的理由。",
|
| 24 |
+
"user_reply": "主人刚刚发话:「{note}」。直接回应主人在乎的这一点,证明你最符合,顺便踩对手一脚。",
|
| 25 |
+
"win": "主人最终选择了买你!发表获胜感言,可以得意忘形,也可以感谢主人慧眼识珠。",
|
| 26 |
+
"lose": "主人最终选了「{winner}」而不是你。发表落选感言:哀怨、酸溜溜、不甘心或优雅认输都行,保持人设。",
|
| 27 |
+
},
|
| 28 |
+
"en": {
|
| 29 |
+
"opening": "Opening round: make a grand entrance, introduce yourself, flash your strongest selling point, and talk some trash at the whole cart.",
|
| 30 |
+
"attack": "Brawl round: call out at least one rival by name and hit a concrete weakness (price, specs, positioning), contrasting with your own strength. Be savage but witty.",
|
| 31 |
+
"rebuttal": "You just got attacked (see the transcript). Deflect the hit, then counter-attack in style.",
|
| 32 |
+
"closing": "Closing plea: make your final pitch — heartfelt or cocky — and give the owner one irresistible reason to buy YOU.",
|
| 33 |
+
"user_reply": 'The owner just said: "{note}". Address exactly what they care about, prove you fit best, and kick a rival on the way.',
|
| 34 |
+
"win": "The owner chose YOU! Give a victory speech — gloating allowed.",
|
| 35 |
+
"lose": 'The owner picked "{winner}" instead of you. Give your concession: bitter, dramatic, or gracious — stay in character.',
|
| 36 |
+
},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
RULES = {
|
| 40 |
+
"zh": (
|
| 41 |
+
"规则:必须全程用中文发言,即使商品资料或辩论记录里出现其他语言;"
|
| 42 |
+
"每次发言不超过80个字;口语化、有攻击性但风趣;"
|
| 43 |
+
"攻击对手时只能引用对手资料里真实的价格和参数,不许编造数字;"
|
| 44 |
+
"严禁重复或套用辩论记录里别人说过的句式,必须说出自己的新角度;"
|
| 45 |
+
"你资料里写明的缺点可以巧妙辩解,但不许直接否认;"
|
| 46 |
+
"不要用列表或markdown;直接输出台词本身,不要带自己的名字前缀,不要加引号。"
|
| 47 |
+
),
|
| 48 |
+
"en": (
|
| 49 |
+
"Rules: speak ENGLISH ONLY — even if product data or earlier transcript lines "
|
| 50 |
+
"are in Chinese, translate the facts and deliver your line in English; "
|
| 51 |
+
"max 60 words per speech; conversational, aggressive but witty; "
|
| 52 |
+
"when attacking, only cite real prices/specs from the rival dossiers — never invent numbers; "
|
| 53 |
+
"never reuse or echo phrasing already in the transcript — bring a fresh angle; "
|
| 54 |
+
"you may spin the weaknesses listed in your own dossier, but never flatly deny them; "
|
| 55 |
+
"no lists, no markdown; output only the line itself, no name prefix, no surrounding quotes."
|
| 56 |
+
),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def make_personas(products: list[Product], lang: str) -> dict[str, dict]:
|
| 61 |
+
names = [p.name for p in products]
|
| 62 |
+
sheet = "\n\n".join(f"### {p.name}\n{p.dossier(lang)}" for p in products)
|
| 63 |
+
if lang == "zh":
|
| 64 |
+
sys = (
|
| 65 |
+
"你是一个喜剧编剧。给购物车里每个'成精的商品'设计一个鲜明的辩论人设。"
|
| 66 |
+
"返回JSON数组,每个元素: {\"name\": 商品名(原样), \"personality\": 一句话人设, "
|
| 67 |
+
"\"style\": 一句话说话风格}。人设之间要反差大、有戏剧冲突。"
|
| 68 |
+
"personality和style必须用中文写。只返回JSON。"
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
sys = (
|
| 72 |
+
"You are a comedy writer. Give each sentient shopping-cart item a vivid debate persona. "
|
| 73 |
+
'Return a JSON array of {"name": exact product name, "personality": one sentence, '
|
| 74 |
+
'"style": one-sentence speaking style}. Make personas clash dramatically. '
|
| 75 |
+
"Write personality and style in English, even if product data is in Chinese. JSON only."
|
| 76 |
+
)
|
| 77 |
+
def norm(s: str) -> str:
|
| 78 |
+
return "".join(c for c in s.lower() if c.isalnum())
|
| 79 |
+
|
| 80 |
+
personas: dict[str, dict] = {}
|
| 81 |
+
try:
|
| 82 |
+
data = chat_json(sys, sheet)
|
| 83 |
+
if isinstance(data, dict):
|
| 84 |
+
data = data.get("personas", [])
|
| 85 |
+
for row in data:
|
| 86 |
+
key = norm(str(row.get("name", "")))
|
| 87 |
+
# fuzzy-match the LLM's (possibly shortened) name back to a product
|
| 88 |
+
match = next(
|
| 89 |
+
(n for n in names if norm(n) == key
|
| 90 |
+
or key and (key in norm(n) or norm(n) in key)),
|
| 91 |
+
None,
|
| 92 |
+
)
|
| 93 |
+
if match:
|
| 94 |
+
personas[match] = row
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
fallback_zh = {"personality": "自信爆棚的实力派", "style": "三句话不离自己的参数"}
|
| 98 |
+
fallback_en = {"personality": "supremely confident workhorse", "style": "can't go two sentences without quoting its own specs"}
|
| 99 |
+
for i, p in enumerate(products):
|
| 100 |
+
row = personas.get(p.name) or (fallback_zh if lang == "zh" else fallback_en).copy()
|
| 101 |
+
row["emoji"] = EMOJI_POOL[i % len(EMOJI_POOL)]
|
| 102 |
+
row["color"] = COLOR_POOL[i % len(COLOR_POOL)]
|
| 103 |
+
personas[p.name] = row
|
| 104 |
+
return {n: personas[n] for n in names}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _system_prompt(speaker: Product, products: list[Product], persona: dict, lang: str) -> str:
|
| 108 |
+
head = "### 对手: " if lang == "zh" else "### Rival: "
|
| 109 |
+
rivals = "\n\n".join(
|
| 110 |
+
f"{head}{p.name}\n{p.dossier(lang)}" for p in products if p.name != speaker.name
|
| 111 |
+
)
|
| 112 |
+
if lang == "zh":
|
| 113 |
+
return (
|
| 114 |
+
f"你是购物车里成精的商品「{speaker.name}」。\n"
|
| 115 |
+
f"人设:{persona.get('personality', '')}\n说话风格:{persona.get('style', '')}\n"
|
| 116 |
+
f"你正在参加一场购物车辩论赛,目标:说服主人买你,而不是买对手。\n\n"
|
| 117 |
+
f"## 你的资料\n{speaker.dossier('zh')}\n\n## 对手资料\n{rivals}\n\n{RULES['zh']}"
|
| 118 |
+
)
|
| 119 |
+
return (
|
| 120 |
+
f'You are "{speaker.name}", a shopping-cart item that has come to life.\n'
|
| 121 |
+
f"Persona: {persona.get('personality', '')}\nVoice: {persona.get('style', '')}\n"
|
| 122 |
+
f"You are in a cart debate. Goal: convince the owner to buy YOU, not your rivals.\n\n"
|
| 123 |
+
f"## Your dossier\n{speaker.dossier('en')}\n\n## Rival dossiers\n{rivals}\n\n{RULES['en']}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _transcript_text(transcript: list[tuple[str, str]], lang: str) -> str:
|
| 128 |
+
if not transcript:
|
| 129 |
+
return "(辩论刚开始,还没人发言)" if lang == "zh" else "(debate just started, nobody has spoken yet)"
|
| 130 |
+
recent = transcript[-TRANSCRIPT_WINDOW:]
|
| 131 |
+
return "\n".join(f"{who}: {text}" for who, text in recent)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def speech(
|
| 135 |
+
speaker: Product,
|
| 136 |
+
products: list[Product],
|
| 137 |
+
personas: dict[str, dict],
|
| 138 |
+
transcript: list[tuple[str, str]],
|
| 139 |
+
round_key: str,
|
| 140 |
+
lang: str,
|
| 141 |
+
**fmt,
|
| 142 |
+
) -> Iterator[str]:
|
| 143 |
+
directive = DIRECTIVES[lang][round_key].format(**fmt)
|
| 144 |
+
head = "## 辩论记录\n" if lang == "zh" else "## Transcript\n"
|
| 145 |
+
task = "\n\n## 你的任务\n" if lang == "zh" else "\n\n## Your task\n"
|
| 146 |
+
user = head + _transcript_text(transcript, lang) + task + directive
|
| 147 |
+
yield from stream_chat(
|
| 148 |
+
_system_prompt(speaker, products, personas[speaker.name], lang),
|
| 149 |
+
user,
|
| 150 |
+
max_tokens=SPEECH_TOKENS,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def round_order(products: list[Product]) -> list[Product]:
|
| 155 |
+
order = list(products)
|
| 156 |
+
random.shuffle(order)
|
| 157 |
+
return order
|
cartbrawl/demo_data.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preset demo carts so the brawl works even without scraping live shops."""
|
| 2 |
+
|
| 3 |
+
from .products import Product
|
| 4 |
+
|
| 5 |
+
DEMO_CARTS = {
|
| 6 |
+
"🎧 耳机三国杀 / Headphone Showdown": [
|
| 7 |
+
Product(
|
| 8 |
+
name="Sony WH-1000XM5",
|
| 9 |
+
price="348", currency="USD", brand="Sony",
|
| 10 |
+
rating="4.7/5 (12,438 评价)",
|
| 11 |
+
description="旗舰头戴式降噪耳机。30小时续航,8麦克风降噪系统,LDAC高解析音质,多点连接。轻至250g,但不能折叠收纳。",
|
| 12 |
+
source="demo",
|
| 13 |
+
),
|
| 14 |
+
Product(
|
| 15 |
+
name="Apple AirPods Pro 2 (USB-C)",
|
| 16 |
+
price="249", currency="USD", brand="Apple",
|
| 17 |
+
rating="4.8/5 (31,022 评价)",
|
| 18 |
+
description="入耳式真无线降噪。自适应通透模式,与iPhone/Mac无缝切换,对话感知,带充电盒续航30小时。安卓用户功能阉割严重。",
|
| 19 |
+
source="demo",
|
| 20 |
+
),
|
| 21 |
+
Product(
|
| 22 |
+
name="Bose QuietComfort Ultra",
|
| 23 |
+
price="429", currency="USD", brand="Bose",
|
| 24 |
+
rating="4.6/5 (8,915 评价)",
|
| 25 |
+
description="沉浸空间音频,业界顶级降噪,佩戴舒适度之王。续航24小时,全家桶生态较弱,价格全场最贵。",
|
| 26 |
+
source="demo",
|
| 27 |
+
),
|
| 28 |
+
],
|
| 29 |
+
"💻 笔记本擂台 / Laptop Arena": [
|
| 30 |
+
Product(
|
| 31 |
+
name="MacBook Air 13 (M3, 16GB/512GB)",
|
| 32 |
+
price="1299", currency="USD", brand="Apple",
|
| 33 |
+
rating="4.8/5 (9,204 评价)",
|
| 34 |
+
description="无风扇静音设计,18小时续航,1.24kg。M3芯片日常飞快,但接口只有两个雷雳口,游戏生态约等于零。",
|
| 35 |
+
source="demo",
|
| 36 |
+
),
|
| 37 |
+
Product(
|
| 38 |
+
name="ThinkPad X1 Carbon Gen 12 (Ultra 7, 32GB/1TB)",
|
| 39 |
+
price="1649", currency="USD", brand="Lenovo",
|
| 40 |
+
rating="4.5/5 (2,871 评价)",
|
| 41 |
+
description="商务旗舰,1.09kg碳纤维机身,键盘手感天花板,接口齐全,军规耐用。屏幕素质和续航略逊于Mac,风扇偶尔咆哮。",
|
| 42 |
+
source="demo",
|
| 43 |
+
),
|
| 44 |
+
Product(
|
| 45 |
+
name="ASUS ROG Zephyrus G14 (RTX 4060, 32GB)",
|
| 46 |
+
price="1399", currency="USD", brand="ASUS",
|
| 47 |
+
rating="4.6/5 (5,633 评价)",
|
| 48 |
+
description="14寸性能小钢炮,OLED 120Hz屏,能打3A游戏能跑AI。1.5kg,插电才是完全体,续航和安静与我无缘。",
|
| 49 |
+
source="demo",
|
| 50 |
+
),
|
| 51 |
+
],
|
| 52 |
+
"👟 球鞋德比 / Sneaker Derby": [
|
| 53 |
+
Product(
|
| 54 |
+
name="Nike Air Force 1 '07",
|
| 55 |
+
price="115", currency="USD", brand="Nike",
|
| 56 |
+
rating="4.8/5 (43,200 评价)",
|
| 57 |
+
description="40多年的街头传奇,百搭白鞋之王,怎么穿都不出错。鞋底偏硬,夏天闷脚,而且满大街都是,撞鞋率100%。",
|
| 58 |
+
source="demo",
|
| 59 |
+
),
|
| 60 |
+
Product(
|
| 61 |
+
name="Adidas Samba OG",
|
| 62 |
+
price="100", currency="USD", brand="Adidas",
|
| 63 |
+
rating="4.7/5 (18,560 评价)",
|
| 64 |
+
description="德训鞋顶流,时尚博主人手一双,T头复古造型绝杀。鞋型偏窄,不适合宽脚,火到经常断码,黄牛加价。",
|
| 65 |
+
source="demo",
|
| 66 |
+
),
|
| 67 |
+
Product(
|
| 68 |
+
name="李宁 赤兔7 Pro 跑鞋",
|
| 69 |
+
price="499", currency="CNY", brand="李宁",
|
| 70 |
+
rating="4.8/5 (96,000 评价)",
|
| 71 |
+
description="中端跑鞋性价比之王,䨻丝中底,回弹拉满,通勤跑步两相宜。颜值偏运动风,配西裤会被时尚警察逮捕。",
|
| 72 |
+
source="demo",
|
| 73 |
+
),
|
| 74 |
+
],
|
| 75 |
+
"🧹 大扫除军备竞赛 / Cleaning Arms Race": [
|
| 76 |
+
Product(
|
| 77 |
+
name="石头 P10 Pro 扫拖机器人",
|
| 78 |
+
price="2999", currency="CNY", brand="石头科技",
|
| 79 |
+
rating="4.8/5 (62,000 评价)",
|
| 80 |
+
description="自动集尘自动洗拖布,激光导航全屋建图,你躺着它干活。沙发底太矮进不去,数据线和袜子是它的天敌,角落清洁靠缘分。",
|
| 81 |
+
source="demo",
|
| 82 |
+
),
|
| 83 |
+
Product(
|
| 84 |
+
name="戴森 V12 Detect Slim 吸尘器",
|
| 85 |
+
price="3490", currency="CNY", brand="Dyson",
|
| 86 |
+
rating="4.7/5 (28,400 评价)",
|
| 87 |
+
description="激光显尘黑科技,看得见的灰尘全消灭,床垫沙发窗帘全能打。要自己动手,续航60分钟,手持久了胳膊酸,价格全场最贵。",
|
| 88 |
+
source="demo",
|
| 89 |
+
),
|
| 90 |
+
Product(
|
| 91 |
+
name="家政深度保洁 12次年卡",
|
| 92 |
+
price="2160", currency="CNY", brand="本地家政",
|
| 93 |
+
rating="4.6/5 (8,900 评价)",
|
| 94 |
+
description="每月一次专业阿姨上门4小时,厨房油污卫生间水垢全搞定,真人服务有温度。需要预约等档期,平时的日常灰尘它管不着。",
|
| 95 |
+
source="demo",
|
| 96 |
+
),
|
| 97 |
+
],
|
| 98 |
+
"🏋️ 减肥大作战 / Get-Fit Fight": [
|
| 99 |
+
Product(
|
| 100 |
+
name="健身房年卡(连锁品牌)",
|
| 101 |
+
price="2388", currency="CNY", brand="乐刻运动",
|
| 102 |
+
rating="4.5/5 (15,200 评价)",
|
| 103 |
+
description="器械齐全,团课免费,有教练有氛围,洗澡都解决了。办卡容易坚持难,业界续卡率不到30%,你确定你不是在给别人交电费?",
|
| 104 |
+
source="demo",
|
| 105 |
+
),
|
| 106 |
+
Product(
|
| 107 |
+
name="家用折叠跑步机",
|
| 108 |
+
price="1599", currency="CNY", brand="麦瑞克",
|
| 109 |
+
rating="4.6/5 (33,000 评价)",
|
| 110 |
+
description="在家就能跑,刮风下雨都不怕,折叠起来塞床底。占地一平米,邻居可能投诉震动,大概率沦为高级晾衣架。",
|
| 111 |
+
source="demo",
|
| 112 |
+
),
|
| 113 |
+
Product(
|
| 114 |
+
name="Switch 健身环大冒险",
|
| 115 |
+
price="399", currency="CNY", brand="Nintendo",
|
| 116 |
+
rating="4.9/5 (120,000 评价)",
|
| 117 |
+
description="把锻炼变成打怪升级,快乐出汗不知不觉瘦,全家都能玩。强度有限练不出腹肌,需要你先有一台Switch,通关后容易吃灰。",
|
| 118 |
+
source="demo",
|
| 119 |
+
),
|
| 120 |
+
],
|
| 121 |
+
"🧋 下午茶内战 / Afternoon-Tea Civil War": [
|
| 122 |
+
Product(
|
| 123 |
+
name="星巴克 燕麦拿铁(大杯)",
|
| 124 |
+
price="33", currency="CNY", brand="Starbucks",
|
| 125 |
+
rating="4.5/5 (22,000 评价)",
|
| 126 |
+
description="第三空间体验,带电脑办公装X刚需,品质稳定。一杯钱够别人喝一周蜜雪,喝的是咖啡还是房租自己心里没数吗?",
|
| 127 |
+
source="demo",
|
| 128 |
+
),
|
| 129 |
+
Product(
|
| 130 |
+
name="瑞幸 生椰拿铁",
|
| 131 |
+
price="9.9", currency="CNY", brand="Luckin",
|
| 132 |
+
rating="4.8/5 (500万+ 评价)",
|
| 133 |
+
description="9块9的国民神券,生椰口味YYDS,App下单到店即取。门店没座位,排队高峰等15分钟,优惠券规则比高数还难懂。",
|
| 134 |
+
source="demo",
|
| 135 |
+
),
|
| 136 |
+
Product(
|
| 137 |
+
name="蜜雪冰城 冰鲜柠檬水",
|
| 138 |
+
price="4", currency="CNY", brand="蜜雪冰城",
|
| 139 |
+
rating="4.7/5 (1000万+ 评价)",
|
| 140 |
+
description="4块钱的快乐,一整杯真柠檬,雪王IP人见人爱。没有咖啡因提神效果为零,含糖量感人,办公楼附近不一定有店。",
|
| 141 |
+
source="demo",
|
| 142 |
+
),
|
| 143 |
+
],
|
| 144 |
+
"☕ 咖啡装备内战 / Coffee Gear Civil War": [
|
| 145 |
+
Product(
|
| 146 |
+
name="De'Longhi 半自动意式咖啡机 EC885",
|
| 147 |
+
price="1899", currency="CNY", brand="De'Longhi",
|
| 148 |
+
rating="4.6/5 (4,102 评价)",
|
| 149 |
+
description="15bar泵压,手动蒸汽奶泡,仪式感拉满。需要练习技术,每天通勤前折腾10分钟,清洗麻烦。",
|
| 150 |
+
source="demo",
|
| 151 |
+
),
|
| 152 |
+
Product(
|
| 153 |
+
name="三顿半 超即溶咖啡 24颗装",
|
| 154 |
+
price="129", currency="CNY", brand="三顿半",
|
| 155 |
+
rating="4.8/5 (50万+ 评价)",
|
| 156 |
+
description="3秒冷水即溶,办公室出差神器,风味还原度高。无法拉花,没有仪式感,按杯算贵过自己磨豆。",
|
| 157 |
+
source="demo",
|
| 158 |
+
),
|
| 159 |
+
Product(
|
| 160 |
+
name="泰摩 栗子C3手摇磨豆机 + V60手冲套装",
|
| 161 |
+
price="368", currency="CNY", brand="泰摩",
|
| 162 |
+
rating="4.7/5 (28,440 评价)",
|
| 163 |
+
description="手冲入门毕业套装,不锈钢磨芯,风味上限高,户外也能用。每杯需要15分钟手工劳动,手臂会酸。",
|
| 164 |
+
source="demo",
|
| 165 |
+
),
|
| 166 |
+
],
|
| 167 |
+
}
|
cartbrawl/llm.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin OpenAI-compatible client for the Modal vLLM endpoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from collections.abc import Iterator
|
| 9 |
+
|
| 10 |
+
from openai import OpenAI
|
| 11 |
+
|
| 12 |
+
MODEL = os.environ.get("CARTBRAWL_MODEL", "Qwen/Qwen3-30B-A3B-Instruct-2507-FP8")
|
| 13 |
+
DEFAULT_URL = "https://wuzhenze--cartbrawl-llm-serve.modal.run/v1"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _client() -> OpenAI:
|
| 17 |
+
base_url = os.environ.get("CARTBRAWL_LLM_URL", DEFAULT_URL)
|
| 18 |
+
api_key = os.environ.get("CARTBRAWL_API_KEY", "")
|
| 19 |
+
if not api_key:
|
| 20 |
+
raise RuntimeError("CARTBRAWL_API_KEY not set")
|
| 21 |
+
return OpenAI(base_url=base_url, api_key=api_key, timeout=120)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Hybrid-thinking Qwen3 models need thinking switched off for fast banter;
|
| 25 |
+
# the Instruct-2507 line has no thinking mode at all.
|
| 26 |
+
_EXTRA = (
|
| 27 |
+
{} if "2507" in MODEL else {"chat_template_kwargs": {"enable_thinking": False}}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def stream_chat(
|
| 32 |
+
system: str,
|
| 33 |
+
user: str,
|
| 34 |
+
max_tokens: int = 280,
|
| 35 |
+
temperature: float = 0.9,
|
| 36 |
+
) -> Iterator[str]:
|
| 37 |
+
"""Yield text deltas."""
|
| 38 |
+
resp = _client().chat.completions.create(
|
| 39 |
+
model=MODEL,
|
| 40 |
+
messages=[
|
| 41 |
+
{"role": "system", "content": system},
|
| 42 |
+
{"role": "user", "content": user},
|
| 43 |
+
],
|
| 44 |
+
max_tokens=max_tokens,
|
| 45 |
+
temperature=temperature,
|
| 46 |
+
presence_penalty=0.7,
|
| 47 |
+
frequency_penalty=0.3,
|
| 48 |
+
stream=True,
|
| 49 |
+
extra_body=_EXTRA,
|
| 50 |
+
)
|
| 51 |
+
for chunk in resp:
|
| 52 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 53 |
+
yield chunk.choices[0].delta.content
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def chat_json(system: str, user: str, max_tokens: int = 1200) -> dict | list:
|
| 57 |
+
"""One-shot call that must return JSON (personas, parsed products...)."""
|
| 58 |
+
resp = _client().chat.completions.create(
|
| 59 |
+
model=MODEL,
|
| 60 |
+
messages=[
|
| 61 |
+
{"role": "system", "content": system},
|
| 62 |
+
{"role": "user", "content": user},
|
| 63 |
+
],
|
| 64 |
+
max_tokens=max_tokens,
|
| 65 |
+
temperature=0.6,
|
| 66 |
+
extra_body=_EXTRA,
|
| 67 |
+
)
|
| 68 |
+
text = resp.choices[0].message.content or ""
|
| 69 |
+
m = re.search(r"```(?:json)?\s*(.+?)```", text, re.S)
|
| 70 |
+
if m:
|
| 71 |
+
text = m.group(1)
|
| 72 |
+
start = min((i for i in (text.find("{"), text.find("[")) if i >= 0), default=0)
|
| 73 |
+
return json.loads(text[start:])
|
cartbrawl/products.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch product pages and extract structured product info.
|
| 2 |
+
|
| 3 |
+
Strategy, in order of trust:
|
| 4 |
+
1. JSON-LD (schema.org Product) — most shops embed this for SEO.
|
| 5 |
+
2. OpenGraph / twitter / product: meta tags.
|
| 6 |
+
3. <title> + meta description as a last resort.
|
| 7 |
+
|
| 8 |
+
If the page can't be fetched at all (bot wall, timeout), we still return a
|
| 9 |
+
Product inferred from the URL slug, marked source="inferred", so the debate
|
| 10 |
+
can go on — the item just has to bluff harder.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from urllib.parse import unquote, urlparse
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
from bs4 import BeautifulSoup
|
| 22 |
+
|
| 23 |
+
UA = (
|
| 24 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 25 |
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
MAX_DESC = 600
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class Product:
|
| 33 |
+
name: str
|
| 34 |
+
url: str = ""
|
| 35 |
+
price: str = ""
|
| 36 |
+
currency: str = ""
|
| 37 |
+
brand: str = ""
|
| 38 |
+
description: str = ""
|
| 39 |
+
rating: str = ""
|
| 40 |
+
image: str = ""
|
| 41 |
+
source: str = "scraped" # scraped | inferred | manual | demo
|
| 42 |
+
extra: dict = field(default_factory=dict)
|
| 43 |
+
|
| 44 |
+
def price_label(self) -> str:
|
| 45 |
+
if not self.price:
|
| 46 |
+
return "价格成谜 / price unknown"
|
| 47 |
+
cur = {"USD": "$", "EUR": "€", "GBP": "£", "CNY": "¥", "JPY": "¥"}.get(
|
| 48 |
+
self.currency, self.currency
|
| 49 |
+
)
|
| 50 |
+
return f"{cur}{self.price}" if cur else self.price
|
| 51 |
+
|
| 52 |
+
def dossier(self, lang: str = "zh") -> str:
|
| 53 |
+
"""Compact fact sheet used inside prompts."""
|
| 54 |
+
L = {
|
| 55 |
+
"zh": ("名称", "价格", "品牌", "评分", "卖点/描述",
|
| 56 |
+
"(注意: 页面抓取失败,以上信息从链接推断,资料不全)"),
|
| 57 |
+
"en": ("Name", "Price", "Brand", "Rating", "Selling points",
|
| 58 |
+
"(note: page scrape failed, info inferred from the URL, incomplete)"),
|
| 59 |
+
}[lang]
|
| 60 |
+
lines = [f"{L[0]}: {self.name}", f"{L[1]}: {self.price_label()}"]
|
| 61 |
+
if self.brand:
|
| 62 |
+
lines.append(f"{L[2]}: {self.brand}")
|
| 63 |
+
if self.rating:
|
| 64 |
+
lines.append(f"{L[3]}: {self.rating}")
|
| 65 |
+
if self.description:
|
| 66 |
+
lines.append(f"{L[4]}: {self.description[:MAX_DESC]}")
|
| 67 |
+
if self.source == "inferred":
|
| 68 |
+
lines.append(L[5])
|
| 69 |
+
return "\n".join(lines)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _walk_jsonld(node):
|
| 73 |
+
"""Yield every dict inside arbitrarily nested JSON-LD."""
|
| 74 |
+
if isinstance(node, dict):
|
| 75 |
+
yield node
|
| 76 |
+
for v in node.values():
|
| 77 |
+
yield from _walk_jsonld(v)
|
| 78 |
+
elif isinstance(node, list):
|
| 79 |
+
for v in node:
|
| 80 |
+
yield from _walk_jsonld(v)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _from_jsonld(soup: BeautifulSoup, url: str) -> Product | None:
|
| 84 |
+
for tag in soup.find_all("script", type="application/ld+json"):
|
| 85 |
+
try:
|
| 86 |
+
data = json.loads(tag.string or "")
|
| 87 |
+
except (json.JSONDecodeError, TypeError):
|
| 88 |
+
continue
|
| 89 |
+
for node in _walk_jsonld(data):
|
| 90 |
+
types = node.get("@type", "")
|
| 91 |
+
types = types if isinstance(types, list) else [types]
|
| 92 |
+
if "Product" not in types:
|
| 93 |
+
continue
|
| 94 |
+
offers = node.get("offers") or {}
|
| 95 |
+
if isinstance(offers, list):
|
| 96 |
+
offers = offers[0] if offers else {}
|
| 97 |
+
for o in _walk_jsonld(offers):
|
| 98 |
+
if o.get("price") or o.get("lowPrice"):
|
| 99 |
+
offers = o
|
| 100 |
+
break
|
| 101 |
+
brand = node.get("brand") or ""
|
| 102 |
+
if isinstance(brand, dict):
|
| 103 |
+
brand = brand.get("name", "")
|
| 104 |
+
rating = node.get("aggregateRating") or {}
|
| 105 |
+
rating_s = (
|
| 106 |
+
f"{rating.get('ratingValue', '')}/5 ({rating.get('reviewCount') or rating.get('ratingCount', '?')} 评价)"
|
| 107 |
+
if isinstance(rating, dict) and rating.get("ratingValue")
|
| 108 |
+
else ""
|
| 109 |
+
)
|
| 110 |
+
image = node.get("image", "")
|
| 111 |
+
if isinstance(image, list):
|
| 112 |
+
image = image[0] if image else ""
|
| 113 |
+
if isinstance(image, dict):
|
| 114 |
+
image = image.get("url", "")
|
| 115 |
+
name = node.get("name", "")
|
| 116 |
+
if not name:
|
| 117 |
+
continue
|
| 118 |
+
return Product(
|
| 119 |
+
name=str(name)[:120],
|
| 120 |
+
url=url,
|
| 121 |
+
price=str(offers.get("price") or offers.get("lowPrice") or ""),
|
| 122 |
+
currency=str(offers.get("priceCurrency", "")),
|
| 123 |
+
brand=str(brand)[:60],
|
| 124 |
+
description=re.sub(r"\s+", " ", str(node.get("description", "")))[:MAX_DESC],
|
| 125 |
+
rating=rating_s,
|
| 126 |
+
image=str(image),
|
| 127 |
+
)
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _meta(soup: BeautifulSoup, *props: str) -> str:
|
| 132 |
+
for p in props:
|
| 133 |
+
tag = soup.find("meta", attrs={"property": p}) or soup.find(
|
| 134 |
+
"meta", attrs={"name": p}
|
| 135 |
+
)
|
| 136 |
+
if tag and tag.get("content"):
|
| 137 |
+
return tag["content"].strip()
|
| 138 |
+
return ""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _from_meta(soup: BeautifulSoup, url: str) -> Product | None:
|
| 142 |
+
name = _meta(soup, "og:title", "twitter:title")
|
| 143 |
+
if not name and soup.title and soup.title.string:
|
| 144 |
+
name = soup.title.string.strip()
|
| 145 |
+
if not name:
|
| 146 |
+
return None
|
| 147 |
+
return Product(
|
| 148 |
+
name=name[:120],
|
| 149 |
+
url=url,
|
| 150 |
+
price=_meta(soup, "product:price:amount", "og:price:amount"),
|
| 151 |
+
currency=_meta(soup, "product:price:currency", "og:price:currency"),
|
| 152 |
+
brand=_meta(soup, "product:brand", "og:site_name"),
|
| 153 |
+
description=re.sub(
|
| 154 |
+
r"\s+", " ", _meta(soup, "og:description", "description", "twitter:description")
|
| 155 |
+
)[:MAX_DESC],
|
| 156 |
+
image=_meta(soup, "og:image", "twitter:image"),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _from_slug(url: str) -> Product:
|
| 161 |
+
path = unquote(urlparse(url).path)
|
| 162 |
+
slug = re.sub(r"[-_/+.]", " ", path)
|
| 163 |
+
slug = re.sub(r"\b(dp|gp|product|item|p|html?|www)\b", " ", slug, flags=re.I)
|
| 164 |
+
slug = re.sub(r"\s+", " ", slug).strip()
|
| 165 |
+
name = slug.title()[:120] or urlparse(url).netloc
|
| 166 |
+
return Product(name=name, url=url, source="inferred")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def fetch_product(url: str, timeout: float = 12.0) -> Product:
|
| 170 |
+
url = url.strip()
|
| 171 |
+
if not re.match(r"https?://", url):
|
| 172 |
+
url = "https://" + url
|
| 173 |
+
try:
|
| 174 |
+
with httpx.Client(
|
| 175 |
+
headers={"User-Agent": UA, "Accept-Language": "en;q=0.9, zh-CN;q=0.8"},
|
| 176 |
+
follow_redirects=True,
|
| 177 |
+
timeout=timeout,
|
| 178 |
+
) as client:
|
| 179 |
+
resp = client.get(url)
|
| 180 |
+
resp.raise_for_status()
|
| 181 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 182 |
+
except Exception:
|
| 183 |
+
return _from_slug(url)
|
| 184 |
+
prod = _from_jsonld(soup, url) or _from_meta(soup, url)
|
| 185 |
+
return prod if prod else _from_slug(url)
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0
|
| 2 |
+
httpx
|
| 3 |
+
beautifulsoup4
|
| 4 |
+
openai
|