diyclassics Claude Opus 4.6 (1M context) commited on
Commit
efe9b39
Β·
1 Parent(s): 6da16e8

Add latincy-lexicon demos: lexicon lookup and paradigm generator

Browse files

Two new pages wrapping latincy-lexicon v0.1.0:

- 11_lexicon_lookup_demo β€” contextual Whitaker's Words lookup over
a Latin sentence, with expandable per-token dictionary entries
(principal parts, age/frequency, all glosses)
- 12_paradigm_demo β€” full inflectional paradigm for any token,
with per-feature filter dropdowns

Shared lexicon_helpers.py caches one pipeline across both pages
(spaCy + whitakers_words + paradigm_generator), so the first visit
pays ~10-15s for spaCy load + JSON artifact build, and subsequent
page switches are instant. Preset sentences drawn from the
latincy-lexicon demo notebook (Aeneid, Caesar, Cicero, Catullus,
Ovid, plus two simple sentences).

Installs latincy-lexicon from the v0.1.0 git tag; flip to a PyPI
pin once the package is published.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

app.py CHANGED
@@ -24,5 +24,7 @@ st.markdown(
24
  - [Normalize U/V spelling](uv_normalizer_demo) with rule-based [latincy-uv](https://github.com/diyclassics/latincy-uv)
25
  - [Correct long-s OCR artifacts](long_s_demo) with [latincy-long-s](https://github.com/diyclassics/latincy-long-s)
26
  - [Restore Greek diacritics](diacritics_demo) with [latincy-diacritics](https://github.com/diyclassics/latincy-diacritics)
 
 
27
  """
28
  )
 
24
  - [Normalize U/V spelling](uv_normalizer_demo) with rule-based [latincy-uv](https://github.com/diyclassics/latincy-uv)
25
  - [Correct long-s OCR artifacts](long_s_demo) with [latincy-long-s](https://github.com/diyclassics/latincy-long-s)
26
  - [Restore Greek diacritics](diacritics_demo) with [latincy-diacritics](https://github.com/diyclassics/latincy-diacritics)
27
+ - [Look up Latin words in Whitaker's Words](lexicon_lookup_demo) with [latincy-lexicon](https://github.com/latincy/latincy-lexicon)
28
+ - [Generate full inflectional paradigms](paradigm_demo) for any Latin token
29
  """
30
  )
lexicon_helpers.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for latincy-lexicon demo pages (11, 12, 13)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import streamlit as st
9
+
10
+
11
+ # Preset sentences shared across all three lexicon demos.
12
+ # Drawn from the latincy-lexicon demo notebook.
13
+ PRESET_SENTENCES: dict[str, str] = {
14
+ "Simple β€” Poeta bonus": "Poeta bonus carmina pulchra scribit.",
15
+ "Simple β€” Gallia": "Gallia est omnis divisa in partes tres.",
16
+ "Aeneid I.1": (
17
+ "Arma virumque cano, Troiae qui primus ab oris "
18
+ "Italiam fato profugus Laviniaque venit litora."
19
+ ),
20
+ "Caesar BG I.1": (
21
+ "Gallia est omnis divisa in partes tres, quarum unam "
22
+ "incolunt Belgae, aliam Aquitani, tertiam qui ipsorum "
23
+ "lingua Celtae, nostra Galli appellantur."
24
+ ),
25
+ "Cicero Cat. I.1": (
26
+ "Quo usque tandem abutere, Catilina, patientia nostra? "
27
+ "Quam diu etiam furor iste tuus nos eludet?"
28
+ ),
29
+ "Catullus 5": (
30
+ "Vivamus mea Lesbia atque amemus, rumoresque senum "
31
+ "severiorum omnes unius aestimemus assis."
32
+ ),
33
+ "Ovid Met. I.1": (
34
+ "In nova fert animus mutatas dicere formas corpora."
35
+ ),
36
+ }
37
+
38
+
39
+ @st.cache_resource(show_spinner="Building Whitaker's Words data (first load only)…")
40
+ def build_lexicon_artifacts() -> tuple[Path, Path]:
41
+ """Build lexicon.json + analyzer.json once per session.
42
+
43
+ Artifacts are cached under a temp directory. First call takes
44
+ ~5–10s; subsequent calls short-circuit via Streamlit's
45
+ cache_resource and are instant.
46
+ """
47
+ from latincy_lexicon.build import build
48
+
49
+ output_dir = Path(tempfile.gettempdir()) / "latincy-dashboard-lexicon"
50
+ output_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ lexicon_path = output_dir / "lexicon.json"
53
+ analyzer_path = output_dir / "analyzer.json"
54
+
55
+ if not (lexicon_path.exists() and analyzer_path.exists()):
56
+ build(output_dir=output_dir)
57
+
58
+ return lexicon_path, analyzer_path
59
+
60
+
61
+ @st.cache_resource(show_spinner="Loading LatinCy pipeline…")
62
+ def load_lexicon_pipeline(model_name: str):
63
+ """Load a LatinCy model with whitakers_words + paradigm_generator attached.
64
+
65
+ Cached per model_name β€” all three lexicon demo pages share the same
66
+ pipeline instance, so switching pages is instant after the first
67
+ load.
68
+ """
69
+ import spacy
70
+
71
+ nlp = spacy.load(model_name)
72
+ lexicon_path, analyzer_path = build_lexicon_artifacts()
73
+
74
+ nlp.add_pipe(
75
+ "whitakers_words",
76
+ config={
77
+ "lexicon_path": str(lexicon_path),
78
+ "analyzer_path": str(analyzer_path),
79
+ },
80
+ last=True,
81
+ )
82
+ nlp.add_pipe(
83
+ "paradigm_generator",
84
+ config={"analyzer_path": str(analyzer_path)},
85
+ last=True,
86
+ )
87
+ return nlp
88
+
89
+
90
+ def sentence_picker(key: str, default_idx: int = 0) -> str:
91
+ """Render a preset dropdown + free-text area. Returns selected text."""
92
+ options = list(PRESET_SENTENCES.keys())
93
+ preset = st.selectbox(
94
+ "Preset sentence:",
95
+ options,
96
+ index=default_idx,
97
+ key=f"preset_{key}",
98
+ )
99
+ text = st.text_area(
100
+ "Latin text:",
101
+ value=PRESET_SENTENCES[preset],
102
+ height=100,
103
+ key=f"text_{key}",
104
+ )
105
+ return text
pages/11_lexicon_lookup_demo.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ from lexicon_helpers import load_lexicon_pipeline, sentence_picker
5
+
6
+ st.set_page_config(page_title="Lexicon Lookup Demo", layout="wide")
7
+ st.sidebar.header("Lexicon Lookup Demo")
8
+
9
+ st.title("Whitaker's Words Lexicon Lookup")
10
+ st.markdown(
11
+ """
12
+ Run Latin text through LatinCy + Whitaker's Words to get dictionary
13
+ glosses and ranked parses for each token.
14
+ """
15
+ )
16
+
17
+ st.info(
18
+ "This lookup is designed for **contextual analysis**: entries are "
19
+ "ranked using upstream LatinCy annotations (POS, morphology, "
20
+ "dependency, NER). Single-word or fragment inputs may rank poorly "
21
+ "because those signals are missing or unreliable β€” give it a full "
22
+ "sentence for best results."
23
+ )
24
+
25
+ model_name = st.sidebar.selectbox(
26
+ "Choose model:",
27
+ ("la_core_web_lg", "la_core_web_md", "la_core_web_sm"),
28
+ )
29
+
30
+ nlp = load_lexicon_pipeline(model_name)
31
+
32
+ tab1, tab2 = st.tabs(["Lookup", "About"])
33
+
34
+ with tab1:
35
+ text = sentence_picker("lookup")
36
+
37
+ if st.button("Analyze", key="lookup_analyze"):
38
+ doc = nlp(text)
39
+ rows = []
40
+ lex_data = []
41
+ for token in doc:
42
+ if token.is_punct or token.is_space:
43
+ continue
44
+ lex = token._.lexicon or []
45
+ top_gloss = ""
46
+ if lex:
47
+ glosses = lex[0].get("glosses", [])
48
+ if isinstance(glosses, list) and glosses:
49
+ top_gloss = glosses[0]
50
+ elif isinstance(glosses, str):
51
+ top_gloss = glosses
52
+ rows.append(
53
+ {
54
+ "Token": token.text,
55
+ "Lemma": token.lemma_,
56
+ "POS": token.pos_,
57
+ "Top Gloss": top_gloss[:80],
58
+ }
59
+ )
60
+ lex_data.append({"text": token.text, "entries": lex})
61
+ st.session_state["lookup_rows"] = rows
62
+ st.session_state["lookup_lex"] = lex_data
63
+
64
+ if "lookup_rows" in st.session_state:
65
+ df = pd.DataFrame(st.session_state["lookup_rows"])
66
+ st.dataframe(
67
+ df,
68
+ use_container_width=True,
69
+ hide_index=True,
70
+ column_config={
71
+ "Token": st.column_config.TextColumn(width="small"),
72
+ "Lemma": st.column_config.TextColumn(width="small"),
73
+ "POS": st.column_config.TextColumn(width="small"),
74
+ "Top Gloss": st.column_config.TextColumn(width="large"),
75
+ },
76
+ )
77
+
78
+ st.markdown("---")
79
+ st.markdown("**Select a token for the full dictionary entry:**")
80
+ lex_data = st.session_state["lookup_lex"]
81
+ options = [f"{d['text']} ({i + 1})" for i, d in enumerate(lex_data)]
82
+ idx = st.selectbox(
83
+ "Token:",
84
+ range(len(options)),
85
+ format_func=lambda i: options[i],
86
+ key="lookup_token",
87
+ )
88
+
89
+ if idx is not None:
90
+ tok = lex_data[idx]
91
+ st.subheader(tok["text"])
92
+ if not tok["entries"]:
93
+ st.info("No lexicon entries for this token.")
94
+ else:
95
+ for j, entry in enumerate(tok["entries"], 1):
96
+ headword = entry.get("headword") or entry.get("lemma", "?")
97
+ pos = entry.get("pos", "?")
98
+ st.markdown(f"**Entry {j}:** `{headword}` ({pos})")
99
+
100
+ parts = entry.get("principal_parts")
101
+ if parts:
102
+ if isinstance(parts, list):
103
+ parts = ", ".join(str(p) for p in parts)
104
+ st.markdown(f"- Principal parts: `{parts}`")
105
+
106
+ glosses = entry.get("glosses")
107
+ if glosses:
108
+ if isinstance(glosses, list):
109
+ st.markdown(
110
+ "- Glosses:\n"
111
+ + "\n".join(f" - {g}" for g in glosses[:6])
112
+ )
113
+ else:
114
+ st.markdown(f"- Gloss: {glosses}")
115
+
116
+ age = entry.get("age")
117
+ freq = entry.get("frequency") or entry.get("freq")
118
+ meta = []
119
+ if age:
120
+ meta.append(f"Age: `{age}`")
121
+ if freq:
122
+ meta.append(f"Frequency: `{freq}`")
123
+ if meta:
124
+ st.markdown("- " + " | ".join(meta))
125
+
126
+ st.markdown("---")
127
+
128
+ with tab2:
129
+ st.markdown(
130
+ """
131
+ ## About
132
+
133
+ This demo runs Latin text through the full LatinCy pipeline plus
134
+ the `whitakers_words` component from
135
+ [latincy-lexicon](https://github.com/latincy/latincy-lexicon),
136
+ which wraps [Whitaker's Words](https://mk270.github.io/whitakers-words/)
137
+ as a spaCy pipeline component.
138
+
139
+ Each token gets:
140
+
141
+ - `token._.gloss` β€” short definition from the top-ranked entry
142
+ - `token._.lexicon` β€” full dictionary entries with principal parts,
143
+ age/frequency metadata, and glosses
144
+ - `token._.ww` β€” ranked morphological parses from the WW stem+ending
145
+ engine
146
+
147
+ Entries are ranked using upstream LatinCy signals (POS match,
148
+ morphological features, dependency label, NER context) plus WW's
149
+ built-in frequency grades.
150
+
151
+ ### First-run note
152
+
153
+ On first load, the page builds the WW lexicon and analyzer JSON
154
+ artifacts in memory (~5–10s). Subsequent loads are instant β€”
155
+ Streamlit caches the pipeline for the session.
156
+ """
157
+ )
pages/12_paradigm_demo.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import streamlit as st
3
+
4
+ from lexicon_helpers import load_lexicon_pipeline, sentence_picker
5
+
6
+ st.set_page_config(page_title="Paradigm Demo", layout="wide")
7
+ st.sidebar.header("Paradigm Demo")
8
+
9
+ st.title("Latin Paradigm Generator")
10
+ st.markdown(
11
+ """
12
+ Pick a token from a Latin sentence to see its complete inflectional
13
+ paradigm β€” every form the lemma can take, generated from Whitaker's
14
+ Words inflection tables.
15
+ """
16
+ )
17
+
18
+ model_name = st.sidebar.selectbox(
19
+ "Choose model:",
20
+ ("la_core_web_lg", "la_core_web_md", "la_core_web_sm"),
21
+ )
22
+
23
+ nlp = load_lexicon_pipeline(model_name)
24
+
25
+
26
+ def _feats_to_dict(feats) -> dict:
27
+ """Normalize feats to a plain dict (accepts dict or UD-string)."""
28
+ if isinstance(feats, dict):
29
+ return feats
30
+ if isinstance(feats, str):
31
+ return dict(
32
+ kv.split("=", 1) for kv in feats.split("|") if "=" in kv
33
+ )
34
+ return {}
35
+
36
+
37
+ tab1, tab2 = st.tabs(["Paradigm", "About"])
38
+
39
+ with tab1:
40
+ text = sentence_picker("paradigm")
41
+
42
+ if st.button("Analyze", key="paradigm_analyze"):
43
+ doc = nlp(text)
44
+ tokens = []
45
+ for token in doc:
46
+ if token.is_punct or token.is_space:
47
+ continue
48
+ paradigm = token._.paradigm or []
49
+ # Serialize to plain dicts so we can stash in session state.
50
+ serialized = [
51
+ {
52
+ "form": f.get("form") if isinstance(f, dict) else f.form,
53
+ "lemma": f.get("lemma") if isinstance(f, dict) else f.lemma,
54
+ "upos": f.get("upos") if isinstance(f, dict) else f.upos,
55
+ "feats": _feats_to_dict(
56
+ f.get("feats") if isinstance(f, dict) else f.feats
57
+ ),
58
+ }
59
+ for f in paradigm
60
+ ]
61
+ tokens.append(
62
+ {
63
+ "text": token.text,
64
+ "lemma": token.lemma_,
65
+ "pos": token.pos_,
66
+ "paradigm": serialized,
67
+ }
68
+ )
69
+ st.session_state["paradigm_tokens"] = tokens
70
+
71
+ if "paradigm_tokens" in st.session_state:
72
+ tokens = st.session_state["paradigm_tokens"]
73
+
74
+ overview = pd.DataFrame(
75
+ [
76
+ {
77
+ "Token": t["text"],
78
+ "Lemma": t["lemma"],
79
+ "POS": t["pos"],
80
+ "Forms": len(t["paradigm"]),
81
+ }
82
+ for t in tokens
83
+ ]
84
+ )
85
+ st.dataframe(overview, use_container_width=True, hide_index=True)
86
+
87
+ st.markdown("---")
88
+ st.markdown("**Select a token to see its full paradigm:**")
89
+ options = [
90
+ f"{t['text']} β€” {t['lemma']} ({t['pos']})" for t in tokens
91
+ ]
92
+ idx = st.selectbox(
93
+ "Token:",
94
+ range(len(options)),
95
+ format_func=lambda i: options[i],
96
+ key="paradigm_token",
97
+ )
98
+
99
+ if idx is not None:
100
+ t = tokens[idx]
101
+ if not t["paradigm"]:
102
+ st.info(
103
+ f"No paradigm available for {t['text']} "
104
+ "(unknown lemma or closed-class word)."
105
+ )
106
+ else:
107
+ # Build a wide table: Form | UPOS | <feat columns>
108
+ rows = []
109
+ all_feats: list[str] = []
110
+ for f in t["paradigm"]:
111
+ for k in f["feats"]:
112
+ if k not in all_feats:
113
+ all_feats.append(k)
114
+ for f in t["paradigm"]:
115
+ row = {"Form": f["form"], "UPOS": f["upos"]}
116
+ for k in all_feats:
117
+ row[k] = f["feats"].get(k, "")
118
+ rows.append(row)
119
+
120
+ st.markdown(
121
+ f"### `{t['lemma']}` β€” {len(t['paradigm'])} forms"
122
+ )
123
+
124
+ # Optional feature filter
125
+ with st.expander("Filter by feature", expanded=False):
126
+ filters: dict[str, str] = {}
127
+ cols = st.columns(min(len(all_feats), 4) or 1)
128
+ for i, feat in enumerate(all_feats):
129
+ vals = sorted(
130
+ {f["feats"].get(feat, "") for f in t["paradigm"]}
131
+ - {""}
132
+ )
133
+ if not vals:
134
+ continue
135
+ with cols[i % len(cols)]:
136
+ choice = st.selectbox(
137
+ feat,
138
+ ["(any)"] + vals,
139
+ key=f"filter_{feat}",
140
+ )
141
+ if choice != "(any)":
142
+ filters[feat] = choice
143
+
144
+ if filters:
145
+ rows = [
146
+ r
147
+ for r in rows
148
+ if all(r.get(k) == v for k, v in filters.items())
149
+ ]
150
+ st.caption(f"Showing {len(rows)} form(s) matching filters.")
151
+
152
+ df = pd.DataFrame(rows)
153
+ st.dataframe(df, use_container_width=True, hide_index=True)
154
+
155
+ with tab2:
156
+ st.markdown(
157
+ """
158
+ ## About
159
+
160
+ The `paradigm_generator` component attaches a full inflectional
161
+ paradigm to each token via `token._.paradigm` β€” the inverse of
162
+ Whitaker's analyzer: given a lemma, produce every form the lemma
163
+ can take.
164
+
165
+ ### Form counts to expect
166
+
167
+ | POS | Forms | Notes |
168
+ |-----|-------|-------|
169
+ | Verb (regular) | 200–280 | Person Γ— Number Γ— Tense Γ— Mood Γ— Voice |
170
+ | Adjective (3 endings) | ~84 | Case Γ— Number Γ— Gender Γ— Degree |
171
+ | Noun | 10–15 | Case Γ— Number |
172
+ | `sum` (irregular) | ~143 | Includes suppletive stems (s-, es-, fu-, fo-) |
173
+
174
+ ### How it works
175
+
176
+ Each entry in the shipped analyzer JSON knows its inflection
177
+ pattern (declension or conjugation). The generator applies every
178
+ applicable ending from the WW inflection tables, filters by age
179
+ and frequency where appropriate, and returns forms tagged with
180
+ UD morphological features.
181
+ """
182
+ )
requirements.txt CHANGED
@@ -9,5 +9,6 @@ la-latincy-lookups>=1.0.0
9
  spacy-streamlit==1.0.6
10
  latincy-diacritics @ https://huggingface.co/latincy/latincy-diacritics/resolve/main/latincy_diacritics-0.1.0-py3-none-any.whl
11
  latincy-preprocess>=0.2.0
 
12
  streamlit==1.45.1
13
  watchdog==6.0.0
 
9
  spacy-streamlit==1.0.6
10
  latincy-diacritics @ https://huggingface.co/latincy/latincy-diacritics/resolve/main/latincy_diacritics-0.1.0-py3-none-any.whl
11
  latincy-preprocess>=0.2.0
12
+ latincy-lexicon @ git+https://github.com/latincy/latincy-lexicon@v0.1.0
13
  streamlit==1.45.1
14
  watchdog==6.0.0