Skip to content

aegean.ai

ai

Multi-provider AI layer — grounded, exploratory-labeled.

Providers: Anthropic (default), OpenAI, xAI Grok, Google Gemini, OpenRouter, and local (a locally hosted OpenAI-compatible endpoint: Ollama, LM Studio, llama.cpp, vLLM). Each is an optional extra, lazily imported. Capabilities: translate, gloss, decipher_hypotheses, nlp_assist, ask, summarize. Every generative output is an ExploratoryResult with provenance and an unverified flag.

from aegean import ai
client = ai.get_client("anthropic")          # needs pyaegean[anthropic] + a key
result = ai.translate("μῆνιν ἄειδε θεά", client=client)
print(result.labeled())                       # carries the EXPLORATORY tag

ResponseCache

ResponseCache(path: str | Path | None = None)

Get/set completions by content hash, optionally persisted to JSON.

AIError

Bases: RuntimeError

Base class for AI-layer errors.

ExploratoryResult dataclass

ExploratoryResult(text: str, kind: str, provider: str, model: str, prompt_version: str, grounding: tuple[GroundingItem, ...] = (), exploratory: bool = True, data: Any = None)

A generative result, explicitly labeled exploratory and provenanced.

grounding is the structured corpus/lexicon/analysis evidence fed to the model (each a GroundingItem with a source and a ref). Use labeled when surfacing to a user so the caveat travels with the text, trace to audit which local facts grounded the output, and data (when set by a structured capability) for the parsed JSON payload.

labeled

labeled() -> str

The text prefixed with an unmistakable exploratory provenance tag.

trace

trace() -> str

A human-readable provenance trace: the generative step and the local, non-generative evidence that grounded it, grouped by source.

Makes the exploratory result auditable — every grounding line names the source (corpus, lexicon, analysis step) and the ref it came from, so a reader can check the output against the facts it was given rather than taking it on trust.

to_dict

to_dict() -> dict[str, Any]

A stable, JSON-ready serialization. Keeps the exploratory flag, the text, the grounding, and any structured data — so a saved AI result can never be mistaken for ground truth when read back.

to_json

to_json(path: str | Path | None = None, *, indent: int | None = 2) -> str | None

Serialize to JSON: returns the string, or writes it to path and returns None.

from_dict classmethod

from_dict(data: dict[str, Any]) -> 'ExploratoryResult'

Reconstruct an ExploratoryResult from to_dict output.

LLMClient

LLMClient(model: str | None = None, *, api_key: str | None = None, cache: ResponseCache | None = None)

Bases: ABC

Abstract provider client. Subclasses implement _complete.

complete

complete(prompt: str, *, system: str | None = None, max_tokens: int = 1024) -> LLMResponse

A cached single-turn completion (cache is keyed on provider identity/model/ system/prompt/max_tokens so re-asking is free and deterministic).

LLMResponse dataclass

LLMResponse(text: str, provider: str, model: str, raw: Any = None)

A raw completion from a provider.

MissingAPIKey

Bases: AIError

Raised when no API key is available for a provider.

ProviderCallError

Bases: AIError

Raised when a provider's API call fails (bad model id, authentication, rate limit, network). Wraps the SDK's exception so callers see one AIError type instead of a provider-specific traceback; the underlying error is the __cause__.

ProviderNotInstalled

Bases: AIError

Raised when a provider's optional SDK isn't installed.

UnknownProvider

Bases: AIError

Raised for an unregistered provider id.

CaseResult dataclass

CaseResult(name: str, used: tuple[str, ...], missing: tuple[str, ...], fabricated: tuple[str, ...], groundedness: float, clean: bool, text: str)

The scored outcome of one case.

EvalReport dataclass

EvalReport(cases: tuple[CaseResult, ...], groundedness: float, fabrication_rate: float, n: int = 0)

Aggregate over a case set: mean groundedness and the fabrication rate (fraction of cases where any must_avoid appeared).

GroundingCase dataclass

GroundingCase(name: str, prompt: str, grounding: tuple[str | GroundingItem, ...] = (), must_use: tuple[str, ...] = (), must_avoid: tuple[str, ...] = (), kind: str = 'ask', note: str = '')

One eval case: a prompt, the evidence to feed, and the facts a faithful answer should use / must not fabricate.

kind picks the capability (ask / decipher / gloss / summarize / translate). must_use are strings a grounded answer should reference; must_avoid are strings that, if present, signal the model went beyond (or against) its evidence.

GroundingItem dataclass

GroundingItem(content: str, source: str = 'custom', ref: str = '')

One piece of grounding evidence and its provenance.

