Spaces:
Running
Running
| """Comic panel search UI.""" | |
| import os | |
| import sys | |
| from pathlib import Path | |
| import streamlit as st | |
| from dotenv import load_dotenv | |
| ROOT = Path(__file__).parent | |
| sys.path.insert(0, str(ROOT)) | |
| load_dotenv(ROOT / ".env") | |
| from src.storage.images import image_src | |
| st.set_page_config( | |
| page_title="Comic Panel Search", | |
| page_icon="π₯", | |
| layout="wide", | |
| initial_sidebar_state="collapsed", | |
| ) | |
| st.markdown(""" | |
| <style> | |
| /* corner radius for custom HTML; keep in sync with theme.baseRadius in .streamlit/config.toml */ | |
| :root { --radius: 2px; } | |
| #MainMenu, footer, [data-testid="collapsedControl"] { visibility: hidden; } | |
| .block-container { padding-top: 1.5rem !important; max-width: 1400px !important; } | |
| /* hero header β margin-top keeps the title clear of Streamlit's fixed toolbar */ | |
| .hero { margin-top: 2.25rem; padding-bottom: 14px; border-bottom: 1px solid #e5e5e5; } | |
| .hero-title { font-size: 26px; font-weight: 700; color: #0a0a0a; line-height: 1.2; } | |
| .hero-tag { font-size: 14px; color: #404040; margin-top: 4px; } | |
| .hero-caps { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 10px; } | |
| .hero-chip { | |
| font-size: 11px; font-weight: 600; color: #002BFF; | |
| background: rgba(0,43,255,0.06); border-radius: var(--radius); | |
| padding: 3px 8px; cursor: default; | |
| } | |
| .hero-meta { font-size: 11px; color: #737373; margin-left: auto; } | |
| /* example strip: equal-width chips; extra margin separates it from the hero rule */ | |
| div.st-key-strip { margin-top: 12px; } | |
| /* "Try:" label β Streamlit vertically centers the column by its wrapper box | |
| (~10px) while the label's line box (~26px) overflows below it; nudge the | |
| text up half the overflow so it sits on the chips' true centerline */ | |
| div.st-key-strip [data-testid="stColumn"]:first-of-type .section-label { | |
| transform: translateY(-8px); | |
| } | |
| div[class*="st-key-strip_"] button { | |
| min-height: 30px !important; | |
| padding: 2px 12px !important; | |
| } | |
| div[class*="st-key-strip_"] button p { | |
| font-size: 12px !important; | |
| white-space: nowrap !important; | |
| } | |
| /* result card */ | |
| .rc { | |
| background: #fff; | |
| border: 1px solid #e5e5e5; | |
| border-radius: var(--radius); | |
| padding: 18px 20px; | |
| display: grid; | |
| grid-template-columns: 62px 1fr; | |
| gap: 18px; | |
| margin-bottom: 10px; | |
| } | |
| .rc:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.07); } | |
| .rc-left { | |
| display: flex; flex-direction: column; | |
| align-items: center; gap: 8px; padding-top: 2px; | |
| } | |
| .rc-rank { | |
| width: 40px; height: 40px; | |
| background: rgba(0,43,255,0.05); border-radius: var(--radius); | |
| display: flex; align-items: center; justify-content: center; | |
| font-weight: 700; font-size: 15px; color: #0a0a0a; flex-shrink: 0; | |
| } | |
| .rc-score-lbl { | |
| font-size: 10px; font-weight: 600; text-transform: uppercase; | |
| letter-spacing: 0.06em; color: #737373; text-align: center; | |
| } | |
| .rc-score-val { font-family: monospace; font-size: 12px; color: #0a0a0a; text-align: center; } | |
| .rc-tag { | |
| font-size: 10px; background: rgba(0,43,255,0.06); | |
| color: #002BFF; border-radius: var(--radius); | |
| padding: 2px 5px; font-weight: 600; display: inline-block; margin: 1px; | |
| } | |
| .rc-right { display: flex; flex-direction: column; gap: 8px; min-width: 0; } | |
| /* "See more like this" button β top of right column, styled as a link */ | |
| div[data-testid="stHorizontalBlock"]:has(.rc) > div[data-testid="column"]:last-child button { | |
| background: none !important; border: none !important; | |
| box-shadow: none !important; padding: 0 !important; | |
| min-height: unset !important; height: auto !important; | |
| } | |
| div[data-testid="stHorizontalBlock"]:has(.rc) > div[data-testid="column"]:last-child button p { | |
| color: #002BFF !important; text-decoration: underline !important; | |
| font-size: 12px !important; white-space: nowrap !important; margin: 0 !important; | |
| } | |
| div[data-testid="stHorizontalBlock"]:has(.rc) > div[data-testid="column"]:last-child button:hover p { | |
| color: #0020CC !important; | |
| } | |
| /* panel action buttons (See more / Find sounds): identical, centered */ | |
| div[class*="st-key-sim_"] button, | |
| div[class*="st-key-snd_"] button { | |
| width: 100% !important; | |
| } | |
| div[class*="st-key-sim_"] button p, | |
| div[class*="st-key-snd_"] button p { | |
| font-size: 14px !important; | |
| white-space: normal !important; | |
| text-align: center !important; | |
| width: 100%; | |
| margin: 0 auto !important; | |
| } | |
| .rc-img { | |
| max-width: 100%; max-height: 65vh; | |
| width: auto; height: auto; | |
| border-radius: var(--radius); display: block; | |
| } | |
| .rc-id { | |
| font-family: monospace; font-size: 13px; | |
| font-weight: 600; color: #0a0a0a; | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .rc-field { | |
| display: grid; grid-template-columns: 95px 1fr; | |
| gap: 6px; font-size: 12px; line-height: 1.5; | |
| } | |
| .rc-key { | |
| color: #737373; font-family: monospace; | |
| font-size: 11px; overflow: hidden; | |
| text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .rc-val { | |
| color: #0a0a0a; word-break: break-word; | |
| } | |
| .rh { | |
| font-size: 13px; color: #737373; | |
| padding-bottom: 12px; | |
| border-bottom: 1px solid #e5e5e5; | |
| margin-bottom: 14px; | |
| } | |
| .rh strong { color: #0a0a0a; } | |
| .ph { | |
| text-align: center; padding: 72px 0; | |
| color: #737373; font-size: 14px; | |
| } | |
| .section-label { | |
| font-size: 11px; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.08em; | |
| color: #737373; margin: 0; white-space: nowrap; | |
| } | |
| /* disable typing in all selectboxes β click still opens the dropdown */ | |
| div[data-testid="stSelectbox"] input { pointer-events: none; } | |
| /* filter editor popover: enough room for full-width field/operator/value controls */ | |
| div[data-testid="stPopoverBody"] { min-width: 320px; } | |
| /* filter summary chips: left-align the text; clamp to one line with ellipsis */ | |
| div[class*="st-key-fp_"] button { justify-content: flex-start !important; } | |
| div[class*="st-key-fp_"] button > div { min-width: 0; overflow: hidden; } | |
| div[class*="st-key-fp_"] button > div > div:first-child { min-width: 0 !important; overflow: hidden; } | |
| div[class*="st-key-fp_"] button > div > div[aria-hidden="true"] { flex-shrink: 0 !important; } | |
| div[class*="st-key-fp_"] button p { | |
| text-align: left !important; | |
| white-space: nowrap !important; | |
| overflow: hidden !important; | |
| text-overflow: ellipsis !important; | |
| } | |
| /* center the β glyph in filter remove buttons */ | |
| div[class*="st-key-fr_"] button { | |
| display: flex; align-items: center; justify-content: center; | |
| padding: 0 !important; min-width: 0 !important; width: 100% !important; | |
| } | |
| div[class*="st-key-fr_"] button > div { | |
| display: flex; align-items: center; justify-content: center; | |
| width: 100%; height: 100%; | |
| } | |
| div[class*="st-key-fr_"] button p { | |
| line-height: 1 !important; margin: 0 !important; text-align: center; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ββ constants & resources βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "comics-v1") | |
| SHOW_FIELDS = ["comic_id", "book_id", "page_num", "panel_num", "source", "ocr_text"] | |
| OPS = ["==", "!=", ">", ">=", "<", "<=", "In", "Not In", "Match phrase", "Match all", "Match any"] | |
| _MATCH_OPS = {"Match phrase", "Match all", "Match any"} | |
| _OP_MAP = { | |
| "==": "$eq", "!=": "$ne", | |
| ">": "$gt", ">=": "$gte", | |
| "<": "$lt", "<=": "$lte", | |
| "In": "$in", "Not In": "$nin", | |
| "Match phrase": "$match_phrase", | |
| "Match all": "$match_all", | |
| "Match any": "$match_any", | |
| } | |
| # Compact operator labels for the one-line filter summary chips. | |
| _OP_SHORT = { | |
| "==": "=", "!=": "β ", ">": ">", ">=": "β₯", "<": "<", "<=": "β€", | |
| "In": "in", "Not In": "not in", | |
| "Match phrase": "phrase", "Match all": "has all", "Match any": "has any", | |
| } | |
| ALL_FIELDS = ["comic_id", "book_id", "page_id", "page_num", "panel_num", "source", "ocr_text", "search_text", "image_path", "is_ad_page"] | |
| def _index(): | |
| from pinecone import Pinecone | |
| pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) | |
| return pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels")) | |
| def _load_clip(): | |
| from src.embeddings.embed_images import _init_model | |
| _init_model() | |
| # The sound index is a SEPARATE, standard dense index (classic API), unlike the | |
| # comic-panels document-schema index above β see src/sounds/. | |
| def _sound_index(): | |
| from pinecone import Pinecone | |
| pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) | |
| return pc.Index(os.environ.get("PINECONE_SOUND_INDEX_NAME", "comic-sounds")) | |
| # ββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _esc(s: str) -> str: | |
| return s.replace("&", "&").replace("<", "<").replace(">", ">") | |
| def _coerce(s: str): | |
| try: return int(s) | |
| except ValueError: pass | |
| try: return float(s) | |
| except ValueError: pass | |
| if s.lower() in ("true", "yes"): return True | |
| if s.lower() in ("false", "no"): return False | |
| return s | |
| def _add_filter(): | |
| fid = st.session_state.filter_next_id | |
| st.session_state.filter_rows = st.session_state.filter_rows + [fid] | |
| st.session_state.filter_next_id += 1 | |
| st.session_state[f"fk_{fid}"] = "search_text" | |
| def _remove_filter(fid: int): | |
| st.session_state.filter_rows = [r for r in st.session_state.filter_rows if r != fid] | |
| def _clear_filters(): | |
| """Drop every filter row and its backing widget state β fresh slate for a new query.""" | |
| for fid in st.session_state.filter_rows: | |
| for prefix in ("fk_", "fo_", "fv_"): | |
| st.session_state.pop(f"{prefix}{fid}", None) | |
| st.session_state.filter_rows = [] | |
| def _build_filters() -> dict: | |
| row_conds = [] | |
| for fid in st.session_state.filter_rows: | |
| k = st.session_state.get(f"fk_{fid}", "").strip() | |
| op = st.session_state.get(f"fo_{fid}", "==") | |
| val_str = st.session_state.get(f"fv_{fid}", "").strip() | |
| if not k or not val_str: | |
| continue | |
| if op in ("In", "Not In"): | |
| vals = [_coerce(v.strip()) for v in val_str.split(",")] | |
| row_conds.append({k: {"$in" if op == "In" else "$nin": vals}}) | |
| elif op in _MATCH_OPS: | |
| row_conds.append({k: {_OP_MAP[op]: val_str}}) | |
| else: | |
| row_conds.append({k: {_OP_MAP[op]: _coerce(val_str)}}) | |
| combinator = st.session_state.get("filter_combinator", "And") | |
| if len(row_conds) == 0: | |
| user_filter = None | |
| elif len(row_conds) == 1: | |
| user_filter = row_conds[0] | |
| elif combinator == "Or": | |
| user_filter = {"$or": row_conds} | |
| else: | |
| user_filter = {"$and": row_conds} | |
| exclude = st.session_state.get("exclude_ads", True) | |
| if exclude and user_filter: | |
| return {"$and": [{"is_ad_page": {"$eq": False}}, user_filter]} | |
| elif exclude: | |
| return {"is_ad_page": {"$eq": False}} | |
| elif user_filter: | |
| return user_filter | |
| return {} | |
| def _card(hit: dict, rank: int, show_fields: list): | |
| score = hit.get("rrf_score") or hit.get("_score") or 0.0 | |
| sources = hit.get("sources") or [] | |
| hit_id = _esc(str(hit.get("_id", "β"))) | |
| tags = "".join(f'<span class="rc-tag">{s}</span>' for s in sources) | |
| img_html = "" | |
| if src := image_src(hit.get("image_path", "")): | |
| img_html = f'<img class="rc-img" src="{src}" loading="lazy"/>' | |
| fields = "" | |
| for k in show_fields: | |
| v = hit.get(k) | |
| if v is None or str(v).strip() == "": | |
| continue | |
| s = str(v) | |
| if len(s) > 500: | |
| s = s[:500] + "β¦" | |
| fields += f'<div class="rc-field"><span class="rc-key">{k}</span><span class="rc-val">{_esc(s)}</span></div>' | |
| st.markdown(f""" | |
| <div class="rc"> | |
| <div class="rc-left"> | |
| <div class="rc-rank">{rank}</div> | |
| <div class="rc-score-lbl">Score</div> | |
| <div class="rc-score-val">{score:.4f}</div> | |
| {tags} | |
| </div> | |
| <div class="rc-right"> | |
| {img_html} | |
| <div class="rc-id">_id: {hit_id}</div> | |
| {fields} | |
| </div> | |
| </div>""", unsafe_allow_html=True) | |
| def _render_sounds(snd: dict) -> None: | |
| """Render the sound-effect results stored for a panel (under its card).""" | |
| if snd.get("error"): | |
| st.error(f"Sound search failed: {snd['error']}") | |
| return | |
| packet = snd.get("packet") | |
| if packet is None: | |
| st.caption("No clear sound cue in this panel (no drawn SFX, no confident visual match).") | |
| return | |
| src = "drawn SFX (OCR)" if packet["source"] == "ocr" else "depicted content (CLIP tags Β· experimental)" | |
| matched = ", ".join(packet["matched"]) | |
| matches = snd.get("matches") or [] | |
| with st.expander(f"Sounds Β· {matched} Β· via {src}", expanded=True): | |
| st.caption(f"CLAP query: β{packet['sound_query']}β") | |
| if not matches: | |
| st.caption("No commercial-safe clips matched the filters.") | |
| return | |
| if matches[0]["score"] < 0.4: | |
| st.caption("Weak matches β this is the 500-clip sample; the full 8,403-clip index will improve coverage.") | |
| for m in matches: | |
| md = m["md"] | |
| labels = ", ".join(md.get("labels", [])[:4]) | |
| st.markdown(f"**{m['score']:.3f}** Β· {labels} Β· `{md.get('license')}` Β· {md.get('duration_sec')}s") | |
| path = md.get("audio_path") | |
| if path and Path(path).exists(): | |
| st.audio(path) | |
| else: | |
| st.caption("Audio file not available locally.") | |
| if md.get("requires_attribution") and md.get("attribution"): | |
| st.caption(f"Attribution: {md['attribution']}") | |
| # ββ example queries βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Combined examples β each fires multiple signals at once; the ranked lists | |
| # are re-ranked client-side with RRF. Each entry maps signal name β query text. | |
| COMBINED_EXAMPLES = [ | |
| ("Explosion + BOOM", {"dense": "explosion fire destruction chaos", "fts": 'search_text:(BOOM^2 OR BLAST OR "KA-BOOM")'}), | |
| ("Villain + escape phrase", {"dense": "villain sinister evil grin", "sparse": "villain escape capture"}), | |
| ("Detective + murder", {"dense": "detective investigating crime scene", "sparse": "crime mystery clue evidence", "fts": "search_text:(detective AND murder)"}), | |
| ("Formula + exact phrase", {"sparse": "secret formula chemical", "fts": 'search_text:("secret formula")'}), | |
| ] | |
| # Filter ($match_*) β dense pipeline: a native text-match filter narrows the | |
| # candidate set server-side, then the dense vector ranks what survives. | |
| # (field, match-operator, filter value, dense query) | |
| FILTER_DENSE_EXAMPLES = [ | |
| ("'formula' text β lab scene", "search_text", "Match any", "formula chemical experiment", "scientist in a laboratory"), | |
| ("'space' phrase β rocket art", "search_text", "Match phrase", "outer space", "spaceship rocket among the stars"), | |
| ("'monster attack' β creature", "search_text", "Match all", "monster attack", "giant monster creature attacking"), | |
| ("'jungle' text β wild beasts", "search_text", "Match any", "jungle wild beast", "wild animal prowling in the jungle"), | |
| ] | |
| # "More examples" popover: (section title, one-line caption, signal kind, items). | |
| # kind "fts" (query_string), "fts_text" (BM25), "dense", "sparse" items are | |
| # (label, query); "filter_dense" items are FILTER_DENSE_EXAMPLES rows; | |
| # "combined" items are COMBINED_EXAMPLES rows. | |
| EXAMPLE_SECTIONS = [ | |
| ("FTS Β· Phrases & keywords", | |
| "FTS type βtextβ: plain BM25 relevance over search_text β no query syntax needed.", | |
| "fts_text", [ | |
| ("secret formula", "secret formula"), | |
| ("POW BANG ZAP", "POW BANG ZAP"), | |
| ("mad scientist", "mad scientist"), | |
| ("buried treasure", "buried treasure"), | |
| ]), | |
| ("FTS Β· Lucene syntax", | |
| "FTS type βquery_stringβ: phrase slop (~N), term boosting (^N), per-field targeting.", | |
| "fts", [ | |
| ('"detective murder"~5 Β· slop', 'search_text:("detective murder"~5)'), | |
| ("explosion^2 OR fire Β· boost", "search_text:(explosion^2 OR fire)"), | |
| ('"secret formula"~3 OR treasure^2', 'search_text:("secret formula"~3 OR treasure^2)'), | |
| ("ocr_text:(hero AND villain)", "ocr_text:(hero AND villain)"), | |
| ]), | |
| ("Dense Β· Visual (CLIP)", | |
| "Finds panels by what's drawn: OpenCLIP embeds the text and matches image vectors.", | |
| "dense", [ | |
| ("Hero flying", "hero in cape flying through sky"), | |
| ("Fistfight", "two men punching fighting brawl"), | |
| ("Elephant", "elephant in the jungle"), | |
| ("Rocket in space", "spaceship rocket outer space stars"), | |
| ]), | |
| ("Filter ($match_*) β Dense", | |
| "A server-side text-match filter narrows candidates, then CLIP ranks what survives.", | |
| "filter_dense", FILTER_DENSE_EXAMPLES), | |
| ("Sparse Β· Keyword expansion", | |
| "Learned keyword expansion over OCR text (pinecone-sparse-english-v0).", | |
| "sparse", [ | |
| ("Secret formula", "secret formula"), | |
| ("Villain escape", "villain escape capture"), | |
| ("Crime mystery", "crime mystery clue evidence"), | |
| ("Hidden treasure", "hidden treasure map gold"), | |
| ]), | |
| ("Hybrid Β· Client-side RRF", | |
| "Each signal queries the index separately; the ranked lists are re-ranked client-side with Reciprocal Rank Fusion.", | |
| "combined", COMBINED_EXAMPLES), | |
| ] | |
| # Header strip chips: label β (signal kind, payload), one equal-width button each. | |
| STRIP_EXAMPLES = { | |
| "Hero flying": ("dense", "hero in cape flying through sky"), | |
| "POW / BANG / ZAP": ("fts_text", "POW BANG ZAP"), | |
| "'space' phrase β rocket art": ("filter_dense", FILTER_DENSE_EXAMPLES[1][1:]), | |
| "Explosion + BOOM": ("combined", COMBINED_EXAMPLES[0][1]), | |
| "Secret formula": ("sparse", "secret formula"), | |
| } | |
| # ββ session state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| for k, v in { | |
| "fts_on": True, "dense_on": False, "sparse_on": False, | |
| "fts_q": "*", "dense_q": "", "sparse_q": "", | |
| "fts_type": "query_string", | |
| "top_k": 20, "exclude_ads": True, | |
| "filter_rows": [], "filter_next_id": 0, "filter_combinator": "And", | |
| "show_fields": ["search_text"], | |
| "_similar_vec": None, "_similar_id": None, | |
| "_sounds": {}, | |
| "results": None, "result_meta": {}, | |
| "run": False, | |
| }.items(): | |
| st.session_state.setdefault(k, v) | |
| def _reset_query(): | |
| """Clear every signal toggle, query box, filter row, and the 'similar' | |
| vector so a freshly-clicked example or 'See more like this' starts from a | |
| clean slate β no stale signal or text leaks into the new search.""" | |
| _clear_filters() | |
| st.session_state.fts_on = False | |
| st.session_state.dense_on = False | |
| st.session_state.sparse_on = False | |
| st.session_state.fts_q = "*" | |
| st.session_state.dense_q = "" | |
| st.session_state.sparse_q = "" | |
| st.session_state.fts_type = "query_string" | |
| st.session_state["_similar_vec"] = None | |
| st.session_state["_similar_id"] = None | |
| def _find_sounds(hit_id: str): | |
| """Find FSD50K sound effects for a panel: OCR onomatopoeia first, CLIP-tag | |
| fallback, then CLAP search over the comic-sounds index.""" | |
| from src.sounds.search.panel_to_sound_query import panel_to_sound_query | |
| from src.sounds.search.search_sounds_dense import search_sounds_dense | |
| try: | |
| doc = _index().documents.fetch(ids=[hit_id], namespace=NAMESPACE).documents[hit_id] | |
| panel = {"ocr_text": doc.get("ocr_text"), "search_text": doc.get("search_text")} | |
| packet = panel_to_sound_query(panel, image_vec=doc.get("image_dense")) | |
| if packet is None: | |
| st.session_state["_sounds"][hit_id] = {"packet": None} | |
| return | |
| res = search_sounds_dense(packet["sound_query"], top_k=6, | |
| filters=packet["filters"], index=_sound_index()) | |
| matches = [{"id": m.id, "score": m.score, "md": m.metadata or {}} for m in res.matches] | |
| st.session_state["_sounds"][hit_id] = {"packet": packet, "matches": matches} | |
| except Exception as exc: | |
| st.session_state["_sounds"][hit_id] = {"error": str(exc)} | |
| def _use_similar(hit_id: str): | |
| try: | |
| result = _index().documents.fetch(ids=[hit_id], namespace=NAMESPACE) | |
| vec = result.documents[hit_id].get("image_dense") | |
| if not vec: | |
| raise ValueError("No dense vector stored for this record") | |
| _reset_query() | |
| st.session_state["_similar_vec"] = vec | |
| st.session_state["_similar_id"] = hit_id | |
| st.session_state.dense_on = True | |
| st.session_state.run = True | |
| except Exception as exc: | |
| st.session_state.result_meta = {"error": f"Could not fetch vector: {exc}"} | |
| def _use_example(query: str, signal: str): | |
| """on_click callback β runs before rerun so widget keys can be set safely. | |
| signal "fts" runs query_string (Lucene); "fts_text" runs the text type | |
| (plain BM25 over search_text) β _reset_query defaults back to query_string. | |
| """ | |
| _reset_query() | |
| if signal == "dense": | |
| st.session_state.dense_on = True | |
| st.session_state.dense_q = query | |
| elif signal == "sparse": | |
| st.session_state.sparse_on = True | |
| st.session_state.sparse_q = query | |
| else: | |
| st.session_state.fts_on = True | |
| st.session_state.fts_q = query | |
| if signal == "fts_text": | |
| st.session_state.fts_type = "text" | |
| st.session_state.run = True | |
| def _use_combined(queries: dict): | |
| """on_click callback for multi-signal examples β enables each signal in the dict.""" | |
| _reset_query() | |
| st.session_state.dense_on = "dense" in queries | |
| st.session_state.sparse_on = "sparse" in queries | |
| st.session_state.fts_on = "fts" in queries | |
| if "dense" in queries: | |
| st.session_state.dense_q = queries["dense"] | |
| if "sparse" in queries: | |
| st.session_state.sparse_q = queries["sparse"] | |
| if "fts" in queries: | |
| st.session_state.fts_q = queries["fts"] | |
| st.session_state.run = True | |
| def _use_filter_dense(field: str, op: str, value: str, dense_query: str): | |
| """on_click callback for $match_* β dense pipeline examples. | |
| Replaces the filter rows with a single text-match filter and runs a | |
| dense-only search, so the match operator narrows candidates server-side | |
| and the dense vector ranks them. | |
| """ | |
| _reset_query() | |
| fid = st.session_state.filter_next_id | |
| st.session_state.filter_next_id += 1 | |
| st.session_state.filter_rows = [fid] | |
| st.session_state[f"fk_{fid}"] = field | |
| st.session_state[f"fo_{fid}"] = op | |
| st.session_state[f"fv_{fid}"] = value | |
| st.session_state.dense_on = True | |
| st.session_state.dense_q = dense_query | |
| st.session_state.run = True | |
| def _use_strip(label: str): | |
| """on_click callback for the header example chips.""" | |
| kind, payload = STRIP_EXAMPLES[label] | |
| if kind == "combined": | |
| _use_combined(payload) | |
| elif kind == "filter_dense": | |
| _use_filter_dense(*payload) | |
| else: | |
| _use_example(payload, kind) | |
| # ββ header ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown(f""" | |
| <div class="hero"> | |
| <div class="hero-title">π₯ Comic Panel Search</div> | |
| <div class="hero-tag">Search 1,229,664 golden-age comic panels by what's drawn, what's said, or both.</div> | |
| <div class="hero-caps"> | |
| <span class="hero-chip" title="OpenCLIP ViT-B/16 image embeddings, queried by text description">Dense Β· CLIP visual</span> | |
| <span class="hero-chip" title="pinecone-sparse-english-v0 keyword expansion over OCR text">Sparse Β· keywords</span> | |
| <span class="hero-chip" title="Two FTS types: text (plain BM25 relevance) and query_string (Lucene: slop, boosting, per-field)">Full-text Β· BM25 / Lucene</span> | |
| <span class="hero-chip" title="Enable multiple signals; the ranked lists are re-ranked client-side with Reciprocal Rank Fusion">Hybrid Β· RRF</span> | |
| <span class="hero-meta">comic-panels Β· 1,229,664 panels Β· namespace {NAMESPACE} Β· COMICS dataset</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ββ example strip βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # One chip per capability; always visible, even with results on screen. | |
| # Equal-width chips plus the "More examples" trigger span the full bar. | |
| with st.container(key="strip"): | |
| cols = st.columns([0.55, 1, 1, 1, 1, 1, 1], gap="small", vertical_alignment="center") | |
| with cols[0]: | |
| st.markdown('<p class="section-label">Try:</p>', unsafe_allow_html=True) | |
| for i, (col, label) in enumerate(zip(cols[1:], STRIP_EXAMPLES)): | |
| with col: | |
| st.button(label, key=f"strip_{i}", width="stretch", | |
| on_click=_use_strip, args=(label,)) | |
| with cols[6], st.popover("More examples", key="strip_more", width="stretch"): | |
| for title, caption, kind, items in EXAMPLE_SECTIONS: | |
| st.markdown(f'<p class="section-label" style="margin:8px 0 4px">{title}</p>', | |
| unsafe_allow_html=True) | |
| st.caption(caption) | |
| for item in items: | |
| if kind == "combined": | |
| label, queries = item | |
| st.button(label, key=f"exΒ·{label}", width="stretch", | |
| on_click=_use_combined, args=(queries,)) | |
| elif kind == "filter_dense": | |
| label = item[0] | |
| st.button(label, key=f"exΒ·{label}", width="stretch", | |
| on_click=_use_filter_dense, args=item[1:]) | |
| else: | |
| label, query = item | |
| st.button(label, key=f"exΒ·{label}", width="stretch", | |
| on_click=_use_example, args=(query, kind)) | |
| # ββ two-column layout βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| left, right = st.columns([1, 2.8], gap="large") | |
| with left: | |
| # ββ Search signals ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-label">Search signals</p>', unsafe_allow_html=True) | |
| # Full-text | |
| fts_on = st.toggle("Full-text search", key="fts_on") | |
| if fts_on: | |
| st.selectbox( | |
| "FTS type", ["query_string", "text"], key="fts_type", | |
| label_visibility="collapsed", | |
| help="query_string: multi-field Lucene syntax (AND/OR/phrases) Β· text: BM25 on a single field", | |
| ) | |
| st.text_input( | |
| "fts_query", key="fts_q", label_visibility="collapsed", | |
| placeholder=( | |
| 'search_text:("secret formula") Β· search_text:(explosion^2 OR fire) Β· ocr_text:(hero AND villain)' | |
| if st.session_state.fts_type == "query_string" | |
| else "plain text, ranked by BM25: secret formula" | |
| ), | |
| ) | |
| # Dense | |
| dense_on = st.toggle("Dense β visual similarity", key="dense_on") | |
| if dense_on: | |
| st.text_input( | |
| "dense_query", key="dense_q", label_visibility="collapsed", | |
| placeholder="describe what you see: hero flying through sky", | |
| help="Embedded with OpenCLIP ViT-B/16", | |
| ) | |
| # Sparse | |
| sparse_on = st.toggle("Sparse β keyword expansion", key="sparse_on") | |
| if sparse_on: | |
| st.text_input( | |
| "sparse_query", key="sparse_q", label_visibility="collapsed", | |
| placeholder="keywords: secret formula villain escape", | |
| help="Embedded with pinecone-sparse-english-v0", | |
| ) | |
| st.divider() | |
| # ββ Filters βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| n_active = len(st.session_state.filter_rows) | |
| fhdr_label = f"Filters Β· {n_active}" if n_active else "Filters" | |
| fhdr_l, fhdr_r = st.columns([1.4, 1.6], vertical_alignment="center") | |
| with fhdr_l: | |
| st.markdown(f'<p class="section-label">{fhdr_label}</p>', unsafe_allow_html=True) | |
| if st.session_state.filter_rows: | |
| with fhdr_r: | |
| st.selectbox("Combine", ["And", "Or"], key="filter_combinator", | |
| label_visibility="collapsed", | |
| help="How to combine multiple filter conditions") | |
| for fid in st.session_state.filter_rows: | |
| k = st.session_state.get(f"fk_{fid}", "search_text") | |
| op = st.session_state.get(f"fo_{fid}", "==") | |
| val = st.session_state.get(f"fv_{fid}", "").strip() | |
| summary = f"{k} Β· {_OP_SHORT.get(op, op)} Β· {val or 'β¦'}" | |
| c1, c2 = st.columns([5.6, 0.8]) | |
| with c1: | |
| with st.popover(summary, key=f"fp_{fid}", width="stretch"): | |
| st.selectbox("Field", ALL_FIELDS, key=f"fk_{fid}") | |
| st.selectbox("Operator", OPS, key=f"fo_{fid}", | |
| help="Match phrase / all / any run full-text matching server-side") | |
| st.text_input("Value", key=f"fv_{fid}", | |
| placeholder="comma-separated for In / Not In") | |
| with c2: | |
| st.button("β", key=f"fr_{fid}", | |
| on_click=_remove_filter, args=(fid,), | |
| width="stretch") | |
| st.button("+ Add filter", key="add_filter", | |
| on_click=_add_filter, width="stretch") | |
| st.checkbox("Exclude ad pages", key="exclude_ads") | |
| st.divider() | |
| # ββ Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-label">Results</p>', unsafe_allow_html=True) | |
| st.number_input("Top K", min_value=1, max_value=200, step=5, key="top_k") | |
| st.multiselect("Metadata to show", ALL_FIELDS, key="show_fields", | |
| placeholder="Fields to display in results", | |
| help="Which metadata fields appear on each result card") | |
| st.caption("Metadata fields shown on each result card.") | |
| st.write("") | |
| if st.button("Run query", type="primary", width="stretch"): | |
| st.session_state.run = True | |
| # ββ run search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if st.session_state.run: | |
| st.session_state.run = False | |
| fts_on = st.session_state.fts_on | |
| dense_on = st.session_state.dense_on | |
| sparse_on = st.session_state.sparse_on | |
| if not any([fts_on, dense_on, sparse_on]): | |
| st.session_state.result_meta = {"error": "Enable at least one search signal."} | |
| st.session_state.results = [] | |
| else: | |
| idx = _index() | |
| filters = _build_filters() | |
| top_k = int(st.session_state.top_k) | |
| from src.search.search_dense import search_dense | |
| from src.search.search_sparse import search_sparse | |
| from src.search.search_fts import search_fts | |
| from src.search.fusion import rrf_merge | |
| sim_vec = st.session_state.get("_similar_vec") | |
| sim_id = st.session_state.get("_similar_id") | |
| if sim_vec is not None: | |
| st.session_state["_similar_vec"] = None | |
| st.session_state["_similar_id"] = None | |
| groups, error = [], None | |
| try: | |
| if dense_on and (sim_vec is not None or st.session_state.dense_q.strip()): | |
| if sim_vec is not None: | |
| vec = sim_vec | |
| else: | |
| _load_clip() # CLIP (torch) is only needed to embed a dense *text* query | |
| from src.embeddings.embed_images import embed_text_query | |
| vec = embed_text_query(st.session_state.dense_q.strip()) | |
| r = search_dense(idx, NAMESPACE, vec, top_k=top_k, filters=filters) | |
| groups.append(("dense", r.get("result", {}).get("hits", []))) | |
| if sparse_on and st.session_state.sparse_q.strip(): | |
| from src.embeddings.embed_sparse_text import embed_sparse_query | |
| svec = embed_sparse_query(st.session_state.sparse_q.strip()) | |
| if svec: | |
| r = search_sparse(idx, NAMESPACE, svec, top_k=top_k, filters=filters) | |
| groups.append(("sparse", r.get("result", {}).get("hits", []))) | |
| if fts_on and st.session_state.fts_q.strip(): | |
| r = search_fts(idx, NAMESPACE, st.session_state.fts_q.strip(), | |
| top_k=top_k, filters=filters, | |
| fts_type=st.session_state.fts_type) | |
| groups.append(("fts", r.get("result", {}).get("hits", []))) | |
| except Exception as exc: | |
| error = str(exc) | |
| if error: | |
| st.session_state.results = [] | |
| st.session_state.result_meta = {"error": error} | |
| elif not groups: | |
| st.session_state.results = [] | |
| st.session_state.result_meta = {"info": "No query text provided for the enabled signals."} | |
| else: | |
| merged = rrf_merge(groups) | |
| src = "hybrid Β· RRF" if len(groups) > 1 else groups[0][0] | |
| if sim_id: | |
| src = f"similar to {sim_id} Β· {src}" | |
| st.session_state.results = merged[:top_k] | |
| st.session_state.result_meta = {"source": src, "top_k": top_k} | |
| # ββ results panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with right: | |
| results = st.session_state.results | |
| meta = st.session_state.result_meta | |
| if meta.get("error"): | |
| st.error(meta["error"]) | |
| elif meta.get("info"): | |
| st.warning(meta["info"]) | |
| elif results is None: | |
| st.markdown('<div class="ph">Pick an example above, or configure a query and click <strong>Run query</strong></div>', | |
| unsafe_allow_html=True) | |
| elif len(results) == 0: | |
| st.markdown('<div class="ph">No results β try adjusting your query or enabling more signals.</div>', | |
| unsafe_allow_html=True) | |
| else: | |
| src = meta.get("source", "") | |
| tk = meta.get("top_k", "?") | |
| st.markdown( | |
| f'<div class="rh">Search: <strong>{len(results)} results</strong>' | |
| f' Β· top_k={tk}' | |
| f' Β· {src}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| show_fields = st.session_state.show_fields or SHOW_FIELDS | |
| for i, hit in enumerate(results, 1): | |
| c_card, c_sim = st.columns([10, 2]) | |
| with c_card: | |
| _card(hit, i, show_fields) | |
| with c_sim: | |
| if hit.get("_id"): | |
| st.button( | |
| "See more like this...", | |
| key=f"sim_{i}_{hit['_id']}", | |
| on_click=_use_similar, | |
| args=(hit["_id"],), | |
| width="stretch", | |
| ) | |
| # "Find sounds" button unwired for the initial release. The | |
| # backend (_find_sounds / _render_sounds here, and src/sounds/*) | |
| # is left intact so the feature can be re-enabled later by | |
| # restoring the button and the _render_sounds call below. | |