Zhenzewu commited on
Commit
08dd3de
·
verified ·
1 Parent(s): 3f84f1a

CartBrawl: cart items brawl for your wallet

Browse files
README.md CHANGED
@@ -1,13 +1,64 @@
1
  ---
2
- title: Cartbrawl
3
- emoji: 🏃
4
- colorFrom: purple
5
- colorTo: indigo
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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