| """Interactive inference demo for the LFM2 Transaction Foundation Model. |
| |
| Demonstrates multi-head predictions (fraud, next merchant, amount range, MCC) |
| on synthetic payment sequences. Includes side-by-side pretrained-vs-random-init |
| comparison showing the value of self-supervised pretraining. |
| |
| Usage: |
| python -m src.demo.app --checkpoint PATH [--data-dir PATH] [--port PORT] |
| |
| Runs on CPU. No GPU or internet required. Inference < 100ms per customer. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import time |
| from pathlib import Path |
| from typing import Any |
|
|
| import gradio as gr |
| import numpy as np |
| import torch |
| import torch.nn.functional as F |
|
|
| from src.data.schema import SchemaConfig, load_schema |
| from src.demo.decode import TransactionDecoder |
| from src.demo.merchant_catalog import DemoMerchantCatalog |
| from src.demo.profile_inference import format_profile_html, infer_profile |
| from src.demo.render import ( |
| format_amount_predictions, |
| format_fraud_score, |
| format_mcc_predictions, |
| format_merchant_predictions, |
| format_timeline, |
| render_comparison_header, |
| render_integration_guide, |
| render_production_architecture, |
| render_why_liquid, |
| ) |
| from src.model.lfm2_small import LFM2Small, ModelConfig |
| from src.model.task_heads import ( |
| AnyHead, |
| DownstreamHead, |
| HeadConfig, |
| MultiHeadModel, |
| TiedEmbeddingHead, |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class DemoData: |
| """Loads test-set sequences and labels into memory.""" |
|
|
| def __init__(self, data_dir: Path, schema: SchemaConfig) -> None: |
| token_ids = np.load(data_dir / "token_ids.npy", mmap_mode="r") |
| seq_labels = np.load(data_dir / "sequence_labels.npy") |
| splits = np.load(data_dir / "split_indices.npz") |
|
|
| test_idx = splits["test"] |
| self.token_ids = np.array(token_ids[test_idx]) |
| self.labels = seq_labels[test_idx].astype(int) |
| self.num_customers = len(test_idx) |
|
|
| self.fraud_indices = np.where(self.labels == 1)[0] |
| self.legit_indices = np.where(self.labels == 0)[0] |
|
|
| self._curated = self._find_curated_examples() |
|
|
| def _find_curated_examples(self) -> dict[str, int]: |
| """Pick interesting examples for quick navigation. |
| |
| Five legitimate profiles to show breadth of normal spending patterns, |
| three fraud profiles representing the main attack archetypes. |
| """ |
| examples: dict[str, int] = {} |
|
|
| |
| if len(self.legit_indices) > 0: |
| examples["Typical Customer"] = int(self.legit_indices[0]) |
| if len(self.legit_indices) > 100: |
| examples["Frequent Shopper"] = int(self.legit_indices[100]) |
| if len(self.legit_indices) > 200: |
| examples["Weekend Spender"] = int(self.legit_indices[200]) |
| if len(self.legit_indices) > 300: |
| examples["High-Spend Loyalist"] = int(self.legit_indices[300]) |
| if len(self.legit_indices) > 500: |
| examples["Low Activity"] = int(self.legit_indices[500]) |
|
|
| |
| if len(self.fraud_indices) > 0: |
| examples["Fraud: Card Testing"] = int(self.fraud_indices[0]) |
| if len(self.fraud_indices) > 50: |
| examples["Fraud: Account Takeover"] = int(self.fraud_indices[50]) |
| if len(self.fraud_indices) > 100: |
| examples["Fraud: High Value"] = int(self.fraud_indices[100]) |
|
|
| return examples |
|
|
| @property |
| def curated_names(self) -> list[str]: |
| return list(self._curated.keys()) |
|
|
| def get_curated_index(self, name: str) -> int: |
| return self._curated[name] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def build_model( |
| model_yaml: Path, |
| schema: SchemaConfig, |
| finetune_yaml: Path, |
| ) -> MultiHeadModel: |
| """Construct MultiHeadModel with 4 downstream heads.""" |
| import yaml |
|
|
| backbone = LFM2Small(ModelConfig.from_yaml(model_yaml), schema) |
|
|
| with open(finetune_yaml) as f: |
| ft_config = yaml.safe_load(f) |
|
|
| heads: dict[str, AnyHead] = {} |
| for name, hcfg in ft_config["heads"].items(): |
| config = HeadConfig( |
| name=name, |
| output_dim=hcfg["output_dim"], |
| loss_type=hcfg["loss"], |
| pool_strategy=hcfg["pool"], |
| target_type=hcfg["target"], |
| weight=hcfg.get("weight", 1.0), |
| mlp_hidden=hcfg.get("mlp_hidden", 128), |
| dropout=hcfg.get("dropout", 0.1), |
| ) |
| if hcfg.get("tied", False): |
| heads[name] = TiedEmbeddingHead( |
| config, backbone.config.hidden_size, schema.num_features, |
| backbone.embedding.value_tables, |
| ) |
| else: |
| heads[name] = DownstreamHead( |
| config, backbone.config.hidden_size, schema.num_features, |
| ) |
|
|
| return MultiHeadModel(backbone, heads) |
|
|
|
|
| def load_model_checkpoint(model: MultiHeadModel, checkpoint_path: Path | None) -> str: |
| """Load fine-tuned weights. Returns status message.""" |
| if checkpoint_path is None: |
| return "No checkpoint loaded" |
| if not checkpoint_path.exists(): |
| raise FileNotFoundError( |
| f"Checkpoint not found: {checkpoint_path}. " |
| f"Run with --checkpoint PATH or --checkpoint none to skip." |
| ) |
|
|
| ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) |
| model.load_state_dict(ckpt["model_state_dict"], strict=True) |
| step = ckpt.get("step", "?") |
| return f"step {step}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @torch.no_grad() |
| def run_inference( |
| model: MultiHeadModel, |
| token_ids: np.ndarray, |
| ) -> dict[str, np.ndarray]: |
| """Run all 4 heads on a single customer sequence.""" |
| tensor = torch.from_numpy(token_ids).unsqueeze(0).long() |
| predictions = model(tensor) |
|
|
| results: dict[str, np.ndarray] = {} |
| for name, logits in predictions.items(): |
| if name == "fraud": |
| prob = torch.sigmoid(logits).squeeze().numpy() |
| results[name] = np.array([float(prob)]) |
| else: |
| probs = F.softmax(logits, dim=-1).squeeze(0).numpy() |
| results[name] = probs |
|
|
| return results |
|
|
|
|
| |
| |
| |
|
|
|
|
| def create_app( |
| pretrained_model: MultiHeadModel, |
| random_model: MultiHeadModel, |
| data: DemoData, |
| decoder: TransactionDecoder, |
| merchant_catalog: DemoMerchantCatalog, |
| checkpoint_status: str, |
| ) -> gr.Blocks: |
| """Build the Gradio app with pretrained vs random-init comparison.""" |
|
|
| pretrained_model.eval() |
| random_model.eval() |
|
|
| def on_customer_select( |
| curated_name: str | None, |
| customer_idx: int, |
| mode: str, |
| ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]: |
| """Run both models, return all outputs for comparison.""" |
|
|
| if mode == "Curated examples" and curated_name: |
| idx = data.get_curated_index(curated_name) |
| else: |
| idx = int(customer_idx) |
|
|
| idx = max(0, min(idx, data.num_customers - 1)) |
| token_ids = data.token_ids[idx] |
| is_fraud = bool(data.labels[idx]) |
|
|
| summary = decoder.summarize_customer(token_ids, is_fraud) |
|
|
| t0 = time.perf_counter() |
| pre_results = run_inference(pretrained_model, token_ids) |
| rand_results = run_inference(random_model, token_ids) |
| latency_ms = (time.perf_counter() - t0) * 1000 |
|
|
| timeline_html = format_timeline(decoder, token_ids) |
|
|
| |
| pre_fraud = format_fraud_score(float(pre_results["fraud"][0])) |
| pre_merchant = format_merchant_predictions( |
| pre_results["next_merchant"], merchant_catalog, k=5, |
| ) |
| pre_amount = format_amount_predictions(pre_results["amount_range"], k=5) |
| pre_mcc = format_mcc_predictions(pre_results["mcc"], k=5) |
|
|
| |
| rand_fraud = format_fraud_score(float(rand_results["fraud"][0])) |
| rand_merchant = format_merchant_predictions( |
| rand_results["next_merchant"], merchant_catalog, k=5, |
| ) |
| rand_amount = format_amount_predictions(rand_results["amount_range"], k=5) |
| rand_mcc = format_mcc_predictions(rand_results["mcc"], k=5) |
|
|
| |
| profile_match = infer_profile(token_ids) |
| profile_html = format_profile_html(profile_match) |
|
|
| |
| fraud_compare = _side_by_side("Fraud Score", pre_fraud, rand_fraud) |
| merchant_compare = _side_by_side("Next Merchant", pre_merchant, rand_merchant) |
| amount_compare = _side_by_side("Amount Range", pre_amount, rand_amount) |
| mcc_compare = _side_by_side("Merchant Category", pre_mcc, rand_mcc) |
|
|
| latency_str = ( |
| f"Inference: {latency_ms:.1f}ms (both models) on CPU | " |
| f"Customer #{idx} | Ground truth: {'FRAUD' if is_fraud else 'Legitimate'}" |
| ) |
|
|
| return ( |
| summary, timeline_html, profile_html, |
| fraud_compare, merchant_compare, amount_compare, mcc_compare, |
| latency_str, |
| ) |
|
|
| with gr.Blocks( |
| title="LFM2 Transaction Foundation Model", |
| ) as app: |
| gr.HTML(""" |
| <div style="text-align: center; margin-bottom: 16px;"> |
| <h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #171717; |
| letter-spacing: -0.02em;"> |
| LFM2 Transaction Foundation Model |
| </h1> |
| <p style="color: #737373; margin: 6px 0 0 0; font-size: 13px; |
| font-family: JetBrains Mono, ui-monospace, monospace;"> |
| Liquid AI · LFM2.5 Architecture · Multi-Head Inference Demo |
| </p> |
| </div> |
| """) |
|
|
| with gr.Tabs(): |
| |
| with gr.Tab("Predictions"): |
| gr.HTML(""" |
| <div style="padding: 10px 14px; background: #ffffff; border: 1px solid rgba(0,0,0,0.1); |
| border-radius: 12px; margin: 8px 0; font-size: 12px; color: #525252;"> |
| <b style="color: #171717;">How to read this:</b> Select a customer, see their |
| transaction history, then compare predictions from the pretrained model (left) |
| vs random initialization (right). Same architecture, same fine-tuning data. |
| The only difference is self-supervised pretraining on unlabeled sequences. |
| </div> |
| """) |
| with gr.Accordion("Reference model details", open=False): |
| gr.HTML(f""" |
| <div style="padding: 8px 12px; font-family: JetBrains Mono, ui-monospace, monospace; |
| font-size: 11px; color: #525252; display: flex; gap: 20px; flex-wrap: wrap;"> |
| <span>arch: <b style="color: #171717;">LFM2-small</b> 9.8M params</span> |
| <span>layers: <b style="color: #10B981;">5 conv</b> + <b style="color: #7c3aed;">3 attn</b></span> |
| <span>input: 64 tx × 15 feat = 960 tokens</span> |
| <span>checkpoint: <b style="color: #171717;">{checkpoint_status}</b></span> |
| <span>data: 200K synthetic sequences, 15 features/tx</span> |
| </div> |
| """) |
|
|
| |
| |
| |
| _CURATED_MODE = "Curated examples" |
| _BROWSE_MODE = "Browse all customers" |
| with gr.Row(): |
| with gr.Column(scale=1): |
| gr.HTML("<h3 style='margin: 0 0 8px 0;'>Select Customer</h3>") |
| selection_mode = gr.Radio( |
| choices=[_CURATED_MODE, _BROWSE_MODE], |
| value=_CURATED_MODE, |
| label="Selection mode", |
| info="Curated: hand-picked legitimate and fraud examples. " |
| "Browse: pick any of 20,000 test customers by index.", |
| elem_classes="liquid-radio", |
| ) |
| curated_dropdown = gr.Dropdown( |
| choices=data.curated_names, |
| value=data.curated_names[0] if data.curated_names else None, |
| label="Curated Examples", |
| info="5 legitimate profiles, 3 fraud archetypes", |
| ) |
| customer_slider = gr.Slider( |
| minimum=0, |
| maximum=data.num_customers - 1, |
| step=1, |
| value=0, |
| label=f"Customer Index (0-{data.num_customers - 1})", |
| info=f"Direct access to any of {data.num_customers:,} test-set customers. " |
| f"~3.7% are fraud, rest are legitimate.", |
| visible=False, |
| ) |
| run_btn = gr.Button( |
| "Run Inference", variant="primary", size="lg", |
| elem_id="run-inference-btn", |
| ) |
|
|
| with gr.Column(scale=2): |
| summary_text = gr.Textbox( |
| label="Customer Profile", interactive=False, lines=2, |
| ) |
| profile_output = gr.HTML(label="Behavioral Profile") |
| latency_text = gr.Textbox( |
| label="Performance", interactive=False, lines=1, |
| ) |
|
|
| def toggle_selector(mode: str) -> tuple[Any, Any]: |
| use_cur = (mode == _CURATED_MODE) |
| return gr.update(visible=use_cur), gr.update(visible=not use_cur) |
|
|
| selection_mode.change( |
| toggle_selector, inputs=[selection_mode], |
| outputs=[curated_dropdown, customer_slider], |
| ) |
|
|
| |
| gr.HTML("""<h3 style='margin: 16px 0 8px 0; color: #171717;'>Transaction History</h3> |
| <div style="font-size: 11px; color: #737373; margin-bottom: 4px;"> |
| 64 most recent transactions. Tx 63 (highlighted) is the most recent. |
| The model predicts what comes next based on this full sequence. |
| </div>""") |
| timeline_output = gr.HTML() |
|
|
| |
| gr.HTML("<h3 style='margin: 16px 0 4px 0; color: #171717; font-size: 18px; font-weight: 600; letter-spacing: -0.01em;'>Model Predictions: Pretrained vs Random Init</h3>") |
| gr.HTML(render_comparison_header()) |
|
|
| fraud_output = gr.HTML() |
| merchant_output = gr.HTML() |
| amount_output = gr.HTML() |
| mcc_output = gr.HTML() |
|
|
| |
| outputs = [ |
| summary_text, timeline_output, profile_output, |
| fraud_output, merchant_output, amount_output, mcc_output, |
| latency_text, |
| ] |
|
|
| run_btn.click( |
| on_customer_select, |
| inputs=[curated_dropdown, customer_slider, selection_mode], |
| outputs=outputs, |
| ) |
| curated_dropdown.change( |
| on_customer_select, |
| inputs=[curated_dropdown, customer_slider, selection_mode], |
| outputs=outputs, |
| ) |
|
|
| |
| with gr.Tab("Architecture"): |
| gr.HTML(render_production_architecture()) |
|
|
| |
| with gr.Tab("Why Liquid AI"): |
| gr.HTML(render_why_liquid()) |
|
|
| |
| with gr.Tab("Integration"): |
| gr.HTML(render_integration_guide()) |
|
|
| return app |
|
|
|
|
| def _side_by_side(title: str, pretrained_html: str, random_html: str) -> str: |
| """Render pretrained vs random-init predictions side by side.""" |
| _mono = "JetBrains Mono, ui-monospace, monospace" |
| return f""" |
| <div style="margin-bottom: 16px;"> |
| <div style="font-size: 13px; font-weight: 600; color: #171717; margin-bottom: 8px; |
| letter-spacing: -0.01em;"> |
| {title} |
| </div> |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;"> |
| <div style="background: #ffffff; border: 1px solid rgba(16,185,129,0.25); |
| border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);"> |
| <div style="font-family: {_mono}; font-size: 10px; color: #10B981; |
| font-weight: 600; margin-bottom: 8px; text-transform: uppercase; |
| letter-spacing: 0.05em;"> |
| ✓ Pretrained |
| </div> |
| {pretrained_html} |
| </div> |
| <div style="background: #ffffff; border: 1px solid rgba(0,0,0,0.08); |
| border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);"> |
| <div style="font-family: {_mono}; font-size: 10px; color: #a3a3a3; |
| font-weight: 600; margin-bottom: 8px; text-transform: uppercase; |
| letter-spacing: 0.05em;"> |
| ✗ Random Init |
| </div> |
| {random_html} |
| </div> |
| </div> |
| </div> |
| """ |
|
|
|
|
|
|
| |
| |
| |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| description="LFM2 Transaction Foundation Model — Interactive Inference Demo", |
| ) |
| parser.add_argument( |
| "--checkpoint", type=Path, |
| default=Path("experiments/v2_tied/finetune_20260516_190905/checkpoints/step_004999.pt"), |
| help="Path to fine-tuned MultiHeadModel checkpoint (.pt file)", |
| ) |
| parser.add_argument( |
| "--data-dir", type=Path, default=Path("data/synthetic"), |
| help="Directory containing token_ids.npy, sequence_labels.npy, split_indices.npz", |
| ) |
| parser.add_argument( |
| "--model-config", type=Path, default=Path("configs/model.yaml"), |
| help="Model backbone YAML config", |
| ) |
| parser.add_argument( |
| "--schema", type=Path, default=Path("data/schema.yaml"), |
| help="Feature schema YAML", |
| ) |
| parser.add_argument( |
| "--finetune-config", type=Path, |
| default=Path("experiments/v2_tied/finetune_20260516_190905/finetune_config.yaml"), |
| help="Fine-tune head config YAML", |
| ) |
| parser.add_argument("--port", type=int, default=7860) |
| parser.add_argument("--share", action="store_true", help="Create public Gradio link") |
|
|
| args = parser.parse_args() |
|
|
| print("Loading schema and merchant catalog...") |
| schema = load_schema(args.schema) |
| merchant_catalog = DemoMerchantCatalog(schema) |
|
|
| print("Loading test data...") |
| demo_data = DemoData(args.data_dir, schema) |
| print(f" {demo_data.num_customers} test customers " |
| f"({len(demo_data.fraud_indices)} fraud, {len(demo_data.legit_indices)} legitimate)") |
|
|
| print("Building pretrained model...") |
| pretrained_model = build_model(args.model_config, schema, args.finetune_config) |
| checkpoint_status = load_model_checkpoint(pretrained_model, args.checkpoint) |
| print(f" Pretrained: {checkpoint_status}") |
|
|
| print("Building random-init baseline model...") |
| random_model = build_model(args.model_config, schema, args.finetune_config) |
| print(" Random-init: fresh weights (no checkpoint)") |
|
|
| total_params = sum(p.numel() for p in pretrained_model.parameters()) |
| print(f" Parameters per model: {total_params:,}") |
|
|
| print("Building decoder...") |
| decoder = TransactionDecoder(schema, merchant_catalog) |
|
|
| print("Launching demo...") |
| app = create_app( |
| pretrained_model, random_model, demo_data, |
| decoder, merchant_catalog, checkpoint_status, |
| ) |
| _liquid_theme = gr.themes.Soft( |
| primary_hue="neutral", |
| secondary_hue="neutral", |
| neutral_hue="neutral", |
| font=gr.themes.GoogleFont("Inter"), |
| font_mono=gr.themes.GoogleFont("JetBrains Mono"), |
| ).set( |
| body_background_fill="#f5f5f5", |
| body_background_fill_dark="#f5f5f5", |
| body_text_color="#171717", |
| body_text_color_dark="#171717", |
| body_text_color_subdued="#737373", |
| body_text_color_subdued_dark="#737373", |
| block_background_fill="#ffffff", |
| block_background_fill_dark="#ffffff", |
| block_border_color="rgba(0,0,0,0.1)", |
| block_border_color_dark="rgba(0,0,0,0.1)", |
| block_label_background_fill="#f5f5f5", |
| block_label_background_fill_dark="#f5f5f5", |
| block_label_text_color="#525252", |
| block_label_text_color_dark="#525252", |
| block_title_text_color="#171717", |
| block_title_text_color_dark="#171717", |
| block_shadow="0 1px 3px rgba(0,0,0,0.04)", |
| block_shadow_dark="0 1px 3px rgba(0,0,0,0.04)", |
| input_background_fill="#ffffff", |
| input_background_fill_dark="#ffffff", |
| input_background_fill_focus="#ffffff", |
| input_background_fill_focus_dark="#ffffff", |
| input_border_color="rgba(0,0,0,0.1)", |
| input_border_color_dark="rgba(0,0,0,0.1)", |
| input_border_color_focus="#171717", |
| input_border_color_focus_dark="#171717", |
| input_placeholder_color="#a3a3a3", |
| input_placeholder_color_dark="#a3a3a3", |
| panel_background_fill="#fafafa", |
| panel_background_fill_dark="#fafafa", |
| panel_border_color="rgba(0,0,0,0.06)", |
| panel_border_color_dark="rgba(0,0,0,0.06)", |
| border_color_primary="rgba(0,0,0,0.1)", |
| border_color_primary_dark="rgba(0,0,0,0.1)", |
| button_primary_background_fill="#171717", |
| button_primary_background_fill_dark="#171717", |
| button_primary_background_fill_hover="#404040", |
| button_primary_background_fill_hover_dark="#404040", |
| button_primary_text_color="#ffffff", |
| button_primary_text_color_dark="#ffffff", |
| button_secondary_background_fill="#ffffff", |
| button_secondary_background_fill_dark="#ffffff", |
| button_secondary_text_color="#525252", |
| button_secondary_text_color_dark="#525252", |
| button_secondary_border_color="rgba(0,0,0,0.1)", |
| button_secondary_border_color_dark="rgba(0,0,0,0.1)", |
| checkbox_background_color="#ffffff", |
| checkbox_background_color_dark="#ffffff", |
| checkbox_border_color="rgba(0,0,0,0.2)", |
| checkbox_border_color_dark="rgba(0,0,0,0.2)", |
| checkbox_background_color_selected="#171717", |
| checkbox_background_color_selected_dark="#171717", |
| checkbox_label_background_fill="#ffffff", |
| checkbox_label_background_fill_dark="#ffffff", |
| checkbox_label_text_color="#171717", |
| checkbox_label_text_color_dark="#171717", |
| slider_color="#171717", |
| slider_color_dark="#171717", |
| table_border_color="rgba(0,0,0,0.06)", |
| table_border_color_dark="rgba(0,0,0,0.06)", |
| table_even_background_fill="#fafafa", |
| table_even_background_fill_dark="#fafafa", |
| table_odd_background_fill="#ffffff", |
| table_odd_background_fill_dark="#ffffff", |
| shadow_spread="0px", |
| shadow_spread_dark="0px", |
| color_accent_soft="rgba(0,0,0,0.04)", |
| color_accent_soft_dark="rgba(0,0,0,0.04)", |
| ) |
|
|
| _liquid_css = """ |
| /* Force light mode regardless of system preference */ |
| :root, .dark { color-scheme: light !important; } |
| |
| .gradio-container { |
| background: #f5f5f5 !important; |
| max-width: 1280px !important; |
| margin: auto !important; |
| padding: 1.5rem !important; |
| } |
| |
| /* Tabs: pill-style matching Liquid design system */ |
| .tabs { background: transparent !important; } |
| .tab-nav { |
| background: #f5f5f5 !important; |
| border: none !important; |
| border-bottom: 1px solid rgba(0,0,0,0.1) !important; |
| gap: 4px !important; |
| padding: 4px 0 !important; |
| } |
| .tab-nav button { |
| font-weight: 500 !important; |
| font-size: 14px !important; |
| color: #737373 !important; |
| background: transparent !important; |
| border: none !important; |
| border-bottom: 2px solid transparent !important; |
| padding: 8px 16px !important; |
| border-radius: 0 !important; |
| transition: all 0.15s ease !important; |
| } |
| .tab-nav button:hover { |
| color: #171717 !important; |
| background: rgba(0,0,0,0.03) !important; |
| } |
| .tab-nav button.selected { |
| color: #171717 !important; |
| font-weight: 600 !important; |
| border-bottom: 2px solid #171717 !important; |
| background: transparent !important; |
| } |
| |
| /* Block/component overrides */ |
| .block { border-radius: 12px !important; } |
| .block.padded { background: #ffffff !important; } |
| |
| /* Input, textarea, dropdown */ |
| input, textarea, select, .wrap { |
| background: #ffffff !important; |
| color: #171717 !important; |
| border-color: rgba(0,0,0,0.1) !important; |
| } |
| .secondary-wrap, .wrap-inner { |
| background: #ffffff !important; |
| } |
| |
| /* Labels */ |
| label, .label-wrap, span.svelte-1gfkn6j { |
| color: #171717 !important; |
| } |
| .info { |
| color: #737373 !important; |
| } |
| |
| /* Action buttons: pill style applied only to explicit primary actions. |
| Scoped by elem_id so it doesn't bleed into Gradio radio/checkbox |
| options (which Gradio also renders as <button class="primary">). */ |
| #run-inference-btn button, |
| button#run-inference-btn { |
| background: #171717 !important; |
| color: #ffffff !important; |
| border-radius: 9999px !important; |
| border: none !important; |
| font-weight: 500 !important; |
| letter-spacing: -0.02em !important; |
| } |
| #run-inference-btn button:hover, |
| button#run-inference-btn:hover { |
| background: #404040 !important; |
| } |
| |
| /* Radio group: render as a clean vertical list with native circle |
| indicators. Scoped via elem_classes="liquid-radio" so we can target |
| reliably without depending on Gradio's internal Svelte-hashed class |
| names. Defeats Gradio's default of styling the selected option as a |
| depressed dark button -- a form selection should not look like an |
| action button. */ |
| .liquid-radio, |
| .liquid-radio > * { |
| background: transparent !important; |
| border: none !important; |
| box-shadow: none !important; |
| } |
| .liquid-radio .wrap, |
| .liquid-radio fieldset { |
| display: flex !important; |
| flex-direction: column !important; |
| gap: 2px !important; |
| padding: 0 !important; |
| } |
| /* Each option label -- override Gradio's button-like rendering */ |
| .liquid-radio label { |
| display: flex !important; |
| align-items: center !important; |
| gap: 10px !important; |
| padding: 8px 10px !important; |
| background: transparent !important; |
| background-color: transparent !important; |
| background-image: none !important; |
| border: none !important; |
| border-radius: 8px !important; |
| cursor: pointer !important; |
| font-size: 13px !important; |
| font-weight: 400 !important; |
| color: #171717 !important; |
| box-shadow: none !important; |
| transition: background 0.1s ease !important; |
| } |
| .liquid-radio label:hover { |
| background: rgba(0,0,0,0.04) !important; |
| } |
| /* The radio input itself -- native circle, dark fill when checked */ |
| .liquid-radio input[type="radio"] { |
| appearance: auto !important; |
| -webkit-appearance: radio !important; |
| accent-color: #171717 !important; |
| width: 16px !important; |
| height: 16px !important; |
| min-width: 16px !important; |
| margin: 0 !important; |
| cursor: pointer !important; |
| opacity: 1 !important; |
| } |
| /* Selected option: subtle background tint + slightly bolder text. |
| Three selectors because different Gradio versions mark the selected |
| option differently: .selected class, [aria-checked="true"], or |
| :has(input:checked). */ |
| .liquid-radio label.selected, |
| .liquid-radio label[aria-checked="true"], |
| .liquid-radio label:has(input:checked) { |
| background: rgba(0,0,0,0.04) !important; |
| color: #171717 !important; |
| font-weight: 500 !important; |
| } |
| /* Kill any inherited button.primary/button.secondary styling that |
| Gradio may apply to radio option wrappers in some versions. */ |
| .liquid-radio button, |
| .liquid-radio button.primary, |
| .liquid-radio button.secondary { |
| background: transparent !important; |
| color: #171717 !important; |
| border: none !important; |
| border-radius: 8px !important; |
| box-shadow: none !important; |
| font-weight: 400 !important; |
| text-align: left !important; |
| justify-content: flex-start !important; |
| } |
| .liquid-radio button.primary, |
| .liquid-radio button.selected { |
| background: rgba(0,0,0,0.04) !important; |
| font-weight: 500 !important; |
| } |
| |
| /* Checkbox */ |
| .checkbox-item { color: #171717 !important; } |
| |
| /* Dropdown */ |
| .dropdown-arrow { color: #525252 !important; } |
| ul.options { background: #ffffff !important; border-color: rgba(0,0,0,0.1) !important; } |
| ul.options li { color: #171717 !important; } |
| ul.options li:hover, ul.options li.selected { |
| background: #f5f5f5 !important; |
| } |
| |
| /* Textbox display (non-editable) */ |
| .textbox textarea[disabled], .textbox input[disabled] { |
| background: #fafafa !important; |
| color: #171717 !important; |
| opacity: 1 !important; |
| } |
| |
| /* Remove dark shadows/borders */ |
| .block { box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important; } |
| |
| /* Accordion/group headers */ |
| .form { background: #ffffff !important; border-color: rgba(0,0,0,0.06) !important; } |
| |
| /* Override any remaining dark backgrounds */ |
| [class*="dark:"], .dark * { |
| --tw-bg-opacity: 1 !important; |
| } |
| """ |
|
|
| _force_light_js = """ |
| () => { |
| document.documentElement.classList.remove('dark'); |
| document.documentElement.style.colorScheme = 'light'; |
| const meta = document.createElement('meta'); |
| meta.name = 'color-scheme'; |
| meta.content = 'light'; |
| document.head.appendChild(meta); |
| } |
| """ |
|
|
| |
| |
| |
| for port in range(args.port, args.port + 10): |
| try: |
| app.launch( |
| server_port=port, |
| share=args.share, |
| theme=_liquid_theme, |
| css=_liquid_css, |
| js=_force_light_js, |
| ) |
| break |
| except OSError as e: |
| if "Cannot find empty port" in str(e) or "Address already in use" in str(e): |
| print(f" port {port} in use, trying {port + 1}...") |
| continue |
| raise |
| else: |
| raise RuntimeError( |
| f"No free port in range {args.port}-{args.port + 9}. " |
| f"Kill stale processes: lsof -ti:{args.port} | xargs kill" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|