| |
| """ |
| Zetema — semantic search over the Perseus Greek corpus. Gradio web UI. |
| Run: python app.py |
| Then open http://localhost:7861 in your browser. |
| """ |
|
|
| import json |
| import os |
| from threading import Thread |
|
|
| |
| |
| |
| IS_SPACE = os.environ.get("SPACE_ID") is not None |
| if IS_SPACE: |
| import spaces |
|
|
| import duckdb |
| import numpy as np |
| import torch |
| import gradio as gr |
| from sentence_transformers import SentenceTransformer |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer |
|
|
|
|
| def gpu(**kwargs): |
| """@spaces.GPU on a ZeroGPU Space, no-op decorator locally.""" |
| if IS_SPACE: |
| return spaces.GPU(**kwargs) |
| return lambda fn: fn |
|
|
|
|
| DB_PATH = os.path.join(os.path.dirname(__file__), "data", "perseus_rag_qwen.duckdb") |
| |
| |
| INDEX_REPO = os.environ.get("ZETEMA_INDEX_REPO", "Jacobo/zetema-index") |
| DATES_PATH = os.path.join(os.path.dirname(__file__), "data", "tlg_dates.json") |
| ENG_PATH = os.path.join(os.path.dirname(__file__), "data", "eng_translations.json") |
| UNKNOWN_CENTURY = "Unknown" |
| UNKNOWN_GENRE = "Unknown" |
| RERANKER_NAME = "Qwen/Qwen3-Reranker-0.6B" |
| RERANK_CANDIDATES = 50 |
| RERANK_BATCH = 16 |
| |
| |
| |
| WEAK_MATCH_THRESHOLD = 0.30 |
| LLM_MODELS = ["google/gemma-3-12b-it", "Qwen/Qwen3-4B-Instruct-2507", "Qwen/Qwen3-8B"] |
| DEFAULT_LLM = LLM_MODELS[0] |
| if IS_SPACE or torch.cuda.is_available(): |
| DEVICE = "cuda" |
| elif torch.backends.mps.is_available(): |
| DEVICE = "mps" |
| else: |
| DEVICE = "cpu" |
|
|
| |
| |
| |
|
|
| _model: SentenceTransformer | None = None |
| _embeddings: np.ndarray | None = None |
| _meta: list[dict] | None = None |
| _authors: list[str] = [] |
| _cent_min: np.ndarray | None = None |
| _cent_max: np.ndarray | None = None |
| _century_labels: list[str] = [] |
| _century_by_label: dict[str, int] = {} |
| _genre_masks: dict[str, np.ndarray] = {} |
| _genre_labels: list[str] = [] |
|
|
| |
| |
| |
| GENRE_NAMES = { |
| "Alchem.": "Alchemy", |
| "Apol.": "Apologetics", |
| "Astrol.": "Astrology", |
| "Astron.": "Astronomy", |
| "Biogr.": "Biography", |
| "Bucol.": "Bucolic poetry", |
| "Chronogr.": "Chronography", |
| "Comic.": "Comedy", |
| "Eleg.": "Elegy", |
| "Epic.": "Epic poetry", |
| "Epigr.": "Epigram", |
| "Epist.": "Epistolography", |
| "Geogr.": "Geography", |
| "Geom.": "Geometry", |
| "Gnom.": "Gnomology", |
| "Gramm.": "Grammar", |
| "Hist.": "History", |
| "Iamb.": "Iambic poetry", |
| "Lyr.": "Lyric poetry", |
| "Math.": "Mathematics", |
| "Mech.": "Mechanics", |
| "Med.": "Medicine", |
| "Mus.": "Music", |
| "Myth.": "Mythography", |
| "Orat.": "Oratory", |
| "Paradox.": "Paradoxography", |
| "Parodius": "Parody", |
| "Perieg.": "Periegesis", |
| "Phil.": "Philosophy", |
| "Philol.": "Philology", |
| "Poet. Med.": "Medical poetry", |
| "Poet. Phil.": "Philosophical poetry", |
| "Poeta": "Poetry", |
| "Rhet.": "Rhetoric", |
| "Scr. Eccl.": "Ecclesiastical", |
| "Scr. Erot.": "Novel", |
| "Soph.": "Sophistic", |
| "Tact.": "Tactics", |
| "Theol.": "Theology", |
| "Trag.": "Tragedy", |
| } |
|
|
|
|
| def century_label(c: int) -> str: |
| n = abs(c) |
| suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10 if n % 100 not in (11, 12, 13) else 0, "th") |
| return f"{n}{suffix} c. {'BC' if c < 0 else 'AD'}" |
|
|
|
|
| def load_index() -> None: |
| global _model, _embeddings, _meta, _authors |
| global _cent_min, _cent_max, _century_labels, _century_by_label |
| global _genre_masks, _genre_labels |
|
|
| db_path = DB_PATH |
| if not os.path.exists(db_path): |
| if IS_SPACE: |
| from huggingface_hub import hf_hub_download |
| print(f"Downloading index from dataset {INDEX_REPO}…") |
| db_path = hf_hub_download( |
| repo_id=INDEX_REPO, |
| filename="perseus_rag_qwen.duckdb", |
| repo_type="dataset", |
| ) |
| else: |
| raise FileNotFoundError( |
| f"Index not found: {db_path}\n" |
| "Run `python ingest.py` first to build the index." |
| ) |
|
|
| con = duckdb.connect(db_path, read_only=True) |
| model_name = con.execute( |
| "SELECT value FROM meta WHERE key = 'model'" |
| ).fetchone()[0] |
|
|
| print(f"Loading embedding model: {model_name}") |
| _model = SentenceTransformer( |
| model_name, |
| model_kwargs={"dtype": torch.float16} if DEVICE != "cpu" else {}, |
| processor_kwargs={"padding_side": "left"}, |
| ) |
|
|
| print("Loading embeddings from DuckDB...") |
| rows = con.execute( |
| "SELECT author, work, section_ref, lang_tag, text, " |
| "tlg_id, work_id, cts_ref, embedding " |
| "FROM sections ORDER BY id" |
| ).fetchall() |
| con.close() |
|
|
| _meta = [ |
| { |
| "author": r[0], |
| "work": r[1], |
| "section_ref": r[2], |
| "lang_tag": r[3], |
| "text": r[4], |
| "tlg_id": r[5], |
| "work_id": r[6], |
| "cts_ref": r[7], |
| } |
| for r in rows |
| ] |
| _embeddings = np.array([r[8] for r in rows], dtype=np.float32) |
| |
| norms = np.linalg.norm(_embeddings, axis=1, keepdims=True) |
| _embeddings = _embeddings / np.where(norms == 0, 1, norms) |
|
|
| _authors = sorted({m["author"] for m in _meta if m["author"]}) |
|
|
| |
| |
| |
| dates: dict[str, dict] = {} |
| if os.path.exists(DATES_PATH): |
| with open(DATES_PATH, encoding="utf-8") as f: |
| dates = json.load(f) |
| else: |
| print(f"Warning: {DATES_PATH} not found — century filter disabled.") |
| cmin, cmax = [], [] |
| genre_sets: list[set[str]] = [] |
| for m in _meta: |
| info = dates.get(m["tlg_id"]) or {} |
| lo, hi = info.get("century_min"), info.get("century_max") |
| m["date"] = info.get("date") if lo is not None else None |
| cmin.append(lo if lo is not None else 0) |
| cmax.append(hi if hi is not None else 0) |
| genre_sets.append( |
| {GENRE_NAMES.get(e, e) for e in info.get("epithets", [])} |
| ) |
| _cent_min = np.array(cmin, dtype=np.int16) |
| _cent_max = np.array(cmax, dtype=np.int16) |
| present = sorted({ |
| c |
| for lo, hi in zip(cmin, cmax) if lo != 0 |
| for c in range(lo, hi + 1) if c != 0 |
| }) |
| _century_by_label = {century_label(c): c for c in present} |
| _century_labels = list(_century_by_label) + [UNKNOWN_CENTURY] |
|
|
| |
| |
| |
| n = len(genre_sets) |
| _genre_masks = { |
| g: np.fromiter((g in s for s in genre_sets), dtype=bool, count=n) |
| for g in sorted({g for s in genre_sets for g in s}) |
| } |
| _genre_labels = list(_genre_masks) + [UNKNOWN_GENRE] |
| _genre_masks[UNKNOWN_GENRE] = np.fromiter( |
| (not s for s in genre_sets), dtype=bool, count=n |
| ) |
|
|
| print(f"Ready — {len(_meta):,} sections, {len(_authors)} authors.") |
|
|
|
|
| |
| |
| |
|
|
| _rr_tokenizer = None |
| _rr_model = None |
| _rr_yes_id = None |
| _rr_no_id = None |
|
|
| RERANK_PREFIX = ( |
| "<|im_start|>system\nJudge whether the Document meets the requirements " |
| "based on the Query and the Instruct provided. Note that the answer can " |
| 'only be "yes" or "no".<|im_end|>\n<|im_start|>user\n' |
| ) |
| RERANK_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n" |
| RERANK_TASK = ( |
| "Given a search query about ancient Greek literature (in English or " |
| "Greek), judge whether the ancient Greek passage is relevant to it" |
| ) |
|
|
|
|
| def load_reranker() -> None: |
| global _rr_tokenizer, _rr_model, _rr_yes_id, _rr_no_id |
| print(f"Loading reranker: {RERANKER_NAME}") |
| _rr_tokenizer = AutoTokenizer.from_pretrained(RERANKER_NAME, padding_side="left") |
| _rr_model = AutoModelForCausalLM.from_pretrained( |
| RERANKER_NAME, |
| dtype=torch.float16 if DEVICE != "cpu" else torch.float32, |
| ).to(DEVICE).eval() |
| _rr_yes_id = _rr_tokenizer.convert_tokens_to_ids("yes") |
| _rr_no_id = _rr_tokenizer.convert_tokens_to_ids("no") |
|
|
|
|
| @torch.no_grad() |
| def rerank_scores(query: str, texts: list[str]) -> list[float]: |
| """P(relevant) for each passage under the Qwen3 reranker.""" |
| prompts = [ |
| f"{RERANK_PREFIX}<Instruct>: {RERANK_TASK}\n<Query>: {query}\n" |
| f"<Document>: {t}{RERANK_SUFFIX}" |
| for t in texts |
| ] |
| scores: list[float] = [] |
| for start in range(0, len(prompts), RERANK_BATCH): |
| batch = prompts[start : start + RERANK_BATCH] |
| inputs = _rr_tokenizer( |
| batch, padding=True, truncation=True, max_length=2048, |
| return_tensors="pt", |
| ).to(DEVICE) |
| |
| |
| logits = _rr_model(**inputs, logits_to_keep=1).logits[:, -1, :] |
| pair = torch.stack([logits[:, _rr_no_id], logits[:, _rr_yes_id]], dim=1) |
| scores.extend(torch.softmax(pair.float(), dim=1)[:, 1].tolist()) |
| return scores |
|
|
|
|
| |
| |
| |
|
|
| @gpu() |
| def semantic_search( |
| query: str, |
| top_k: int = 10, |
| author_filter: list[str] | None = None, |
| century_filter: list[str] | None = None, |
| genre_filter: list[str] | None = None, |
| ) -> list[dict]: |
| """Dense-retrieve a candidate pool, rerank it, return top-k matches.""" |
| |
| |
| q_emb = _model.encode( |
| [query], |
| prompt_name="query", |
| normalize_embeddings=True, |
| convert_to_numpy=True, |
| )[0] |
|
|
| scores = (_embeddings @ q_emb).astype(np.float32) |
|
|
| if author_filter: |
| mask = np.array( |
| [m["author"] in author_filter for m in _meta], dtype=bool |
| ) |
| scores = np.where(mask, scores, -2.0) |
|
|
| if century_filter: |
| |
| |
| match = np.zeros(len(scores), dtype=bool) |
| for label in century_filter: |
| if label == UNKNOWN_CENTURY: |
| match |= _cent_min == 0 |
| elif label in _century_by_label: |
| c = _century_by_label[label] |
| match |= (_cent_min <= c) & (c <= _cent_max) |
| scores = np.where(match, scores, -2.0) |
|
|
| if genre_filter: |
| |
| |
| match = np.zeros(len(scores), dtype=bool) |
| for label in genre_filter: |
| if label in _genre_masks: |
| match |= _genre_masks[label] |
| scores = np.where(match, scores, -2.0) |
|
|
| pool = min(max(RERANK_CANDIDATES, top_k), len(scores)) |
| top_indices = np.argpartition(scores, -pool)[-pool:] |
| top_indices = top_indices[np.argsort(scores[top_indices])[::-1]] |
| |
| top_indices = [i for i in top_indices if scores[i] > -2.0] |
|
|
| candidates = [ |
| {**_meta[i], "dense_score": float(scores[i]), "idx": int(i)} |
| for i in top_indices |
| ] |
| rr = rerank_scores(query, [c["text"] for c in candidates]) |
| for c, s in zip(candidates, rr): |
| c["score"] = s |
| candidates.sort(key=lambda c: c["score"], reverse=True) |
| return candidates[:top_k] |
|
|
|
|
| def author_summary(results: list[dict], top_n: int = 5) -> str: |
| """Return a ranked list of authors by average score in results.""" |
| by_author: dict[str, list[float]] = {} |
| for r in results: |
| by_author.setdefault(r["author"], []).append(r["score"]) |
| ranked = sorted( |
| by_author.items(), |
| key=lambda kv: (len(kv[1]), sum(kv[1]) / len(kv[1])), |
| reverse=True, |
| )[:top_n] |
| lines = [] |
| for author, scores in ranked: |
| avg = sum(scores) / len(scores) |
| lines.append(f"**{author}** — {len(scores)} passage{'s' if len(scores)>1 else ''}, avg score {avg:.3f}") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| _llm_cache: dict[str, tuple] = {} |
|
|
|
|
| def get_llm(model_name: str) -> tuple: |
| """Lazy-load and cache a chat LLM (first request pays the load time).""" |
| if model_name not in _llm_cache: |
| print(f"Loading LLM: {model_name}") |
| tokenizer = AutoTokenizer.from_pretrained(model_name) |
| model = AutoModelForCausalLM.from_pretrained( |
| model_name, |
| |
| dtype=torch.bfloat16 if DEVICE == "cuda" |
| else torch.float16 if DEVICE == "mps" |
| else torch.float32, |
| ).to(DEVICE).eval() |
| _llm_cache[model_name] = (tokenizer, model) |
| return _llm_cache[model_name] |
|
|
|
|
| @gpu(duration=120) |
| def llm_chat_stream(messages: list[dict], model_name: str): |
| """Stream a chat completion, yielding the accumulated text.""" |
| try: |
| tokenizer, model = get_llm(model_name) |
| |
| |
| text = tokenizer.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True, |
| enable_thinking=False, |
| ) |
| inputs = tokenizer([text], return_tensors="pt").to(model.device) |
| streamer = TextIteratorStreamer( |
| tokenizer, skip_prompt=True, skip_special_tokens=True |
| ) |
| thread = Thread( |
| target=model.generate, |
| kwargs=dict( |
| **inputs, streamer=streamer, max_new_tokens=1024, |
| do_sample=True, temperature=0.7, top_p=0.8, top_k=20, |
| ), |
| ) |
| thread.start() |
| acc = "" |
| for chunk in streamer: |
| acc += chunk |
| yield acc |
| thread.join() |
| except Exception as e: |
| yield f"⚠️ LLM error: {e}" |
|
|
|
|
| CONTEXT_PASSAGES = 8 |
|
|
|
|
| def passages_context(passages: list[dict]) -> str: |
| """Format retrieved passages as numbered context for LLM prompts. |
| |
| Carries the reranker's relevance score with each passage. Without it the |
| synthesis and chat treat a 0.99 match and a 0.31 near-miss as equally |
| authoritative, discarding the ranking the retrieval path just computed. |
| """ |
| return "\n\n".join( |
| f"[{i}] {p['author']}, {p['work']} §{p['section_ref']} " |
| f"(relevance {p['score']:.2f})\n{p['text'][:600]}" |
| for i, p in enumerate(passages[:CONTEXT_PASSAGES], 1) |
| ) |
|
|
|
|
| def evidence_note(passages: list[dict]) -> str: |
| """Shared preamble telling the LLM how to read the relevance scores.""" |
| best = max((p["score"] for p in passages[:CONTEXT_PASSAGES]), default=0.0) |
| note = ( |
| "Passages are listed best-first, and each carries the reranker's " |
| "relevance score from 0 to 1. Weight them accordingly: a passage " |
| f"scoring below about {WEAK_MATCH_THRESHOLD:.2f} is a weak match that " |
| "may not bear on the query at all." |
| ) |
| if best < WEAK_MATCH_THRESHOLD: |
| note += ( |
| " Note that NO passage here scored above " |
| f"{WEAK_MATCH_THRESHOLD:.2f}, so the corpus probably does not " |
| "address this query. Say so plainly rather than assembling an " |
| "answer out of near-misses." |
| ) |
| return note |
|
|
|
|
| def synthesize_stream(query: str, passages: list[dict], model_name: str): |
| """Stream an LLM synthesis of the retrieved passages.""" |
| context = passages_context(passages) |
|
|
| prompt = ( |
| f"You are a scholar of ancient Greek literature. " |
| f"The following passages from the Perseus corpus were retrieved for the query:\n" |
| f" \"{query}\"\n\n" |
| f"{evidence_note(passages)}\n\n" |
| f"Passages (in ancient Greek):\n{context}\n\n" |
| f"Please:\n" |
| f"1. Identify the main themes or concepts related to the query found in these passages.\n" |
| f"2. Note which authors or works engage most directly with the topic.\n" |
| f"3. If helpful, briefly translate or paraphrase key phrases.\n" |
| f"Answer concisely in English." |
| ) |
|
|
| yield from llm_chat_stream([{"role": "user", "content": prompt}], model_name) |
|
|
|
|
| def chat_system_prompt(query: str, passages: list[dict], |
| synthesis: str = "") -> str: |
| prompt = ( |
| "You are a scholar of ancient Greek literature helping a user explore " |
| "passages retrieved from the Perseus corpus for the search query " |
| f"\"{query}\".\n\n" |
| f"{evidence_note(passages)}\n\n" |
| f"Retrieved passages (in ancient Greek):\n{passages_context(passages)}\n\n" |
| ) |
| if synthesis.strip(): |
| |
| |
| |
| prompt += ( |
| "You have already given the user this summary of these passages:\n" |
| f"---\n{synthesis.strip()}\n---\n" |
| "Stay consistent with it. If you now think part of it was wrong, " |
| "say so explicitly rather than quietly changing your account.\n\n" |
| ) |
| return prompt + ( |
| "Answer the user's questions about these passages: interpret, translate, " |
| "compare, and point to the relevant authors and works. Refer to passages " |
| "by their bracketed numbers. If a question goes beyond what the passages " |
| "support, say so rather than inventing sources. Answer in English unless " |
| "asked otherwise." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| _eng_translations: dict[str, str] = {} |
| if os.path.exists(ENG_PATH): |
| with open(ENG_PATH, encoding="utf-8") as _f: |
| _eng_translations = json.load(_f) |
|
|
|
|
| def scaife_url(r: dict) -> str: |
| """Scaife Viewer link for a result — reader page when the passage has a |
| CTS reference, the work's library page otherwise. When the work has a |
| known English translation, it is opened alongside via ?right=.""" |
| work = f"{r['tlg_id']}.{r['work_id']}" |
| urn = f"urn:cts:greekLit:{work}" |
| if r.get("cts_ref"): |
| url = f"https://scaife.perseus.org/reader/{urn}.{r['lang_tag']}:{r['cts_ref']}/" |
| eng = _eng_translations.get(work) |
| return f"{url}?right={eng}" if eng else url |
| return f"https://scaife.perseus.org/library/{urn}/" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| APP_CSS = """ |
| body { |
| --sem-card-bg: #faf6ec; |
| --sem-card-border: #d9cdb4; |
| --sem-text: #3d2f1e; |
| --sem-author: #8b2e2e; |
| --sem-link: #8b2e2e; |
| --sem-meta: #7a6a52; |
| --sem-score: #a08c5a; |
| --sem-page-bg: #efe7d3; |
| --sem-block-bg: #faf6ec; |
| --sem-input-bg: #fdfbf4; |
| --sem-primary: #8b2e2e; |
| --sem-primary-hover: #722525; |
| --sem-primary-text: #faf6ec; |
| --sem-secondary-bg: #e7dcc2; |
| --sem-secondary-bg-hover: #ddcfae; |
| --sem-accent-soft: #ecdfc4; |
| --sem-subdued: #7a6a52; |
| } |
| body.sem-aegean { |
| --sem-card-bg: #f7fafc; |
| --sem-card-border: #cfdde8; |
| --sem-text: #17202a; |
| --sem-author: #1b4f72; |
| --sem-link: #1b4f72; |
| --sem-meta: #5d7285; |
| --sem-score: #c0603d; |
| --sem-page-bg: #e8eff5; |
| --sem-block-bg: #ffffff; |
| --sem-input-bg: #ffffff; |
| --sem-primary: #1b4f72; |
| --sem-primary-hover: #163f5c; |
| --sem-primary-text: #ffffff; |
| --sem-secondary-bg: #dbe7f0; |
| --sem-secondary-bg-hover: #cdddea; |
| --sem-accent-soft: #d9e7f1; |
| --sem-subdued: #5d7285; |
| } |
| body.sem-academic { |
| --sem-card-bg: #ffffff; |
| --sem-card-border: #e0e0e0; |
| --sem-text: #1c1c1c; |
| --sem-author: #1c1c1c; |
| --sem-link: #1a56a0; |
| --sem-meta: #666666; |
| --sem-score: #9a9a9a; |
| --sem-page-bg: #f4f5f7; |
| --sem-block-bg: #ffffff; |
| --sem-input-bg: #ffffff; |
| --sem-primary: #1a56a0; |
| --sem-primary-hover: #144781; |
| --sem-primary-text: #ffffff; |
| --sem-secondary-bg: #e8eaee; |
| --sem-secondary-bg-hover: #dcdfe5; |
| --sem-accent-soft: #dce8f5; |
| --sem-subdued: #666666; |
| } |
| body.sem-darkscholar, body.dark { |
| --sem-card-bg: #20242b; |
| --sem-card-border: #3a4048; |
| --sem-text: #e8e2d5; |
| --sem-author: #d4a656; |
| --sem-link: #d4a656; |
| --sem-meta: #8f9aab; |
| --sem-score: #d4a656; |
| --sem-page-bg: #14171c; |
| --sem-block-bg: #20242b; |
| --sem-input-bg: #262b34; |
| --sem-primary: #d4a656; |
| --sem-primary-hover: #c2933f; |
| --sem-primary-text: #14171c; |
| --sem-secondary-bg: #2c323c; |
| --sem-secondary-bg-hover: #363d49; |
| --sem-accent-soft: #3a3420; |
| --sem-subdued: #8f9aab; |
| } |
| body { |
| --body-background-fill: var(--sem-page-bg); |
| --background-fill-primary: var(--sem-block-bg); |
| --background-fill-secondary: var(--sem-accent-soft); |
| --block-background-fill: var(--sem-block-bg); |
| --body-text-color: var(--sem-text); |
| --body-text-color-subdued: var(--sem-subdued); |
| --block-title-text-color: var(--sem-text); |
| --block-label-text-color: var(--sem-subdued); |
| --border-color-primary: var(--sem-card-border); |
| --input-background-fill: var(--sem-input-bg); |
| --button-primary-background-fill: var(--sem-primary); |
| --button-primary-background-fill-hover: var(--sem-primary-hover); |
| --button-primary-text-color: var(--sem-primary-text); |
| --button-secondary-background-fill: var(--sem-secondary-bg); |
| --button-secondary-background-fill-hover: var(--sem-secondary-bg-hover); |
| --button-secondary-text-color: var(--sem-text); |
| --color-accent: var(--sem-primary); |
| --color-accent-soft: var(--sem-accent-soft); |
| --link-text-color: var(--sem-link); |
| --slider-color: var(--sem-primary); |
| --checkbox-background-color-selected: var(--sem-primary); |
| --loader-color: var(--sem-primary); |
| } |
| .gradio-container { padding-top: 24px !important; } |
| .sem-title h1 { |
| font-size: 2.4em; |
| line-height: 1.25; |
| margin: 0.2em 0 0.3em 0; |
| color: var(--sem-author); |
| } |
| .sem-card { |
| border: 1px solid var(--sem-card-border); |
| border-radius: 8px; |
| padding: 14px 16px; |
| margin-bottom: 12px; |
| background: var(--sem-card-bg); |
| font-family: serif; |
| } |
| .sem-score { |
| font-family: monospace; |
| font-size: 0.8em; |
| color: var(--sem-score); |
| margin-bottom: 6px; |
| } |
| .sem-author { |
| font-weight: bold; |
| font-size: 1.05em; |
| color: var(--sem-author); |
| } |
| .sem-meta { |
| color: var(--sem-meta); |
| font-size: 0.9em; |
| margin-bottom: 8px; |
| } |
| .sem-meta a { color: var(--sem-link); } |
| .sem-lang { font-size: 0.85em; } |
| .sem-greek { |
| font-size: 1.2em; |
| line-height: 1.8; |
| color: var(--sem-text); |
| } |
| .sem-warn { |
| border: 1px solid #d4a017; |
| background: #fff8e1; |
| border-radius: 8px; |
| padding: 10px 14px; |
| margin-bottom: 12px; |
| color: #7a5c00; |
| font-size: 0.9em; |
| } |
| body.dark .sem-warn, body.sem-darkscholar .sem-warn { |
| background: #33290e; |
| border-color: #8a6d12; |
| color: #e8c96a; |
| } |
| """ |
|
|
| |
| |
| SCHEME_CLASSES = { |
| "Parchment": "", |
| "Aegean": "sem-aegean", |
| "Clean academic": "sem-academic", |
| "Dark scholarly": "sem-darkscholar", |
| } |
| SCHEME_SWITCH_JS = """ |
| (choice) => { |
| const map = { |
| "Parchment": "", |
| "Aegean": "sem-aegean", |
| "Clean academic": "sem-academic", |
| "Dark scholarly": "sem-darkscholar", |
| }; |
| document.body.classList.remove("sem-aegean", "sem-academic", "sem-darkscholar"); |
| if (map[choice]) document.body.classList.add(map[choice]); |
| } |
| """ |
|
|
| def format_results_html(results: list[dict]) -> str: |
| if not results: |
| return "<p>No results.</p>" |
| cards = [] |
| for r in results: |
| score_pct = int(r["score"] * 100) |
| score_bar = "█" * (score_pct // 5) + "░" * (20 - score_pct // 5) |
| text_preview = r["text"][:400] + ("…" if len(r["text"]) > 400 else "") |
| date_note = f" · {r['date']}" if r.get("date") else "" |
| cards.append(f""" |
| <div class="sem-card"> |
| <div class="sem-score">{score_bar} {r['score']:.3f}</div> |
| <div class="sem-author">{r['author']}</div> |
| <div class="sem-meta"> |
| <em>{r['work']}</em> · §{r['section_ref']}{date_note} |
| · <span class="sem-lang">{r['lang_tag']}</span> |
| · <a href="{scaife_url(r)}" target="_blank" rel="noopener">Read in context ↗</a> |
| </div> |
| <div class="sem-greek">{text_preview}</div> |
| </div>""") |
| return "\n".join(cards) |
|
|
|
|
| def run_search(query, top_k, author_filter, century_filter, genre_filter, |
| llm_model, do_synthesize): |
| if not query.strip(): |
| yield "<p>Enter a search query.</p>", "", "", [], "", [] |
| return |
|
|
| results = semantic_search( |
| query, |
| top_k=int(top_k), |
| author_filter=author_filter or None, |
| century_filter=century_filter or None, |
| genre_filter=genre_filter or None, |
| ) |
|
|
| html = format_results_html(results) |
| top_authors = author_summary(results) |
|
|
| weak_match = bool(results) and results[0]["score"] < WEAK_MATCH_THRESHOLD |
| if weak_match: |
| html = ( |
| '<div class="sem-warn">' |
| f'⚠️ No strong matches — the best passage scored only ' |
| f'{results[0]["score"]:.2f}. The corpus may not contain this ' |
| 'concept; the results below are the nearest retrieved text, not ' |
| 'confirmed matches.</div>' |
| ) + html |
|
|
| |
| |
| yield html, top_authors, "", results, query, [] |
| if do_synthesize and results: |
| if weak_match: |
| note = ( |
| f"*Synthesis skipped: no retrieved passage scored above " |
| f"{WEAK_MATCH_THRESHOLD:.2f}, so the corpus likely does not " |
| f"address this concept directly.*" |
| ) |
| yield html, top_authors, note, results, query, [] |
| else: |
| for acc in synthesize_stream(query, results, llm_model): |
| yield html, top_authors, acc, results, query, [] |
|
|
|
|
| def chat_respond(message, history, llm_model, query, results, synthesis): |
| """Chat about the last search's passages; streams the assistant reply.""" |
| message = (message or "").strip() |
| if not message: |
| yield history, "" |
| return |
| history = history + [{"role": "user", "content": message}] |
| if not results: |
| history.append({ |
| "role": "assistant", |
| "content": "Run a search first — then I can discuss the retrieved passages with you.", |
| }) |
| yield history, "" |
| return |
|
|
| messages = [ |
| {"role": "system", |
| "content": chat_system_prompt(query, results, synthesis or "")} |
| ] + history |
| history.append({"role": "assistant", "content": ""}) |
| for acc in llm_chat_stream(messages, llm_model): |
| history[-1]["content"] = acc |
| yield history, "" |
|
|
|
|
| def build_ui() -> gr.Blocks: |
| with gr.Blocks(title="ζήτημα") as demo: |
| gr.Markdown( |
| "# Zetema\n" |
| "Query the ancient Greek corpus using natural language. " |
| "Passages are retrieved by embedding similarity, then reranked " |
| "with a cross-encoder for final ordering.", |
| elem_classes="sem-title", |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=3): |
| query_box = gr.Textbox( |
| label="Search query (in English or Greek)", |
| placeholder="e.g. 'the immortality of the soul', 'ψυχή', 'rhetoric and democracy'…", |
| lines=2, |
| ) |
| with gr.Column(scale=1): |
| search_btn = gr.Button("Search", variant="primary", size="md") |
|
|
| with gr.Row(): |
| top_k = gr.Slider(5, 50, value=15, step=5, label="Number of results") |
| author_filter = gr.Dropdown( |
| choices=_authors, |
| multiselect=True, |
| label="Filter by author", |
| value=None, |
| ) |
| century_filter = gr.Dropdown( |
| choices=_century_labels, |
| multiselect=True, |
| label="Filter by century", |
| value=None, |
| ) |
| genre_filter = gr.Dropdown( |
| choices=_genre_labels, |
| multiselect=True, |
| label="Filter by genre", |
| value=None, |
| ) |
|
|
| with gr.Row(): |
| llm_model = gr.Dropdown( |
| choices=LLM_MODELS, |
| value=DEFAULT_LLM, |
| label="LLM for synthesis / chat (loaded on first use)", |
| ) |
| do_synthesize = gr.Checkbox( |
| label="Synthesize results", |
| value=True, |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=2): |
| gr.Markdown("### Retrieved passages") |
| results_html = gr.HTML() |
| with gr.Column(scale=1): |
| gr.Markdown("### Top authors on this topic") |
| top_authors_md = gr.Markdown() |
| gr.Markdown("### LLM synthesis") |
| synthesis_md = gr.Markdown() |
|
|
| gr.Markdown("### Chat about these results") |
| chatbot = gr.Chatbot( |
| height=400, |
| label="Grounded in the retrieved passages — cleared on each new search", |
| ) |
| with gr.Row(): |
| chat_input = gr.Textbox( |
| placeholder="Ask about the retrieved passages — interpretation, translation, comparisons…", |
| show_label=False, |
| scale=5, |
| ) |
| chat_send = gr.Button("Send", scale=1) |
|
|
| results_state = gr.State([]) |
| query_state = gr.State("") |
|
|
| search_outputs = [ |
| results_html, top_authors_md, synthesis_md, |
| results_state, query_state, chatbot, |
| ] |
| search_inputs = [ |
| query_box, top_k, author_filter, century_filter, genre_filter, |
| llm_model, do_synthesize, |
| ] |
| search_btn.click(fn=run_search, inputs=search_inputs, outputs=search_outputs) |
| query_box.submit(fn=run_search, inputs=search_inputs, outputs=search_outputs) |
|
|
| |
| |
| chat_inputs = [chat_input, chatbot, llm_model, query_state, |
| results_state, synthesis_md] |
| chat_send.click(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input]) |
| chat_input.submit(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input]) |
|
|
| |
| |
| scheme_picker = gr.Radio( |
| choices=list(SCHEME_CLASSES), |
| value="Parchment", |
| label="Color scheme", |
| ) |
| scheme_picker.change(fn=None, inputs=scheme_picker, outputs=None, |
| js=SCHEME_SWITCH_JS) |
|
|
| return demo |
|
|
|
|
| def main() -> None: |
| print("Loading index...") |
| load_index() |
| load_reranker() |
| if IS_SPACE: |
| |
| |
| get_llm(DEFAULT_LLM) |
| demo = build_ui() |
| |
| default_port = "7860" if IS_SPACE else "7861" |
| demo.launch(server_name="0.0.0.0", |
| server_port=int(os.environ.get("GRADIO_SERVER_PORT", default_port)), |
| inbrowser=not IS_SPACE, |
| theme=gr.themes.Soft(), css=APP_CSS) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|