| import gradio as gr |
| from sentence_transformers import SentenceTransformer |
| import faiss |
| import numpy as np |
| import pandas as pd |
| import anthropic |
| import os |
|
|
| print("Loading search engine...") |
| model = SentenceTransformer("all-MiniLM-L6-v2") |
|
|
| import subprocess |
| subprocess.run(["python", "build_index.py"]) |
|
|
| index = faiss.read_index("library.index") |
| df = pd.read_csv("catalog_processed.csv") |
| print("Ready!") |
|
|
|
|
| def get_claude_explanation(query, books, api_key): |
| try: |
| if not api_key or not api_key.strip().startswith("sk-"): |
| return "**Debug — key check failed:** key was empty or wrong format" |
|
|
| client = anthropic.Anthropic(api_key=api_key.strip()) |
|
|
| book_list = "\n".join([ |
| f"- {b['title']} by {b['author']} ({b['subject']}): {b['description']}" |
| for b in books |
| ]) |
|
|
| message = client.messages.create( |
| model="claude-haiku-4-5-20251001", |
| max_tokens=400, |
| messages=[ |
| { |
| "role": "user", |
| "content": f"""A library patron searched for: "{query}" |
| |
| These books were returned as semantic matches: |
| |
| {book_list} |
| |
| In 2-3 sentences, explain why these books are relevant to the patron's search. |
| Focus on the themes and ideas that connect the search to these results. |
| Write in a warm, helpful librarian voice. Do not use bullet points.""" |
| } |
| ] |
| ) |
| return message.content[0].text |
| except Exception as e: |
| return None |
|
|
|
|
| def search(query, num_results, api_key): |
| if not query: |
| return "Please enter a search query." |
|
|
| if api_key and api_key.strip().startswith("sk-"): |
| os.environ["ANTHROPIC_API_KEY"] = api_key.strip() |
|
|
| query_embedding = model.encode([query]) |
| distances, indices = index.search(np.array(query_embedding), int(num_results)) |
|
|
| books = [] |
| for idx in indices[0]: |
| book = df.iloc[idx] |
| books.append({ |
| "title": book["title"], |
| "author": book["author"], |
| "subject": book["subject"], |
| "description": book["description"] |
| }) |
|
|
| explanation = get_claude_explanation(query, books, api_key) |
|
|
| results = "" |
| if explanation: |
| results += f"#### Librarian's Note\n\n{explanation}\n\n---\n\n" |
|
|
| for i, book in enumerate(books): |
| results += f"**{i+1}. {book['title']}**\n\n" |
| results += f"*{book['author']}* · {book['subject']}\n\n" |
| results += f"{book['description']}\n\n---\n\n" |
|
|
| return results |
|
|
|
|
| css = """ |
| :root { color-scheme: light; } |
| html { color-scheme: light; } |
| |
| @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;1,400&display=swap'); |
| |
| :root { |
| --maroon: #5C1A1A; |
| --maroon-mid: #7A2424; |
| --maroon-rule: #8B3A3A; |
| --maroon-tint: #F9F2F2; |
| --ink: #1A1A1A; |
| --ink-2: #3D3D3D; |
| --ink-3: #767676; |
| --surface: #FFFFFF; |
| --bg: #F5F3EF; |
| --rule: #DDD9D0; |
| --rule-light: #EAE7E0; |
| } |
| |
| html, body, .gradio-container { |
| background: var(--bg) !important; |
| font-family: 'Source Sans 3', Georgia, sans-serif !important; |
| color: var(--ink) !important; |
| } |
| |
| footer, .built-with, .svelte-1rjryqp { display: none !important; } |
| |
| .gradio-container { |
| max-width: 900px !important; |
| margin: 0 auto !important; |
| padding: 0 !important; |
| } |
| |
| .inst-bar { |
| background: var(--maroon) !important; |
| padding: 0.55rem 2.5rem !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: space-between !important; |
| border-bottom: 3px solid var(--maroon-rule) !important; |
| } |
| |
| .inst-bar-left { |
| display: flex !important; |
| align-items: center !important; |
| gap: 0.75rem !important; |
| } |
| |
| .inst-bar-icon { |
| width: 26px !important; |
| height: 26px !important; |
| opacity: 0.9 !important; |
| } |
| |
| .inst-bar-name { |
| font-family: 'Source Sans 3', sans-serif !important; |
| font-size: 0.78rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.12em !important; |
| text-transform: uppercase !important; |
| color: #F0E8E8 !important; |
| } |
| |
| .inst-bar-badge { |
| font-size: 0.65rem !important; |
| font-weight: 400 !important; |
| letter-spacing: 0.1em !important; |
| text-transform: uppercase !important; |
| color: #C89A9A !important; |
| border: 1px solid #8B5555 !important; |
| padding: 0.2rem 0.55rem !important; |
| border-radius: 2px !important; |
| } |
| |
| .content-wrap { |
| padding: 2.5rem 2.5rem 4rem !important; |
| } |
| |
| .page-title-block { |
| margin-bottom: 2rem !important; |
| padding-bottom: 1.5rem !important; |
| border-bottom: 1px solid var(--rule) !important; |
| } |
| |
| .page-title { |
| font-family: 'Playfair Display', Georgia, serif !important; |
| font-size: 2.1rem !important; |
| font-weight: 400 !important; |
| color: var(--maroon) !important; |
| letter-spacing: -0.01em !important; |
| line-height: 1.15 !important; |
| margin-bottom: 0.4rem !important; |
| } |
| |
| .page-desc { |
| font-size: 0.9rem !important; |
| color: var(--ink-3) !important; |
| line-height: 1.6 !important; |
| max-width: 560px !important; |
| font-weight: 300 !important; |
| } |
| |
| label span, .label-wrap span { |
| font-size: 0.72rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.08em !important; |
| text-transform: uppercase !important; |
| color: var(--ink-3) !important; |
| } |
| |
| textarea, input[type="text"], input[type="password"] { |
| background: var(--surface) !important; |
| border: 1px solid var(--rule) !important; |
| border-radius: 3px !important; |
| color: var(--ink) !important; |
| font-family: 'Source Sans 3', sans-serif !important; |
| font-size: 0.95rem !important; |
| font-weight: 300 !important; |
| padding: 0.7rem 0.9rem !important; |
| box-shadow: none !important; |
| transition: border-color 0.15s !important; |
| } |
| |
| textarea:focus, input:focus { |
| border-color: var(--maroon-mid) !important; |
| outline: none !important; |
| box-shadow: 0 0 0 3px rgba(92,26,26,0.07) !important; |
| } |
| |
| input[type="range"] { accent-color: var(--maroon) !important; } |
| |
| button.primary, button[variant="primary"] { |
| background: var(--maroon) !important; |
| color: #FFFFFF !important; |
| border: none !important; |
| border-radius: 3px !important; |
| font-family: 'Source Sans 3', sans-serif !important; |
| font-size: 0.78rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.1em !important; |
| text-transform: uppercase !important; |
| padding: 0.72rem 1.6rem !important; |
| transition: background 0.15s, transform 0.1s !important; |
| box-shadow: none !important; |
| } |
| |
| button.primary:hover, button[variant="primary"]:hover { |
| background: var(--maroon-mid) !important; |
| transform: translateY(-1px) !important; |
| } |
| |
| button.primary:active { transform: translateY(0) !important; } |
| |
| .examples button, button.secondary { |
| background: var(--surface) !important; |
| color: var(--ink-2) !important; |
| border: 1px solid var(--rule) !important; |
| border-radius: 3px !important; |
| font-family: 'Source Sans 3', sans-serif !important; |
| font-size: 0.82rem !important; |
| font-weight: 300 !important; |
| padding: 0.35rem 0.8rem !important; |
| transition: border-color 0.15s, color 0.15s !important; |
| } |
| |
| .examples button:hover { |
| border-color: var(--maroon) !important; |
| color: var(--maroon) !important; |
| } |
| |
| .prose, .md, .markdown-body, [data-testid="markdown"] { |
| background: transparent !important; |
| font-family: 'Source Sans 3', sans-serif !important; |
| font-size: 0.95rem !important; |
| line-height: 1.8 !important; |
| font-weight: 300 !important; |
| } |
| |
| .prose p, .md p, [data-testid="markdown"] p, |
| .prose li, .md li, [data-testid="markdown"] li { |
| color: #1A1A1A !important; |
| } |
| |
| .inst-bar-name { color: #F0E8E8 !important; } |
| .inst-bar-badge { color: #C89A9A !important; } |
| |
| [data-testid="markdown"] strong, .prose strong, .md strong { |
| font-family: 'Playfair Display', serif !important; |
| font-weight: 400 !important; |
| font-size: 1.05rem !important; |
| color: #1A1A1A !important; |
| } |
| |
| [data-testid="markdown"] em, .prose em, .md em { |
| font-style: normal !important; |
| font-size: 0.83rem !important; |
| letter-spacing: 0.03em !important; |
| color: #767676 !important; |
| font-weight: 400 !important; |
| } |
| |
| [data-testid="markdown"] h4, .prose h4, .md h4 { |
| font-size: 0.72rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.1em !important; |
| text-transform: uppercase !important; |
| color: #5C1A1A !important; |
| margin-bottom: 0.5rem !important; |
| font-family: 'Source Sans 3', sans-serif !important; |
| } |
| |
| [data-testid="markdown"] hr, .prose hr, .md hr { |
| border: none !important; |
| border-top: 1px solid #EAE7E0 !important; |
| margin: 1.2rem 0 !important; |
| } |
| |
| .poc-banner { |
| background: var(--maroon-tint) !important; |
| border: 1px solid #D4AAAA !important; |
| border-left: 3px solid var(--maroon) !important; |
| border-radius: 0 3px 3px 0 !important; |
| padding: 0.6rem 1rem !important; |
| margin-bottom: 2rem !important; |
| font-size: 0.8rem !important; |
| color: var(--maroon) !important; |
| font-weight: 400 !important; |
| line-height: 1.5 !important; |
| } |
| """ |
|
|
| with gr.Blocks( |
| css=css, |
| theme=gr.themes.Base( |
| font=gr.themes.GoogleFont("Source Sans 3"), |
| ), |
| title="Research Collections Search" |
| ) as demo: |
|
|
| gr.HTML(""" |
| <div class="inst-bar"> |
| <div class="inst-bar-left"> |
| <svg class="inst-bar-icon" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| <rect x="3" y="16" width="3" height="7" fill="#F0E8E8" opacity="0.8"/> |
| <rect x="7.5" y="12" width="3" height="11" fill="#F0E8E8" opacity="0.8"/> |
| <rect x="12" y="8" width="3" height="15" fill="#F0E8E8"/> |
| <rect x="16.5" y="12" width="3" height="11" fill="#F0E8E8" opacity="0.8"/> |
| <rect x="21" y="16" width="3" height="7" fill="#F0E8E8" opacity="0.6"/> |
| <rect x="2" y="23" width="22" height="1.5" fill="#F0E8E8" opacity="0.5"/> |
| </svg> |
| <span class="inst-bar-name">Research Collections & Services</span> |
| </div> |
| <span class="inst-bar-badge">Proof of Concept</span> |
| </div> |
| """) |
|
|
| with gr.Column(elem_classes=["content-wrap"]): |
|
|
| gr.HTML(""" |
| <div class="page-title-block"> |
| <div class="page-title">Semantic Search</div> |
| <div class="page-desc"> |
| Discover collections through meaning rather than exact keywords. Describe what you're looking for |
| in plain language — subjects, themes, questions, or ideas. |
| </div> |
| </div> |
| """) |
|
|
| gr.HTML(""" |
| <div class="poc-banner"> |
| This tool is an early-stage prototype demonstrating AI-powered semantic search across a 200-title catalog. |
| Results are ranked by conceptual relevance using sentence-transformer embeddings. |
| </div> |
| """) |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=5): |
| query_input = gr.Textbox( |
| label="Search query", |
| placeholder="e.g. the social consequences of artificial intelligence...", |
| lines=2, |
| ) |
| with gr.Column(scale=1, min_width=110): |
| num_results = gr.Slider( |
| minimum=1, maximum=5, value=3, step=1, |
| label="Results", |
| ) |
|
|
| with gr.Row(equal_height=True): |
| api_key_input = gr.Textbox( |
| label="Anthropic API Key — optional, enables AI-generated annotations", |
| placeholder="sk-ant-...", |
| type="password", |
| scale=5, |
| ) |
| with gr.Column(scale=1, min_width=130): |
| gr.HTML("<div style='height:1.55rem'></div>") |
| search_btn = gr.Button("Search →", variant="primary") |
|
|
| results_output = gr.Markdown() |
|
|
| gr.Examples( |
| examples=[ |
| ["books about artificial intelligence and society", 3, ""], |
| ["stories about race and justice in America", 3, ""], |
| ["women who made a difference in science", 3, ""], |
| ["how governments control people", 3, ""], |
| ["climate change and the future of the planet", 3, ""], |
| ["libraries and the organization of knowledge", 3, ""], |
| ["survival against the odds", 3, ""], |
| ], |
| inputs=[query_input, num_results, api_key_input], |
| label="Example queries", |
| ) |
|
|
| search_btn.click( |
| fn=search, |
| inputs=[query_input, num_results, api_key_input], |
| outputs=results_output, |
| ) |
| query_input.submit( |
| fn=search, |
| inputs=[query_input, num_results, api_key_input], |
| outputs=results_output, |
| ) |
|
|
| demo.launch() |