Keep retrieval, synthesis and chat consistent; correct the corpus counts
Browse filesapp.py: the reranker's relevance scores now travel into the synthesis and chat
prompts, the chat is handed the synthesis the user is reading so follow-ups
build on it instead of contradicting it, and a shared evidence_note states
plainly when nothing cleared the weak-match threshold.
README.md: the corpus is Perseus plus First1KGreek, 364 authors and 1,644
works, not the 100 authors and 813 texts previously advertised; the Scaife
links open the English translation alongside where one exists.
README.md
CHANGED
|
@@ -7,22 +7,22 @@ sdk: gradio
|
|
| 7 |
sdk_version: 6.19.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
short_description: Semantic search over
|
| 11 |
---
|
| 12 |
|
| 13 |
# Zetema
|
| 14 |
|
| 15 |
-
Semantic search over the [Perseus Digital Library](http://www.perseus.tufts.edu/) Greek
|
| 16 |
|
| 17 |
## What it does
|
| 18 |
|
| 19 |
- **Cross-lingual retrieval**: type a query in English and retrieve matching passages in ancient Greek — the embedding model maps both into the same semantic space
|
| 20 |
- **Author ranking**: see at a glance which authors discuss a topic most
|
| 21 |
-
- **Read in context**: every result links to the passage in the [Scaife Viewer](https://scaife.perseus.org) via its CTS URN,
|
| 22 |
- **Reranked results**: dense retrieval fetches a candidate pool, then a cross-encoder reranker (`Qwen/Qwen3-Reranker-0.6B`) orders the final results
|
| 23 |
- **Century and genre filters**: narrow the search to particular centuries or genres (history, tragedy, philosophy, …), both derived from the TLG Canon; result cards show each author's dates
|
| 24 |
- **LLM synthesis**: optionally send top passages to a local LLM (Gemma 3 by default, via Hugging Face `transformers`, streamed) to get a summary in English with key phrase translations
|
| 25 |
-
- **
|
| 26 |
|
| 27 |
## Requirements
|
| 28 |
|
|
@@ -47,14 +47,15 @@ Install a `torch` build that matches your CUDA driver — a mismatched build sil
|
|
| 47 |
|
| 48 |
### 2. Point to the corpus
|
| 49 |
|
| 50 |
-
|
| 51 |
|
| 52 |
```bash
|
| 53 |
-
# example — change the
|
| 54 |
ln -sf /path/to/perseus/xml perseus
|
|
|
|
| 55 |
```
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
### 3. Build the index (one-time, ~10–20 min on GPU, longer on CPU)
|
| 60 |
|
|
@@ -158,19 +159,23 @@ Query (English or Greek)
|
|
| 158 |
|
| 159 |
```
|
| 160 |
zetema/
|
| 161 |
-
├── ingest.py
|
| 162 |
-
├── app.py
|
| 163 |
-
├── extract_tlg_dates.py
|
|
|
|
| 164 |
├── requirements.txt
|
| 165 |
-
├── perseus -> ...
|
|
|
|
| 166 |
└── data/
|
| 167 |
-
├── tlg_dates.json
|
| 168 |
-
|
|
|
|
| 169 |
```
|
| 170 |
|
| 171 |
## References
|
| 172 |
|
| 173 |
- [Perseus Digital Library](http://www.perseus.tufts.edu/), Tufts University
|
|
|
|
| 174 |
- [Thesaurus Linguae Graecae](https://www.tlg.uci.edu/) Canon (author dates and genres)
|
| 175 |
- [Qwen/Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B) embedding model
|
| 176 |
- [Qwen/Qwen3-Reranker-0.6B](https://huggingface.co/Qwen/Qwen3-Reranker-0.6B) reranker
|
|
|
|
| 7 |
sdk_version: 6.19.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: Semantic search over Perseus and First1KGreek
|
| 11 |
---
|
| 12 |
|
| 13 |
# Zetema
|
| 14 |
|
| 15 |
+
Semantic search over the [Perseus Digital Library](http://www.perseus.tufts.edu/) and [First1KGreek](https://github.com/OpenGreekAndLatin/First1K-Greek) Greek corpora using local open-weight language models. Ask questions in English (or Greek) and find which ancient authors write about a given topic.
|
| 16 |
|
| 17 |
## What it does
|
| 18 |
|
| 19 |
- **Cross-lingual retrieval**: type a query in English and retrieve matching passages in ancient Greek — the embedding model maps both into the same semantic space
|
| 20 |
- **Author ranking**: see at a glance which authors discuss a topic most
|
| 21 |
+
- **Read in context**: every result links to the passage in the [Scaife Viewer](https://scaife.perseus.org) via its CTS URN, with the English translation opened side by side when the corpus has one
|
| 22 |
- **Reranked results**: dense retrieval fetches a candidate pool, then a cross-encoder reranker (`Qwen/Qwen3-Reranker-0.6B`) orders the final results
|
| 23 |
- **Century and genre filters**: narrow the search to particular centuries or genres (history, tragedy, philosophy, …), both derived from the TLG Canon; result cards show each author's dates
|
| 24 |
- **LLM synthesis**: optionally send top passages to a local LLM (Gemma 3 by default, via Hugging Face `transformers`, streamed) to get a summary in English with key phrase translations
|
| 25 |
+
- **364 authors, 1,644 Greek works**: Perseus and [First1KGreek](https://github.com/OpenGreekAndLatin/First1K-Greek) together — Plato, Aristotle, Thucydides, Homer, Aeschylus, Sophocles, Euripides, Herodotus, and many more
|
| 26 |
|
| 27 |
## Requirements
|
| 28 |
|
|
|
|
| 47 |
|
| 48 |
### 2. Point to the corpus
|
| 49 |
|
| 50 |
+
Two symlinks in the project root point at your local corpus checkouts. Both are optional individually — `ingest.py` skips a missing one — but the index only covers what it can find:
|
| 51 |
|
| 52 |
```bash
|
| 53 |
+
# example — change the targets to match your actual paths
|
| 54 |
ln -sf /path/to/perseus/xml perseus
|
| 55 |
+
ln -sf /path/to/First1KGreek/data first1k
|
| 56 |
```
|
| 57 |
|
| 58 |
+
Each corpus should contain directories named `tlg0001`, `tlg0003`, … each with `__cts__.xml` metadata and TEI XML text files. Where the two corpora carry the same work, Perseus takes precedence and the duplicate is skipped.
|
| 59 |
|
| 60 |
### 3. Build the index (one-time, ~10–20 min on GPU, longer on CPU)
|
| 61 |
|
|
|
|
| 159 |
|
| 160 |
```
|
| 161 |
zetema/
|
| 162 |
+
├── ingest.py # corpus ingestion: parse → embed → store
|
| 163 |
+
├── app.py # Gradio web UI
|
| 164 |
+
├── extract_tlg_dates.py # derives data/tlg_dates.json from the TLG Canon
|
| 165 |
+
├── extract_eng_translations.py # derives data/eng_translations.json from the corpora
|
| 166 |
├── requirements.txt
|
| 167 |
+
├── perseus -> ... # symlink to Perseus XML corpus
|
| 168 |
+
├── first1k -> ... # symlink to First1KGreek XML corpus
|
| 169 |
└── data/
|
| 170 |
+
├── tlg_dates.json # author dates + genres (checked in)
|
| 171 |
+
├── eng_translations.json # work → English version label (checked in)
|
| 172 |
+
└── perseus_rag_qwen.duckdb # built by ingest.py
|
| 173 |
```
|
| 174 |
|
| 175 |
## References
|
| 176 |
|
| 177 |
- [Perseus Digital Library](http://www.perseus.tufts.edu/), Tufts University
|
| 178 |
+
- [First1KGreek](https://github.com/OpenGreekAndLatin/First1K-Greek), Open Greek and Latin
|
| 179 |
- [Thesaurus Linguae Graecae](https://www.tlg.uci.edu/) Canon (author dates and genres)
|
| 180 |
- [Qwen/Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B) embedding model
|
| 181 |
- [Qwen/Qwen3-Reranker-0.6B](https://huggingface.co/Qwen/Qwen3-Reranker-0.6B) reranker
|
app.py
CHANGED
|
@@ -430,13 +430,38 @@ CONTEXT_PASSAGES = 8 # how many retrieved passages the LLM sees
|
|
| 430 |
|
| 431 |
|
| 432 |
def passages_context(passages: list[dict]) -> str:
|
| 433 |
-
"""Format retrieved passages as numbered context for LLM prompts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
return "\n\n".join(
|
| 435 |
-
f"[{i}] {p['author']}, {p['work']} §{p['section_ref']}
|
|
|
|
| 436 |
for i, p in enumerate(passages[:CONTEXT_PASSAGES], 1)
|
| 437 |
)
|
| 438 |
|
| 439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
def synthesize_stream(query: str, passages: list[dict], model_name: str):
|
| 441 |
"""Stream an LLM synthesis of the retrieved passages."""
|
| 442 |
context = passages_context(passages)
|
|
@@ -445,6 +470,7 @@ def synthesize_stream(query: str, passages: list[dict], model_name: str):
|
|
| 445 |
f"You are a scholar of ancient Greek literature. "
|
| 446 |
f"The following passages from the Perseus corpus were retrieved for the query:\n"
|
| 447 |
f" \"{query}\"\n\n"
|
|
|
|
| 448 |
f"Passages (in ancient Greek):\n{context}\n\n"
|
| 449 |
f"Please:\n"
|
| 450 |
f"1. Identify the main themes or concepts related to the query found in these passages.\n"
|
|
@@ -456,12 +482,26 @@ def synthesize_stream(query: str, passages: list[dict], model_name: str):
|
|
| 456 |
yield from llm_chat_stream([{"role": "user", "content": prompt}], model_name)
|
| 457 |
|
| 458 |
|
| 459 |
-
def chat_system_prompt(query: str, passages: list[dict]
|
| 460 |
-
|
|
|
|
| 461 |
"You are a scholar of ancient Greek literature helping a user explore "
|
| 462 |
"passages retrieved from the Perseus corpus for the search query "
|
| 463 |
f"\"{query}\".\n\n"
|
|
|
|
| 464 |
f"Retrieved passages (in ancient Greek):\n{passages_context(passages)}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
"Answer the user's questions about these passages: interpret, translate, "
|
| 466 |
"compare, and point to the relevant authors and works. Refer to passages "
|
| 467 |
"by their bracketed numbers. If a question goes beyond what the passages "
|
|
@@ -745,7 +785,7 @@ def run_search(query, top_k, author_filter, century_filter, genre_filter,
|
|
| 745 |
yield html, top_authors, acc, results, query, []
|
| 746 |
|
| 747 |
|
| 748 |
-
def chat_respond(message, history, llm_model, query, results):
|
| 749 |
"""Chat about the last search's passages; streams the assistant reply."""
|
| 750 |
message = (message or "").strip()
|
| 751 |
if not message:
|
|
@@ -761,7 +801,8 @@ def chat_respond(message, history, llm_model, query, results):
|
|
| 761 |
return
|
| 762 |
|
| 763 |
messages = [
|
| 764 |
-
{"role": "system",
|
|
|
|
| 765 |
] + history
|
| 766 |
history.append({"role": "assistant", "content": ""})
|
| 767 |
for acc in llm_chat_stream(messages, llm_model):
|
|
@@ -858,7 +899,10 @@ def build_ui() -> gr.Blocks:
|
|
| 858 |
search_btn.click(fn=run_search, inputs=search_inputs, outputs=search_outputs)
|
| 859 |
query_box.submit(fn=run_search, inputs=search_inputs, outputs=search_outputs)
|
| 860 |
|
| 861 |
-
|
|
|
|
|
|
|
|
|
|
| 862 |
chat_send.click(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input])
|
| 863 |
chat_input.submit(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input])
|
| 864 |
|
|
|
|
| 430 |
|
| 431 |
|
| 432 |
def passages_context(passages: list[dict]) -> str:
|
| 433 |
+
"""Format retrieved passages as numbered context for LLM prompts.
|
| 434 |
+
|
| 435 |
+
Carries the reranker's relevance score with each passage. Without it the
|
| 436 |
+
synthesis and chat treat a 0.99 match and a 0.31 near-miss as equally
|
| 437 |
+
authoritative, discarding the ranking the retrieval path just computed.
|
| 438 |
+
"""
|
| 439 |
return "\n\n".join(
|
| 440 |
+
f"[{i}] {p['author']}, {p['work']} §{p['section_ref']} "
|
| 441 |
+
f"(relevance {p['score']:.2f})\n{p['text'][:600]}"
|
| 442 |
for i, p in enumerate(passages[:CONTEXT_PASSAGES], 1)
|
| 443 |
)
|
| 444 |
|
| 445 |
|
| 446 |
+
def evidence_note(passages: list[dict]) -> str:
|
| 447 |
+
"""Shared preamble telling the LLM how to read the relevance scores."""
|
| 448 |
+
best = max((p["score"] for p in passages[:CONTEXT_PASSAGES]), default=0.0)
|
| 449 |
+
note = (
|
| 450 |
+
"Passages are listed best-first, and each carries the reranker's "
|
| 451 |
+
"relevance score from 0 to 1. Weight them accordingly: a passage "
|
| 452 |
+
f"scoring below about {WEAK_MATCH_THRESHOLD:.2f} is a weak match that "
|
| 453 |
+
"may not bear on the query at all."
|
| 454 |
+
)
|
| 455 |
+
if best < WEAK_MATCH_THRESHOLD:
|
| 456 |
+
note += (
|
| 457 |
+
" Note that NO passage here scored above "
|
| 458 |
+
f"{WEAK_MATCH_THRESHOLD:.2f}, so the corpus probably does not "
|
| 459 |
+
"address this query. Say so plainly rather than assembling an "
|
| 460 |
+
"answer out of near-misses."
|
| 461 |
+
)
|
| 462 |
+
return note
|
| 463 |
+
|
| 464 |
+
|
| 465 |
def synthesize_stream(query: str, passages: list[dict], model_name: str):
|
| 466 |
"""Stream an LLM synthesis of the retrieved passages."""
|
| 467 |
context = passages_context(passages)
|
|
|
|
| 470 |
f"You are a scholar of ancient Greek literature. "
|
| 471 |
f"The following passages from the Perseus corpus were retrieved for the query:\n"
|
| 472 |
f" \"{query}\"\n\n"
|
| 473 |
+
f"{evidence_note(passages)}\n\n"
|
| 474 |
f"Passages (in ancient Greek):\n{context}\n\n"
|
| 475 |
f"Please:\n"
|
| 476 |
f"1. Identify the main themes or concepts related to the query found in these passages.\n"
|
|
|
|
| 482 |
yield from llm_chat_stream([{"role": "user", "content": prompt}], model_name)
|
| 483 |
|
| 484 |
|
| 485 |
+
def chat_system_prompt(query: str, passages: list[dict],
|
| 486 |
+
synthesis: str = "") -> str:
|
| 487 |
+
prompt = (
|
| 488 |
"You are a scholar of ancient Greek literature helping a user explore "
|
| 489 |
"passages retrieved from the Perseus corpus for the search query "
|
| 490 |
f"\"{query}\".\n\n"
|
| 491 |
+
f"{evidence_note(passages)}\n\n"
|
| 492 |
f"Retrieved passages (in ancient Greek):\n{passages_context(passages)}\n\n"
|
| 493 |
+
)
|
| 494 |
+
if synthesis.strip():
|
| 495 |
+
# The user is reading this summary on screen, so the chat has to build
|
| 496 |
+
# on it. Without it the chat answers as though the summary never
|
| 497 |
+
# existed and can flatly contradict what the user just read.
|
| 498 |
+
prompt += (
|
| 499 |
+
"You have already given the user this summary of these passages:\n"
|
| 500 |
+
f"---\n{synthesis.strip()}\n---\n"
|
| 501 |
+
"Stay consistent with it. If you now think part of it was wrong, "
|
| 502 |
+
"say so explicitly rather than quietly changing your account.\n\n"
|
| 503 |
+
)
|
| 504 |
+
return prompt + (
|
| 505 |
"Answer the user's questions about these passages: interpret, translate, "
|
| 506 |
"compare, and point to the relevant authors and works. Refer to passages "
|
| 507 |
"by their bracketed numbers. If a question goes beyond what the passages "
|
|
|
|
| 785 |
yield html, top_authors, acc, results, query, []
|
| 786 |
|
| 787 |
|
| 788 |
+
def chat_respond(message, history, llm_model, query, results, synthesis):
|
| 789 |
"""Chat about the last search's passages; streams the assistant reply."""
|
| 790 |
message = (message or "").strip()
|
| 791 |
if not message:
|
|
|
|
| 801 |
return
|
| 802 |
|
| 803 |
messages = [
|
| 804 |
+
{"role": "system",
|
| 805 |
+
"content": chat_system_prompt(query, results, synthesis or "")}
|
| 806 |
] + history
|
| 807 |
history.append({"role": "assistant", "content": ""})
|
| 808 |
for acc in llm_chat_stream(messages, llm_model):
|
|
|
|
| 899 |
search_btn.click(fn=run_search, inputs=search_inputs, outputs=search_outputs)
|
| 900 |
query_box.submit(fn=run_search, inputs=search_inputs, outputs=search_outputs)
|
| 901 |
|
| 902 |
+
# synthesis_md is passed by value: the chat needs to see the summary
|
| 903 |
+
# the user is currently reading.
|
| 904 |
+
chat_inputs = [chat_input, chatbot, llm_model, query_state,
|
| 905 |
+
results_state, synthesis_md]
|
| 906 |
chat_send.click(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input])
|
| 907 |
chat_input.submit(fn=chat_respond, inputs=chat_inputs, outputs=[chatbot, chat_input])
|
| 908 |
|