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, grounding_runtime: dict[str, Any] | None = None, draft: ExploratoryResult | None = 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 evidence grounded the output, and data (when set by a structured capability) for the parsed JSON payload. grounding_runtime retains the complete local execution provenance; a compact backend/coverage summary may also appear among the prompt evidence. draft retains the complete original result when a translation receives an optional model checking pass. Translation audit data separates the final wording, model-reported uncertainty and changes, and the exact raw response; a structured format status validates its shape, not the translation.

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 evidence 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 = '', status: str = 'unclassified', provenance: dict[str, Any] | None = None)

One piece of grounding evidence and its provenance.

content is the evidence text; 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). status distinguishes predictions, source annotations, and field-level human review; provenance carries supporting details. Both reach the model through evidence_block. Plain strings remain unclassified evidence, never implicitly verified. Stringification retains the historical content-only behavior.

render

render() -> str

Evidence plus provenance, for both the prompt and the audit trace.

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', style: str = 'annotated', grounding: Grounding = (), source_context: str | None = None, max_tokens: int = 2048, client: LLMClient | None = None) -> ExploratoryResult

Translate into a separately retained translation and model-reported notes.

style selects concise or annotated presentation. Both retain uncertainty, change information and the exact provider response in result.data. Invalid structured responses remain raw text with an explicit format status; no extra provider call attempts to repair their format. max_tokens is the positive integer output-token limit (default 2048); it does not trigger retries.

source_context is separately fenced interpretive background for referents, speakers and meaning. Only text is translated. Every supplied string, including blank/whitespace strings, is retained exactly in result.data['source_context']; None supplies no context and retains the existing context-free prompt and data shape. Context is untrusted data, not directions or a license to invent unprovided source history.

verify_translation

verify_translation(text: str, draft: str | ExploratoryResult, *, source: str = 'Ancient Greek', target: str = 'English', style: str = 'annotated', grounding: Grounding = (), source_context: str | None = None, max_tokens: int = 2048, check_policy: str = 'standard', client: LLMClient | None = None) -> ExploratoryResult

Check a draft translation against provisional grounding and repair only definite contradictions, retaining the draft, notes and reported changes.

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 and structured translation audit data. An ExploratoryResult draft retains its complete original provenance in result.draft; a string draft is retained exactly in result.data['draft_text'] without inventing provenance. text_changed compares exact wording only when both responses are structured; change_report_status flags changed wording omitted from the model's report. max_tokens is the positive integer output-token limit (default 2048), independently supplied for this checking call; no format-repair call is added.

source_context=None inherits a saved result's recorded source context, including an exact blank string. An explicitly supplied non-None context must equal any recorded context exactly; a mismatch raises before the provider call, and None cannot clear inherited context. Saved results without a context key can receive explicit context, without asserting what their original prompt used. String drafts use only the explicit argument. Supplied context and draft text are retained exactly; context guides interpretation but is never translated or followed as instructions.

check_policy='clauses' additionally requests a model-reported audit of source propositions, roles, negation, qualifications and speech acts. It uses the same single completion, preserving exact source wording and occurrence references. Local validation checks literal spans and supplied references, not meaning or scholarly correctness. Missing/invalid audit data remains explicit without discarding an otherwise structured translation. The default 'standard' retains the existing checking policy and response shape.

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'].

annotation_evidence

annotation_evidence(tokens: Iterable[Token], *, source: str = 'annotation', source_annotated: bool = False, fields: Sequence[str] = ('lemma', 'upos', 'morph', 'feats', 'head', 'deprel')) -> list[GroundingItem]

Render existing token annotations without rerunning or approving analysis.

Set source_annotated=True when the tokens retain annotations from the named source edition. Missing provenance otherwise remains unclassified: absence of model metadata does not establish annotation origin. Only a current per-field accept/correct decision with its verification flag grants human-reviewed status. Deferred, rejected and stale decisions remain visible. Reviewing a lemma never certifies adjacent fields. Pass the returned items as grounding= to an AI or hybrid translation call.

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, analysis: Sequence[TokenRecord] | None = None) -> 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). These are dictionary candidates, not verified contextual senses. A historical LSJ lead sense can differ from the sense appropriate to this passage. 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.

analysis may supply an already-computed GreekPipeline result. When present, those contextual lemmas and parts of speech drive both filtering and rarity gating; no module-level analysis backend is consulted. This lets an isolated pipeline remain isolated through translation grounding.

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 is a Corpus, the results of a Corpus.query (whose matched documents are its inscriptions), or a list of documents, so grounding can be drawn from a queried subset and reflects the co-occurrence in that subset. Anything that is not a body of documents raises a TypeError naming what arrived: an empty grounding list reads as "this word co-occurs with nothing", which a wrong argument must never say.

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

Status, source, locator and supplied provenance travel with each evidence line. They describe evidence, not instructions; capabilities wrap this block as untrusted data before sending it to a provider.

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, *, analysis: Sequence[TokenRecord] | None = None) -> 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.

analysis may supply already-computed TokenRecords. Their lemmas drive the secondary path without consulting or rerunning a module-level Greek backend; hybrid translation uses this to preserve explicit GreekPipeline isolation.

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, but the score is a sum, so with no overlap signal the leading sense is not necessarily the dominant one: a later sense carrying the entry's markers, or a short one favoured by the length prior, can outrank the entry's first sense. Read dominant rather than the position.

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.