content is what the model sees; source is the provenance category (e.g. "corpus:lineara", "lexicon:LSJ", "lemmatizer", "transliteration", "analysis:cooccurrence"); ref is the specific locator it concerns (a word, lemma, or document id). Stringifies to content so it drops into the prompt like a plain evidence line.

RegimeSignal dataclass

RegimeSignal(regime: str, rarity: float, polysemy: float, markedness: float, content_words: int)

An estimate of whether lexical grounding helps this text (EXPLORATORY).

regime is "help" / "neutral" / "hurt"; rarity, polysemy, and markedness are the three component signals (each 0..1-ish), and content_words is how many content words fed the estimate. A difficulty signal, not a measured accuracy.

SenseCandidate dataclass

SenseCandidate(marker: str, level: int, gloss: str, score: float, overlap: tuple[str, ...], dominant: bool)

One ranked LSJ sense of a word (EXPLORATORY: a hypothesis, not a decision).

marker/level/gloss mirror the underlying greek.lexicon.Sense; score is the context-fit total; overlap are the context lemmas (accent- stripped) that the sense's cited Greek shares; dominant flags the order-1 sense (the gloss content_glosses would have used).

ask

ask(question: str, *, grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Answer a question over corpus/commentary grounding.

decipher_hypotheses

decipher_hypotheses(text: str, *, grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Offer decipherment hypotheses for an undeciphered (Linear A) sequence, each tied to cited corpus evidence. Strictly exploratory.

extract

extract(text: str, *, instruction: str = 'Extract the structured data from the following.', schema: Mapping[str, str] | str | None = None, grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Ask for structured (JSON) output and parse it into result.data so the AI layer can feed a pipeline or database.

schema describes the wanted shape — a mapping of field → description (rendered as a field list) or a free-form shape string — and is appended to instruction. The model is told to return JSON only; the response is parsed leniently (parse_json). result.data is the parsed value (or None if the model didn't return parseable JSON — result.text always has the raw response). Still exploratory and grounded like every capability.

r = extract("KN Fp 1: OLE S 1", schema={"commodity": "ideogram", ... "amount": "number"}, client=client) # doctest: +SKIP r.data # doctest: +SKIP

gloss

gloss(text: str, *, source: str = 'Ancient Greek', grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Produce an interlinear, word-by-word gloss of the source text.

nlp_assist

nlp_assist(text: str, *, task: str = 'lemma and POS disambiguation', grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Ask the model to disambiguate an NLP analysis (lemma/POS/parse) where the rule-based pipeline is uncertain.

parse_json

parse_json(text: str) -> Any | None

Best-effort parse of a JSON value from a model response. Returns None (never raises) when nothing parseable is found.

Tolerant of the ways models wrap JSON: a `json fenced block, or prose around a bare object/array. Tries the fenced content, then the whole string, then the outermost/[...]`` slice.

summarize

summarize(text: str, *, grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Summarize a corpus excerpt or commentary.

translate

translate(text: str, *, source: str = 'Ancient Greek', target: str = 'English', grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Translate source text, grounded in optional lexicon/corpus evidence.

verify_translation

verify_translation(text: str, draft: str, *, source: str = 'Ancient Greek', target: str = 'English', grounding: Grounding = (), client: LLMClient | None = None) -> ExploratoryResult

Check a draft translation against deterministic grounding and repair only definite contradictions, returning the corrected translation.

The model is shown the source text, the draft, and the local analysis (morphology, syntax, dictionary glosses, idiom meanings), and is asked to fix only clear errors the analysis contradicts (wrong voice, subject/object, case relation, a rare word's or idiom's sense, an omission or addition) and otherwise keep the draft. This is the repair half of a translate-then-check pass: the grounding never touches the draft, so it cannot bias it, though a wrong analysis can still mislead the repair. The result is a translate-kind ExploratoryResult carrying the grounding, so callers handle it exactly like a translate result.

get_client

get_client(provider: str = 'anthropic', *, model: str | None = None, api_key: str | None = None, cache: ResponseCache | None = None) -> LLMClient

Construct a client for provider (default Anthropic). Importing aegean.ai registers all built-in providers.

register_provider

register_provider(cls: type[LLMClient]) -> type[LLMClient]

Register an LLMClient subclass under its provider name (each adapter calls this).

run_eval

run_eval(cases: Sequence[GroundingCase], client: LLMClient) -> EvalReport

Run each case through its capability with client and aggregate.

Needs a working LLMClient (a provider with a key, or a stub). Returns an EvalReport with mean groundedness and the fabrication rate — the AI layer's analogue of the lemmatizer's held-out accuracy.

score_text

score_text(text: str, case: GroundingCase) -> CaseResult

Score one answer against a case (case-insensitive substring containment).

list_providers

list_providers() -> list[str]

Sorted names of registered providers, e.g. ['anthropic', 'gemini', 'grok', 'local', 'openai', 'openrouter'].

as_item

as_item(x: str | GroundingItem) -> GroundingItem

Coerce a string or GroundingItem to a GroundingItem (strings become source="custom").

clean_gloss

clean_gloss(text: str, *, limit: int = 60) -> str

Reduce a raw dictionary line to its bare English meaning, or "".

Concise dictionaries (Middle Liddell, Cunliffe, Abbott-Smith, Dodson) are the right source for grounding a translator, but their lines carry apparatus a model should not see asserted as the meaning: a leading headword: repeat, the lemma's Greek etymology run, = X cross-reference redirects, and editorial-abbreviation leads. This strips those and returns the first English clause, length-capped. A trailing parenthetical that opened an etymology/cross-reference note (…, reckoning (cf. λέγω…)) is dropped whole rather than left dangling as …, reckoning (cf when the Greek inside it is cut. Returns "" when nothing definition-like survives (a bare redirect, a Greek-only line, or an etymology note), so the caller can fall through to the next dictionary rather than inject a non-gloss.

concise_gloss

concise_gloss(lemma: str) -> str

A cleaned, concise, common-sense-first gloss for lemma, or "".

Cascades over the loaded concise dictionaries (Middle Liddell, Cunliffe for Homer, Abbott-Smith / Dodson for the NT) via greek.gloss(lemma, dictionary=...), cleans each candidate with clean_gloss, and returns the first that survives. Requires at least one of those concise dictionaries to be loaded: this gloss is never taken from LSJ. LSJ orders senses etymologically, so its lead sense is frequently the archaic one (καιρός = "row of thrums in a loom", βίος = "bow", λόγος = "computation"), and asserting that as the meaning injects exactly the errors this layer exists to avoid; emitting nothing is strictly better than emitting the archaic trap. So with only use_lsj() loaded and no concise dictionary, this returns "" and the caller omits the gloss rather than grounding on a misleading sense. Only whichever concise dictionaries are actually loaded are consulted; a dictionary that is registered but not active is skipped, never raised on. Returns "" when no loaded concise source yields a clean gloss.

content_glosses

content_glosses(text: str, *, max_senses: int = 6, limit: int = 20, skip_lemmas: frozenset[str] | None = None, source: str = 'lsj', rarity_gate: bool = False) -> list[GroundingItem]

Gated dictionary glosses for the content words of text — grounding that helps a model without misleading it.

Two gloss sources:

  • source="lsj" (default, legacy): for each content word (not a function word, deduped by lemma) that has an LSJ entry with at most max_senses senses, emit one concise dominant-sense gloss. The polysemy cap is deliberate: a first-sense gloss for a highly polysemous word (στάσις, κρίσις, ἄρουρα) is often the wrong contextual sense, so those are left to the model's own reading; obscure, dominant-sense vocabulary is where a gloss adds real signal. Requires the LSJ lexicon (greek.use_lsj()); empty without it. Source tag lexicon:LSJ.
  • source="cascade" (recommended): gloss each content lemma from a concise, common-sense-first dictionary cascade (Middle Liddell, Cunliffe for Homer, Abbott-Smith / Dodson for the NT), cleaned (see concise_gloss). This is the validated source: LSJ orders senses etymologically, so its first sense is often the archaic one and asserting it injects errors, whereas a concise dictionary leads with the common sense. It therefore requires a concise dictionary and never falls back to the LSJ first sense: with only greek.use_lsj() loaded and no concise dictionary, the cascade emits nothing for that lemma rather than the archaic LSJ-lead trap. Uses whichever concise dictionaries are loaded and never requires a specific one. Source tag lexicon:concise.

rarity_gate (cascade source) restricts glossing to the text's rare content lemmas, measured against the Greek NT via greek.terminology_rarity: a gloss helps most on the rare words and is noise on common ones (πολύς, λόγος). An all-common passage is therefore glossed not at all, not glossed wholesale. It degrades to glossing every content lemma only when no reference corpus is available offline (the rarity signal is absent), never raising.

skip_lemmas is an optional set of lemmas to not gloss — pass a high-frequency lemma list to focus grounding on genuinely rare words. The package bundles no such list (frequency is corpus- and register-dependent); supply one, use rarity_gate=True, or omit it.

Best-effort throughout. Gloss coverage on rare or inflected forms depends on the active lemmatizer: the joint neural pipeline (greek.use_neural_pipeline()) gives sentence-contextual lemmas and POS-based function-word filtering; the neural lemmatizer generates lemmas for unseen forms; the AGDT treebank folds inflections attested in the literary corpus; the baseline seed table misses most. ref = the surface word.

cooccurrence_evidence

cooccurrence_evidence(corpus: object, word: str, *, limit: int = 12) -> list[GroundingItem]

Grounding for an undeciphered-script query: the words that most often share a document with word. Source analysis:cooccurrence, ref=word. Empty if word co-occurs with nothing.

corpus_context

corpus_context(corpus: object, *, limit: int = 20) -> list[GroundingItem]

A small grounding context from a corpus: its most frequent words.

Kept deliberately small — this is seed grounding, not retrieval. Accepts any object exposing word_frequencies() (e.g. aegean.Corpus); the source is tagged corpus:<script_id> so the trace names the corpus.

evidence_block

evidence_block(evidence: Iterable[str | GroundingItem]) -> str

Render grounding evidence as a compact, labeled bullet list (or empty).

Only the content reaches the prompt — provenance is for the trace, not the model — so the wording stays stable across GroundingItem and plain strings.

lexicon_evidence

lexicon_evidence(words: Iterable[str], *, limit: int = 20) -> list[GroundingItem]

Grounding from the active LSJ lexicon: a short gloss per word that has an entry. Returns nothing if the lexicon isn't loaded (greek.use_lsj()) — grounding is best-effort, never a hard dependency. Source lexicon:LSJ.

wrap_untrusted

wrap_untrusted(text: str, label: str = 'SOURCE') -> str

Delimit untrusted source text with an explicit do-not-follow note.

idiom_glosses

idiom_glosses(text: str) -> list[GroundingItem]

Detect curated Greek idioms in text and gloss their real (non-literal) meaning.

For each idiom from the bundled lexicon that is present in text, returns one GroundingItem whose content is "<surface>: <gloss>" (e.g. "ἐφ' ἡμῖν: in our power, up to us"), source="lexicon:idiom", and ref the idiom's surface form. These ground a translator in the meaning of a non-compositional phrase, the one class of error per-token morphology cannot fix.

Detection is two-pronged and not a parser:

  • surface (primary): accent-insensitive match of the idiom's surface form, with elision/apostrophe normalized away, so fixed idioms are caught verbatim (including elided and gapped-correlative spellings);
  • contiguous lemma match (secondary): the idiom's content lemmas appearing as an adjacent run among the text's content lemmas (via greek.pipeline / greek.lemmatize), which catches inflected idioms (οἷός τε ἐστί for οἷός τε εἰμί) without firing on the same function words scattered across an unrelated sentence. Explicitly gapped ... correlatives are not routed through this path (the surface path catches them). Inflection coverage depends on the active lemmatizer; the path simply yields fewer matches without a rich backend loaded.

When idioms overlap, the longest match wins and its shorter sub-idioms are suppressed, on both the surface path (by token span) and the lemma path (by lemma-index span); identical glosses are de-duplicated. Best-effort and offline: returns [] (never raises) on empty input or a missing backend. The lexicon is a curated set of vetted non-compositional expressions, not an exhaustive idiom dictionary; a gloss is a meaning aid, not a syntactic claim.

grounding_regime

grounding_regime(text: str, *, corpus: object | None = None) -> RegimeSignal

Estimate whether lexical grounding helps text (EXPLORATORY, offline).

Combines three deterministic signals: rarity (corpus-relative via greek.terminology_rarity when corpus is given, else a length/charset heuristic), polysemy load (mean LSJ senses over the content words, when greek.use_lsj() is active; else 0), and register markedness (share of content words LSJ tags as dialectal/poetic/technical). High rarity favours grounding (help); high polysemy without rarity favours hurt (the dominant-gloss trap); little of either is neutral.

A best-effort heuristic: it never raises on missing backends, returning the signals it can compute. The label is exploratory — a guide for when to apply grounding, not a measured accuracy.

select_sense

select_sense(word: str, context: str, *, max_candidates: int = 3, overlap_weight: float = 1.0, markedness_weight: float = 0.25, prior_weight: float = 0.15) -> list[SenseCandidate]

Rank word's LSJ senses by fit to context; best first (EXPLORATORY).

Returns up to max_candidates SenseCandidates, the contextually-best sense first. Each sense scores by lexical overlap (overlap_weight) between the Greek it cites and the context's content lemmas, a markedness bonus (markedness_weight) when the sense carries the entry's dialect/register markers, and a mild sense-order/length prior (prior_weight) favouring the earlier, more central senses. Ties keep LSJ order, so with no overlap signal at all the dominant sense leads, exactly as content_glosses assumes.

Best-effort and offline: returns [] if the LSJ lexicon is not loaded (greek.use_lsj()) or the word has no entry. A ranked sense is a hypothesis from a lexical-overlap heuristic, not a word-sense disambiguation — label it unverified at point of use.