diogenet commited on
Commit
89f9f65
·
1 Parent(s): efa7834

Keep retrieval, synthesis and chat consistent; correct the corpus counts

Browse files

app.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.

Files changed (2) hide show
  1. README.md +18 -13
  2. app.py +51 -7
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 the Perseus Greek corpus
11
  ---
12
 
13
  # Zetema
14
 
15
- Semantic search over the [Perseus Digital Library](http://www.perseus.tufts.edu/) Greek corpus 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, so you can read it in its full context
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
- - **100 authors, 813 Greek texts**: Plato, Aristotle, Thucydides, Homer, Aeschylus, Sophocles, Euripides, Herodotus, and many more
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
- The `perseus` symlink in the project root must point to your local copy of the Perseus XML corpus. Adjust as needed:
51
 
52
  ```bash
53
- # example — change the target to match your actual path
54
  ln -sf /path/to/perseus/xml perseus
 
55
  ```
56
 
57
- The corpus should contain directories named `tlg0001`, `tlg0003`, … each with `__cts__.xml` metadata and TEI XML text files.
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 # corpus ingestion: parse → embed → store
162
- ├── app.py # Gradio web UI
163
- ├── extract_tlg_dates.py # derives data/tlg_dates.json from the TLG Canon
 
164
  ├── requirements.txt
165
- ├── perseus -> ... # symlink to Perseus XML corpus
 
166
  └── data/
167
- ├── tlg_dates.json # author dates + genres (checked in)
168
- ── perseus_rag_qwen.duckdb # built by ingest.py
 
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']}\n{p['text'][:600]}"
 
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]) -> str:
460
- return (
 
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", "content": chat_system_prompt(query, results)}
 
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
- chat_inputs = [chat_input, chatbot, llm_model, query_state, results_state]
 
 
 
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