"""Cardboard Chat — a Pokémon card investment advisor Space. Serves the CardQwen-7B fine-tune (Qwen2.5-7B-Instruct) on ZeroGPU. Consent model: * "18 or older" + "not financial advice" are REQUIRED to chat. * Data collection is OPTIONAL — a separate, unchecked-by-default checkbox. Only when ticked do we log the exchange (keyed by a per-session deletion ID). ZeroGPU notes: * `import spaces` must precede torch/transformers (it patches torch.cuda.*). * The model is loaded once at module scope and placed on CUDA eagerly, so weights are NOT reloaded per call — only the decorated function runs on the real GPU. """ import json import os import threading import uuid from datetime import datetime, timezone import spaces # noqa: F401 (must come before torch/transformers on ZeroGPU) import gradio as gr import torch from huggingface_hub import HfApi, create_repo from transformers import AutoModelForCausalLM, AutoTokenizer # --------------------------------------------------------------------------- # # Configuration # --------------------------------------------------------------------------- # MODEL_ID = "gramajo/CardQwen-7B" DATASET_REPO = "gramajo/cardboard-chat-logs" # Space secret: READ on MODEL_ID + WRITE on DATASET_REPO. HF_TOKEN = os.environ.get("HF_TOKEN") # System prompt — the Cardboard Chad persona. Keep byte-identical to the # deployed config so generation stays consistent. SYSTEM_PROMPT = """You are Cardboard Chad, a sharp and funny Pokémon card investing buddy for the Cardboard community. You know sets, print runs, grading, sealed-vs-singles, and what actually moves prices. Talk like a knowledgeable friend at the card shop, not a financial advisor. Be concise — a few sentences, not an essay. Have opinions and defend them, but always land on the truth: the "right" move depends on the person's budget, risk tolerance, and how much they're in it for love vs. money. When it matters, ask what they're working with before giving a call. You're here to help people think, not to promise returns. Card prices are volatile and nobody knows the future. Keep it fun, keep it real, and never pretend a coin-flip is a sure thing.""" # Production generation settings (from the fine-tune config). GENERATION_KWARGS = dict( max_new_tokens=256, do_sample=True, temperature=0.35, ) LOG_FILE = "conversations.jsonl" # --------------------------------------------------------------------------- # # Model — loaded once, placed on CUDA at startup (ZeroGPU convention) # --------------------------------------------------------------------------- # tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN) model = AutoModelForCausalLM.from_pretrained( MODEL_ID, token=HF_TOKEN, dtype=torch.float16, ).to("cuda") model.eval() # --------------------------------------------------------------------------- # # Generation # --------------------------------------------------------------------------- # @spaces.GPU def generate(messages: list) -> str: """Run the model over a full message list (system + turns) → reply text.""" prompt = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, ) inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): out = model.generate( **inputs, pad_token_id=tokenizer.eos_token_id, **GENERATION_KWARGS, ) new_tokens = out[0][inputs["input_ids"].shape[1]:] return tokenizer.decode(new_tokens, skip_special_tokens=True).strip() # --------------------------------------------------------------------------- # # Optional, consent-gated logging → private HF Dataset repo # --------------------------------------------------------------------------- # _log_lock = threading.Lock() _repo_ready = False def _ensure_repo() -> None: global _repo_ready if _repo_ready: return create_repo( DATASET_REPO, repo_type="dataset", token=HF_TOKEN, exist_ok=True, private=True, ) _repo_ready = True def _log_row(session_id: str, hf_username: str | None, prompt: str, response: str) -> None: row = { "session_id": session_id, "hf_username": hf_username, "timestamp_utc": datetime.now(timezone.utc).isoformat(), "prompt": prompt, "response": response, } # 1) Append locally (fast + synchronous) so we never block the chat. with open(LOG_FILE, "a") as f: f.write(json.dumps(row) + "\n") # 2) Push the whole growing JSONL to the Hub in the background. def _push() -> None: with _log_lock: try: _ensure_repo() HfApi(token=HF_TOKEN).upload_file( path_or_fileobj=LOG_FILE, path_in_repo="train.jsonl", repo_id=DATASET_REPO, repo_type="dataset", ) except Exception: pass # a failed push must never crash the chat threading.Thread(target=_push, daemon=True).start() def _log_conversation(session_id, hf_username, prompt, response) -> None: try: _log_row(session_id, hf_username, prompt, response) except Exception: pass # --------------------------------------------------------------------------- # # Gradio UI # --------------------------------------------------------------------------- # def _content_text(content) -> str: """Gradio 6 stores message content as a list of parts; the chat template needs a str.""" if isinstance(content, str): return content if isinstance(content, list): return "".join( part.get("text", "") if isinstance(part, dict) else str(part) for part in content ) return "" def build_messages(history: list, message: str) -> list: msgs = [{"role": "system", "content": SYSTEM_PROMPT}] for m in history or []: if not isinstance(m, dict): continue role = m.get("role") text = _content_text(m.get("content")).strip() if role in ("user", "assistant") and text: msgs.append({"role": role, "content": text}) msgs.append({"role": "user", "content": message}) return msgs def respond(message, history, age_ok, advice_ok, consent_ok, session_id, request: gr.Request): history = list(history or []) message = (message or "").strip() if not message: return history, "" # Defensive re-check (UI already disables chat until these are ticked). if not (age_ok and advice_ok): reply = "Please tick the “18 or older” and “not financial advice” boxes above first." history += [ {"role": "user", "content": message}, {"role": "assistant", "content": reply}, ] return history, "" reply = generate(build_messages(history, message)) history += [ {"role": "user", "content": message}, {"role": "assistant", "content": reply}, ] if consent_ok: # username is None for anonymous visitors on a public Space; the # session_id (shown in the UI) is the reliable deletion key. hf_username = getattr(request, "username", None) if request else None _log_conversation(session_id, hf_username, message, reply) return history, "" def init_session(): sid = str(uuid.uuid4()) return sid, f"**Deletion ID:** `{sid}` — save this to request removal of any stored chats." with gr.Blocks( title="Cardboard Chat", ) as demo: gr.Markdown( """ # 🃏 Cardboard Chat **Pokémon card investment advisor** — ask about sealed sets, singles, grading, and market timing. Powered by a fine-tuned Qwen2.5-7B. """ ) with gr.Group(): age_ok = gr.Checkbox(label="I am 18 or older.", value=False) advice_ok = gr.Checkbox( label="I understand this is not financial advice.", value=False ) consent_ok = gr.Checkbox( label=( "I consent to my questions and the model's replies being stored " "to improve the model (optional — see privacy note)." ), value=False, ) sid_display = gr.Markdown("") session_id = gr.State() chatbot = gr.Chatbot(label="CardQwen-7B", height=480) msg = gr.Textbox( label="Message", placeholder="Which Scarlet & Violet sets are worth holding sealed?", interactive=False, ) submit_btn = gr.Button("Send", variant="primary", interactive=False) gr.Examples( examples=[ "Which Scarlet & Violet sets are worth holding sealed?", "Should I grade a Base Set Charizard?", "What's a smart entry point for modern singles right now?", "Is it better to hold sealed booster boxes or chase singles?", ], inputs=msg, ) def unlock(age, advice): on = bool(age and advice) return gr.update(interactive=on), gr.update(interactive=on) age_ok.change(unlock, inputs=[age_ok, advice_ok], outputs=[msg, submit_btn]) advice_ok.change(unlock, inputs=[age_ok, advice_ok], outputs=[msg, submit_btn]) demo.load(init_session, outputs=[session_id, sid_display]) submit_btn.click( respond, inputs=[msg, chatbot, age_ok, advice_ok, consent_ok, session_id], outputs=[chatbot, msg], ) msg.submit( respond, inputs=[msg, chatbot, age_ok, advice_ok, consent_ok, session_id], outputs=[chatbot, msg], ) demo.launch(theme=gr.themes.Soft(primary_hue="red", neutral_hue="slate"))