diff --git a/.env.example b/.env.example index f81b458..66ab660 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ # =========================================== # OntoCast Environment Configuration -# Copy this file to .env and update with your values. OntoCast reads only the -# environment, so load the file into your shell before running it: -# set -a; source .env; set +a +# Copy the lines you need to your own file and pass it to the CLI: +# ontocast --env-file my.env serve +# --env-file is repeatable (later files win; shell exports win over all), so +# credentials and project settings can live in separate files. Keep only the +# lines that differ from the defaults: `ontocast config check my.env` names +# the redundant, unknown and invalid ones. # # This is the COMPLETE surface. If you are setting OntoCast up for the first # time, start from `.env.example.minimal` (the variables worth a decision, @@ -89,8 +92,8 @@ ONTOLOGY_CONTEXT_SCOPE=unit # unit | document # A prefix cache is populated by a request that has already *completed*, so a # fan-out issuing N calls that share a prefix simultaneously has all N miss it; # running one unit first turns the other N-1 into hits, at the wall-clock cost -# of one serialized call. Only worth setting together with -# ONTOLOGY_CONTEXT_SCOPE=document, where the calls actually share a prefix. +# of one serialized call. Only worth setting together with document scope +# (ONTOLOGY_CONTEXT_SCOPE above), where the calls actually share a prefix. FANOUT_WARMUP_UNITS=0 # The catalog ontology for ONTOLOGY_CONTEXT_MODE=fixed_single_ontology, in # `ontocast process` and for server requests that name none; it does not change @@ -127,7 +130,9 @@ ENABLE_ONTOLOGY_CONSOLIDATION=false # LLM Configuration # Available providers: openai, ollama, anthropic, google LLM_PROVIDER=openai -LLM_MODEL_NAME=gpt-5.4 +LLM_MODEL_NAME=gpt-5.6-luna +# Sent only where the model accepts it: OpenAI GPT-5.5+ takes it at +# reasoning effort none only, and Claude Opus 4.7+ / Sonnet 5+ / Fable never. LLM_TEMPERATURE=0.0 LLM_API_KEY=your_openai_api_key_here # LLM_CACHE_ENABLED=true @@ -137,19 +142,19 @@ LLM_API_KEY=your_openai_api_key_here # Ollama Configuration (Alternative) # LLM_PROVIDER=ollama -# LLM_MODEL_NAME=llama3.1 +# LLM_MODEL_NAME=qwen3.8 # LLM_BASE_URL=http://localhost:11434 # LLM_TEMPERATURE=0.0 # Anthropic Claude (Alternative) # LLM_PROVIDER=anthropic -# LLM_MODEL_NAME=claude-sonnet-4-20250514 +# LLM_MODEL_NAME=claude-sonnet-5-5 # LLM_API_KEY=your_anthropic_api_key_here # LLM_TEMPERATURE=0.0 # Google Gemini (Alternative) # LLM_PROVIDER=google -# LLM_MODEL_NAME=gemini-2.0-flash +# LLM_MODEL_NAME=gemini-3.7-flash # LLM_API_KEY=your_google_api_key_here # LLM_TEMPERATURE=0.0 @@ -734,7 +739,7 @@ LOGGING_LEVEL=info # and ignored. Both join the LLM cache key, so changing one re-issues every # prompt. Read budget.reasoning_share_of_output first: at 0.0 the model is not # a reasoning model and neither knob changes its bill. -# Discrete reasoning depth: none | minimal | low | medium | high | xhigh +# Discrete reasoning depth: none | minimal | low | medium | high | xhigh | max # (unset = provider default). Read by OpenAI reasoning models as # reasoning_effort and by Gemini 3+ as thinking_level. The vocabulary is the # union across providers and model generations -- the floor is spelled minimal diff --git a/.env.example.minimal b/.env.example.minimal index 0740a51..1bb6e20 100644 --- a/.env.example.minimal +++ b/.env.example.minimal @@ -8,9 +8,8 @@ # Convention, as in `.env.example`: an uncommented line is one you should look # at; `#VAR=value` is optional and the value shown is the default. # -# Copy this to .env and set LLM_API_KEY. OntoCast reads only the environment, -# so load the file into your shell before running it: -# set -a; source .env; set +a +# Copy this to your own file, set LLM_API_KEY, and pass it to the CLI: +# ontocast --env-file my.env serve # Then pick a recipe: https://growgraph.github.io/ontocast/guides/recipes/ # =========================================== @@ -32,9 +31,12 @@ ONTOCAST_ONTOLOGY_DIRECTORY=/path/to/ontology/files # -- 2. LLM --------------------------------------------------------------- LLM_PROVIDER=openai # openai | ollama | anthropic | google -LLM_MODEL_NAME=gpt-4o-mini +LLM_MODEL_NAME=gpt-5.6-luna LLM_API_KEY=your-api-key-here LLM_TEMPERATURE=0.0 # leave at 0.0; extraction is not a creative task +# Reasoning models may not accept it: OpenAI GPT-5.5+ uses it only with +# reasoning effort none, and newer Claude models never; it is then not sent. +#LLM_REASONING_EFFORT= # none | minimal | low | medium | high | xhigh | max # Concurrent requests to the provider, shared across all content units. Lower # it when you hit rate limits -- this is the knob for that, not PARALLEL_WORKERS. @@ -115,9 +117,10 @@ CHUNK_MAX_SIZE=12000 # citations_only extracts bibliographic metadata instead. #CHUNK_BIBLIOGRAPHY_MODE=skip # skip | citations_only | domain_facts -# For publisher PDFs with selectable text: born_digital switches the backend and -# repairs ligature gaps ("di ff usion"). Try it before reaching for OCR settings. -#CONVERTER_PROFILE=default # default | born_digital +# auto checks each PDF for a text layer and skips OCR when it has one. Pin +# fast (never OCR) or ocr (always) only when every input is of one kind; lean +# is fast plus equations decoded to LaTeX, a model call per equation. +#CONVERTER_PROFILE=auto # auto | fast | lean | ocr # -- 6. Local embedding models (memory) ---------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index b815c27..5202c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,113 +5,91 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`ontocast --env-file FILE`** (repeatable): load settings files in order; later files and + shell exports override earlier ones. +- **`ontocast config check FILE...`**: classify variables as unknown, invalid, redundant, or + pinned; exit 1 on unknown or invalid. +- **Model presets** for GPT-5.6, GPT-6, Gemini 3.x, and current Ollama catalog models. +- **`LLM_REASONING_EFFORT=max`**. + +### Changed + +- **Default model** is `gpt-5.6-luna` (was `gpt-5.4`); cached LLM entries for the old default + are not reused. +- **Claude presets** use provider API IDs; legacy `claude-4.x-*-latest` aliases removed. +- **Gemini 2.x and 1.5 presets** removed; other Gemini names still pass through with a warning. + +### Fixed + +- **`temperature`** omitted for models that reject it (OpenAI GPT-5.5+ unless + `LLM_REASONING_EFFORT=none`; Claude Opus 4.7+, Sonnet 5+, Fable/Mythos), including GPT-6. +- **`gpt-4.1` / `gpt-4.1-mini`** preset IDs (was `gpt-41` / `gpt-41-mini`). +- **`WEB_SEARCH_ALLOWED_DOMAINS` / `WEB_SEARCH_BLOCKED_DOMAINS`** accept comma-separated lists + and empty values without failing startup. +- **Removed `CONVERTER_PROFILE` values** `default` and `born_digital` dropped from shipped env + examples. + ## [0.6.5] ### Added -- **Contributor License Agreement.** Outside contributors accept `CLA.md` once, by commenting on a - pull request; the `cla` status check tracks acceptances (write access and bots are exempt). -- **`plot-graph --format`** selects any of `svg`, `png`, `pdf` (default `svg,png`). -- **`ontocast.config.env_names`:** environment-variable names of every settings field. -- **More input formats:** Word, Excel, HTML, Markdown, CSV, AsciiDoc and images - convert through Docling alongside PDF and PowerPoint; - `CONVERTER_SUPPORTED_EXTENSIONS` narrows the set, and `ontocast process` warns - about each file it skips by suffix. -- **`GET /ontologies`** lists the current version of each catalog ontology in the - request's tenant and project. -- **Startup warnings:** `serve` and `process` name any dataset, collection or table - setting replaced by the tenant/project-derived name; a raised - `ONTOLOGY_CONTEXT_MAX_TRIPLES` that the induced-subgraph cap overrides is reported. -- **`llm/ontology_selection`** in `budget.counters` counts the per-unit ontology - selection calls of `selected_single_ontology` mode. -- **`ToolBox.ensure_vector_store`** prepares the vector store for a mode on demand. +- **Contributor License Agreement** for outside contributors (one-time PR acceptance; `cla` check). +- **`plot-graph --format`:** `svg`, `png`, `pdf` (default `svg,png`). +- **`ontocast.config.env_names`:** env var for every settings field. +- **Docling inputs:** Word, Excel, HTML, Markdown, CSV, AsciiDoc, and images; optional + `CONVERTER_SUPPORTED_EXTENSIONS`; skipped files warned on `process`. +- **`GET /ontologies`:** current catalog ontology versions for the request tenant/project. +- **Startup warnings** for tenant/project storage overrides and for + `ONTOLOGY_CONTEXT_MAX_TRIPLES` capped by induced-subgraph limits. +- **`llm/ontology_selection`** budget counter in `selected_single_ontology` mode. +- **`ToolBox.ensure_vector_store`** prepares a vector store on demand. ### Changed -- **PDF conversion picks its settings per document.** `CONVERTER_PROFILE` defaults to - `auto`: a PDF with a text layer converts with OCR off and the fast table model - (`fast`); one whose pages are images only keeps OCR on (`ocr`, Docling's defaults). - `lean` adds equations decoded to LaTeX. New settings `CONVERTER_TABLE_MODE` and - `CONVERTER_DO_FORMULA_ENRICHMENT`; a profile sets only the fields not set - explicitly. Converter cache entries are rebuilt. -- **`plot` extra** requires `pygraphviz>=2.0,<3`, whose wheels bundle Graphviz. -- **`docs` extra:** `properdocs` replaces `mkdocs`; `mkdocs-jupyter` and the - `griffe<2` pin are dropped. -- **`plot-graph`** no longer writes `graph.preview.png` (a remote mermaid.ink call). -- **`strip_provenance`:** an unrecognised value returns `400` (it was read as `false`). -- **`FUSEKI_URI` alone selects Fuseki;** `FUSEKI_AUTH` is optional. Previously the - URI without auth fell back to the in-memory store. A `401`/`403` while creating a - dataset now stops startup with `FusekiAccessError` (it was logged and ignored). -- **`ToolBox.initialize()`** without `ontology_context_mode` prepares what - `ONTOLOGY_CONTEXT_MODE` asks for, including the vector index. -- **`Config.in_memory()`** leaves the vector backend on `auto`, so enabling LanceDB - on the result takes effect. -- **Graph evaluation** (`POST /match/evaluate`, `match-graphs`): a precision, - recall or F1 with a zero denominator is `null` (was `0.0`), and entity metrics - count subjects and objects that are not vocabulary (predicates were counted). -- **Section labels from content density:** acknowledgements need two gratitude or - funding cues, and an excluded density label no longer spreads to the following - unlabelled segments. Fewer methods chunks are dropped as acknowledgements. -- **LLM cache key** includes `json_mode` when `LLM_JSON_MODE` is on; entries - written with it off keep their keys. -- **Fixed ontology mode over HTTP:** a request that names no - `ontology_context_fixed_ontology_id` uses `ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID` - (every such request returned `400`). -- **`plot-graph`:** the left-to-right pipeline figure uses larger labels and - tighter spacing, so it stays legible at page width. +- **`CONVERTER_PROFILE`** defaults to `auto` (text PDF → fast OCR off; scan → OCR on); profiles + `lean`, `fast`, `ocr`; new `CONVERTER_TABLE_MODE` and `CONVERTER_DO_FORMULA_ENRICHMENT`. +- **`plot` extra** requires `pygraphviz>=2.0,<3` (bundled Graphviz). +- **`docs` extra:** ProperDocs replaces MkDocs. +- **`plot-graph`** no longer writes remote preview PNG; pipeline figure layout improved. +- **`strip_provenance`:** invalid values return `400`. +- **Fuseki:** `FUSEKI_URI` alone selects Fuseki (`FUSEKI_AUTH` optional); dataset access errors + fail startup. +- **`ToolBox.initialize()`** / **`Config.in_memory()`** honor configured ontology context and + vector backend (`auto` + LanceDB). +- **Match evaluate** (`POST /match/evaluate`, `match-graphs`): zero-denominator scores are + `null`; entity metrics exclude vocabulary terms from subject/object counts. +- **Section labelling:** stricter acknowledgements; excluded density labels do not spread. +- **LLM cache key** includes `json_mode` when `LLM_JSON_MODE` is on. +- **Fixed ontology over HTTP** uses `ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID` when the request omits + an ontology id. ### Removed -- **`CONVERTER_PROFILE=born_digital` and `=default`.** Both now fail validation with - the replacement: `fast` (or `auto`) and `ocr` (the previous default). -- **`suthing` dropped from the `server` extra.** Nothing in the package imports - it; installing `ontocast[server]` no longer pulls it in. +- **`CONVERTER_PROFILE`** values `born_digital` and `default` (use `auto`, `fast`, or `ocr`). +- **`suthing`** from the `server` extra. ### Fixed -- **Section-label matching** compiles each heading pattern and keyword once. The - catalog outgrows `re`'s cache, so schema detection recompiled it for every heading. -- **`GET /info`** `input_types` lists what `/process` accepts on this install (it - advertised `text`, `markdown` and `jsonl`, and PDF without the `doc-processing` - extra; JSONL is read only by `ontocast process`). -- **LangChain tools:** `ontocast_delete_ontology` no longer claims to delete the - seed file (it keeps it; the ontology reloads on the next start), and - `ontocast_convert_document` names the formats it converts. -- **Vector-search context error** names both ways to enable a vector store, - `QDRANT_URI` or `LANCEDB_ENABLED=true`. -- **CLI help:** `--section-schema-id` lists every schema (`patent`, `standard`, - `news` were missing); `--summarize-sections` says summaries are written per - content unit. -- **Ontology version bumps** see class and property additions and deletions; they - were never detected, so no update produced a major bump. -- **Section exclusion log** names each dropped label with its size and labelling tier. -- **Converter availability:** without the `doc-processing` extra no converted format - is accepted or advertised (PDF and PPTX were, then failed on import). -- **`plot-graph`** runs without LLM credentials or storage services. -- **`VECTOR_STORE_BACKEND=lancedb`** without `LANCEDB_ENABLED` gets the BM25 tool - (it failed on first search). -- **A vector-mode request on the startup scope** prepares the vector index instead - of returning `409` when the server started in another mode. -- **LangChain `ontocast_extract`** is offered only when `docling-core` is installed. -- **Per-tenant requests** reuse their scope's compiled graph; it was recompiled on - every request with `?tenant=` or `?project=`. -- **A blank `tenant` or `project`** returns `400` on every route; outside `/flush` - it returned `500`. -- **`POST /match/entities` and `match-graphs`** default `similarity_threshold` and - `embedding_model` to `AGG_SIMILARITY_THRESHOLD` / `AGG_EMBEDDING_MODEL` (they - hard-coded `0.80` and an unprefixed model name that loaded a second copy). +- **Section labels:** compile patterns once; exclusion log includes label, size, and tier. +- **`GET /info`** and **`/process`** accept lists match installed extras and formats. +- **LangChain tools:** corrected delete/convert descriptions; **`ontocast_extract`** only with + Docling installed. +- **Vector store:** clearer enablement errors; LanceDB backend gets BM25 without + `LANCEDB_ENABLED`; vector-mode requests prepare the index instead of `409`. +- **CLI help:** all section schemas listed; **`--summarize-sections`** documents per-unit output. +- **Ontology versioning** detects class/property additions and deletions for major bumps. +- **`plot-graph`** runs without LLM or storage credentials. +- **Tenancy:** blank `tenant`/`project` → `400`; scoped requests reuse the compiled graph. +- **Match APIs** default threshold and embedding model from aggregation settings. ### Documentation -- Docs are built with ProperDocs (`properdocs.yml`, `properdocs build` / `serve`) - and reorganized into Getting started, Concepts, Guides, Reference and Internals, - with a new theme and landing page. -- The configuration reference is generated from the settings models, one entry per - environment variable; setting descriptions rewritten to state behaviour only. -- Pipeline and per-unit loop diagrams redrawn in the new palette and served as SVG; - the loop diagrams state the real convergence rule and the facts loop's exits. -- Docs no longer claim OntoCast reads a `.env` file (it reads the environment). -- `demo/README.md` sample paths fixed; public callables fully annotated. +- **ProperDocs** site with reorganized nav; configuration reference generated from settings. +- **Pipeline diagrams** updated (SVG); docs describe environment-based config, not `.env` auto-load. ## [0.6.4] - 2026-09-16 diff --git a/README.md b/README.md index c90ed66..1d27cca 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ without a server: ontocast process --input-path ./papers --output-dir ./out ``` -To keep settings in a file, copy [`.env.example.minimal`](.env.example.minimal) -to `.env` and load it into your shell with `set -a; source .env; set +a`: -OntoCast does not read the file itself. Step by step: +To keep settings in a file, copy [`.env.example.minimal`](.env.example.minimal), +edit it, and pass it with `ontocast --env-file my.env serve` (repeatable; later +files win). Step by step: [Quick start](https://growgraph.github.io/ontocast/getting_started/quickstart/). ## Your own ontologies diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 7961f1b..513bd13 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -34,14 +34,16 @@ To use another provider, install its extra and set three variables: !!! tip "Keeping settings in a file" The repository ships `.env.example.minimal`, the settings worth a decision, - and `.env.example`, all of them. Copy one to `.env` and edit it. OntoCast - does not read the file itself, so load it into the environment of the + and `.env.example`, all of them. Copy one, edit it, and pass it to the command you run: ```bash - set -a; source .env; set +a + ontocast --env-file my.env serve ``` + The flag is repeatable, so keys and project settings can live in separate + files; see [Configuring OntoCast](../guides/configuration.md#layering-settings-files). + ## 3. Start the server ```bash diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 000936b..7a9e551 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -17,16 +17,52 @@ Every setting is an environment variable, such as `LLM_PROVIDER` or `RENDER_MODE`; case does not matter. Booleans are `true` or `false`; lists and mappings are written as JSON. -OntoCast does not read a settings file itself. To keep your settings in one, -start from `.env.example.minimal` (the settings worth a decision) or -`.env.example` (all of them) in the repository, and load it into the -environment of the command you run: +To keep your settings in a file, start from `.env.example.minimal` (the +settings worth a decision) or `.env.example` (all of them) in the repository, +and pass it to the CLI: ```bash -set -a; source .env; set +a -ontocast serve +ontocast --env-file my.env serve ``` +### Layering settings files + +`--env-file` is repeatable. Files apply in order, a later file overriding an +earlier one, and a variable exported in the shell overrides them all. That +lets credentials and project settings live apart, and keeps a one-off change +on the command line: + +```bash +LLM_MODEL_NAME=gpt-5.6-terra ontocast \ + --env-file ~/.config/ontocast/openai.env \ + --env-file project.env \ + process --input-path ./papers --output-dir ./out +``` + +Keep in a file only the settings you mean to differ from the defaults. A copied +default stays in force after a release improves it, and a variable whose +setting was renamed or removed is ignored without effect. `ontocast config +check` reports both, naming variables but never printing values: + +```bash +ontocast config check project.env +``` + +| Verdict | Meaning | +|---|---| +| `unknown` | No setting reads it: renamed, removed, or not an OntoCast variable | +| `invalid` | The setting rejects the value, alone or with the file's other values | +| `redundant` | Equals the current default; delete the line | +| `pinned` | Overrides the current default; review it when you upgrade | + +The command exits `1` when a file has an unknown or invalid variable. Each file +is judged on its own against the defaults, not against the environment. +`--env-file` also warns at startup about variables no setting reads, and still +exports them, since libraries read some of them (`HF_TOKEN`). + +Embedded in Python, `Config()` reads the process environment only; load a file +there with your own tooling. + The server reads its settings once, at startup. A few can also be set per request on `/process`: `render_mode`, `ontology_context_mode`, `ontology_context_fixed_ontology_id`, `llm_graph_format` and `max_visits`. A @@ -48,13 +84,17 @@ see [Embedding OntoCast in your agent](embedding.md). | [`LLM_BASE_URL`](../reference/configuration/llm.md#llm_base_url) | The Ollama server, or the endpoint of any OpenAI-compatible service (with `LLM_PROVIDER=openai`) | Leave [`LLM_TEMPERATURE`](../reference/configuration/llm.md#llm_temperature) at -`0`: extraction should be repeatable. Responses are cached on disk, so a +`0`: extraction should be repeatable. Reasoning models may refuse it, and then +it is not sent: OpenAI GPT-5.5 and later accept it only with +`LLM_REASONING_EFFORT=none`, and Claude Opus 4.7, Sonnet 5 and later never do. +The default model, `gpt-5.6-luna`, therefore samples at its provider default +unless reasoning is off. Responses are cached on disk, so a document you run twice with the same prompts costs nothing the second time; see [LLM caching](llm_caching.md). For reasoning models, [`LLM_REASONING_EFFORT`](../reference/configuration/llm.md#llm_reasoning_effort) -sets how much the model thinks before answering (OpenAI, Gemini 3 and later); +sets how much the model thinks before answering, from `none` to `max` (OpenAI, Gemini 3 and later); reasoning tokens are billed as output. For local models through Ollama, raise [`LLM_NUM_CTX`](../reference/configuration/llm.md#llm_num_ctx): Ollama's default context window is too small for most OntoCast prompts. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 3eed22e..2682519 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -142,12 +142,12 @@ tuning](performance.md) explains the concurrency layers and what to measure. ## Settings seem ignored -- **The settings file is not read.** OntoCast reads only the process environment, never a `.env` file. Load the file into the environment of the command you run: `set -a; source .env; set +a`. +- **The settings file is not read.** OntoCast reads a file only when it is passed with `ontocast --env-file FILE`; otherwise it reads the process environment alone. A variable exported in the shell wins over the file. - **The server reads settings once.** Restart `ontocast serve` after a change. - **A request overrides it.** `render_mode`, `ontology_context_mode`, `ontology_context_fixed_ontology_id`, `llm_graph_format` and `max_visits` sent with a request win over the environment. - **Dataset names come from the tenant and project.** `ontocast serve` and `ontocast process` name the Fuseki datasets and vector collections after the tenant and project (`--tenant`, `--project`), whatever `FUSEKI_DATASET` or a table setting says, and log a warning naming the ignored setting. See [Tenancy](tenancy.md). - **Fixed mode needs the mode setting.** For `ontocast process`, set `ONTOLOGY_CONTEXT_MODE=fixed_single_ontology` as well as `ONTOLOGY_CONTEXT_FIXED_ONTOLOGY_ID`. -- **The name is wrong.** An unknown variable is ignored without a warning. Check the spelling against the [configuration reference](../reference/configuration/index.md); the run manifest shows the values the run used. +- **The name is wrong.** An unknown variable in the environment is ignored without a warning. `ontocast config check FILE` names the unknown variables in a settings file, and `--env-file` warns about them at startup; the run manifest shows the values the run used. ## Fuseki refuses the connection diff --git a/ontocast/cli/config.py b/ontocast/cli/config.py new file mode 100644 index 0000000..57f1a20 --- /dev/null +++ b/ontocast/cli/config.py @@ -0,0 +1,59 @@ +"""``ontocast config`` commands for auditing configuration files.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from ontocast.cli.env_files import read_env_file +from ontocast.config.env_audit import Verdict, audit_assignments + + +@click.group("config") +def config() -> None: + """Audit OntoCast configuration files.""" + + +@config.command("check") +@click.argument( + "files", + nargs=-1, + required=True, + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +@click.option( + "--quiet", + "-q", + is_flag=True, + help="Report only unknown and invalid variables.", +) +def check(files: tuple[Path, ...], quiet: bool) -> None: + """Classify each variable in FILES against the current defaults. + + \b + unknown no setting reads it (renamed, removed, or not OntoCast's) + invalid the setting rejects the value + redundant equals the current default; the line can be deleted + pinned overrides the current default + + Each file is judged on its own, against the code defaults, never the + ambient environment. Only variable names are printed. Exits 1 when any + file has an unknown or invalid variable. + """ + failed = False + for path in files: + report = audit_assignments(read_env_file(path)) + failed = failed or not report.ok + click.echo(str(path)) + for finding in report.findings: + if quiet and finding.verdict in (Verdict.REDUNDANT, Verdict.PINNED): + continue + line = f" {finding.verdict.value:<10} {finding.name}" + if finding.detail: + line += f": {finding.detail}" + click.echo(line) + for error in report.config_errors: + click.echo(f" {'config':<10} {error}") + if failed: + raise SystemExit(1) diff --git a/ontocast/cli/env_files.py b/ontocast/cli/env_files.py new file mode 100644 index 0000000..d2e7e1a --- /dev/null +++ b/ontocast/cli/env_files.py @@ -0,0 +1,55 @@ +"""Load layered dotenv files into the environment for the ``ontocast`` CLI. + +Files apply in the order given and a later file overrides an earlier one, so a +stack such as provider credentials, then project settings, reads as one +configuration. A variable already exported in the shell is never overridden, +which keeps a one-off ``VAR=value ontocast ...`` effective on top of the stack. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Sequence +from pathlib import Path + +from dotenv import dotenv_values + +from ontocast.config.env_audit import declared_env_names + +logger = logging.getLogger(__name__) + + +def read_env_file(path: Path) -> dict[str, str | None]: + """Parse one dotenv file; ``None`` marks a line with no ``=``.""" + return dict(dotenv_values(path)) + + +def merge_env_files(paths: Sequence[Path]) -> dict[str, str | None]: + """Merge dotenv files in order, a later file winning on a shared name.""" + merged: dict[str, str | None] = {} + for path in paths: + merged.update(read_env_file(path)) + return merged + + +def apply_env_files(paths: Sequence[Path]) -> None: + """Export the merged files into ``os.environ`` beneath the shell's own values. + + Variables no setting reads are still exported -- libraries read some of + them, such as ``HF_TOKEN`` -- but are named in a warning, since a renamed or + removed OntoCast setting looks exactly like one. Values are never logged. + """ + merged = merge_env_files(paths) + declared = declared_env_names() + unknown = sorted(name for name in merged if name.upper() not in declared) + if unknown: + logger.warning( + "--env-file sets variables no OntoCast setting reads (exported anyway; " + "a renamed or removed setting if you meant one): %s", + ", ".join(unknown), + ) + for name, value in merged.items(): + if value is None or name in os.environ: + continue + os.environ[name] = value diff --git a/ontocast/cli/server.py b/ontocast/cli/server.py index 45f5456..02a8aad 100644 --- a/ontocast/cli/server.py +++ b/ontocast/cli/server.py @@ -40,6 +40,8 @@ stores_use_tenancy_partitions, ) from ontocast.cli.cache import cache as cache_cli +from ontocast.cli.config import config as config_cli +from ontocast.cli.env_files import apply_env_files from ontocast.cli.inspect_sections import main as inspect_sections_cli from ontocast.config import Config from ontocast.config.env_names import env_names @@ -430,11 +432,24 @@ def _shared_runtime_options(fn: F) -> F: @click.group() -def cli() -> None: +@click.option( + "--env-file", + "env_files", + multiple=True, + type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path), + help=( + "Dotenv file to load before running; repeatable. Later files override " + "earlier ones, and variables exported in the shell override them all." + ), +) +def cli(env_files: tuple[pathlib.Path, ...]) -> None: """OntoCast: start the API server or process local files in batch mode.""" + if env_files: + apply_env_files(env_files) cli.add_command(cache_cli) +cli.add_command(config_cli) cli.add_command(inspect_sections_cli) diff --git a/ontocast/config/env_audit.py b/ontocast/config/env_audit.py new file mode 100644 index 0000000..adbf59f --- /dev/null +++ b/ontocast/config/env_audit.py @@ -0,0 +1,193 @@ +"""Classify environment assignments against the declared settings. + +Pydantic-settings ignores an environment variable that no field reads, so a +renamed or removed setting in a configuration file silently does nothing, and a +value copied from an earlier default keeps overriding the current one. This +module tells the cases apart. Findings name variables, never their values. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Collection, Iterator, Mapping +from contextlib import contextmanager +from enum import StrEnum + +from pydantic import BaseModel, ValidationError +from pydantic_settings import BaseSettings + +from ontocast.config.env_names import SettingsField, iter_settings_fields +from ontocast.config.settings import Config + + +class Verdict(StrEnum): + """What one assignment does relative to the current defaults.""" + + UNKNOWN = "unknown" + """No setting reads the variable: it is renamed, removed, or not OntoCast's.""" + INVALID = "invalid" + """The setting rejects the value, alone or combined with the file's others.""" + REDUNDANT = "redundant" + """The value equals the current default; deleting the line changes nothing.""" + PINNED = "pinned" + """The value differs from the current default, which it overrides.""" + + +class Finding(BaseModel): + """The verdict on one assignment.""" + + name: str + verdict: Verdict + detail: str = "" + + +class AuditReport(BaseModel): + """Per-variable findings plus errors only the full configuration raises.""" + + findings: list[Finding] + config_errors: list[str] + + @property + def ok(self) -> bool: + """True when nothing is unknown or invalid and the whole config builds.""" + return not self.config_errors and all( + f.verdict in (Verdict.REDUNDANT, Verdict.PINNED) for f in self.findings + ) + + +def declared_env_names() -> dict[str, SettingsField]: + """Map every environment name a setting reads to the field reading it.""" + declared: dict[str, SettingsField] = {} + for field in iter_settings_fields(Config): + for name in field.env_names: + declared.setdefault(name, field) + return declared + + +@contextmanager +def _environment( + assignments: Mapping[str, str], declared: Collection[str] +) -> Iterator[None]: + """Run with only ``assignments`` among the declared names; restore after. + + Settings read names case-insensitively, so any spelling of a declared name + is removed. Log output is suppressed: settings validators warn about + combinations, and probing builds many of them. + """ + saved = dict(os.environ) + previous_disable = logging.root.manager.disable + logging.disable(logging.CRITICAL) + try: + for key in list(os.environ): + if key.upper() in declared: + del os.environ[key] + os.environ.update(assignments) + yield + finally: + os.environ.clear() + os.environ.update(saved) + logging.disable(previous_disable) + + +def _messages(exc: Exception) -> str: + """Error text without the rejected input, so secrets are never echoed.""" + if isinstance(exc, ValidationError): + return "; ".join(e["msg"] for e in exc.errors(include_input=False)) + return str(exc) + + +def _build( + group: type[BaseSettings], + assignments: Mapping[str, str], + declared: Collection[str], +) -> tuple[BaseSettings | None, str]: + with _environment(assignments, declared): + try: + return group(), "" + except (ValidationError, ValueError) as exc: + return None, _messages(exc) + + +def audit_assignments(assignments: Mapping[str, str | None]) -> AuditReport: + """Classify each assignment as unknown, invalid, redundant or pinned. + + Each settings group is built from the file's assignments to it, so values + are judged as the running program would parse them. A group that rejects + the combination is retried one variable at a time to say which value is at + fault; values that are each valid but jointly rejected are all reported + invalid. The ambient environment never takes part. + + Args: + assignments: Variable name to value, as read from one file. ``None`` + stands for a line with no ``=``. + + Returns: + Findings in input order, and any error raised only when the complete + configuration is built from the valid assignments. + """ + declared = declared_env_names() + findings: dict[str, Finding] = {} + known: dict[str, str] = {} + by_group: dict[type[BaseSettings], list[str]] = {} + + for name, value in assignments.items(): + field = declared.get(name.upper()) + if field is None: + findings[name] = Finding(name=name, verdict=Verdict.UNKNOWN) + elif value is None: + findings[name] = Finding( + name=name, verdict=Verdict.INVALID, detail="no value assigned" + ) + else: + known[name] = value + by_group.setdefault(field.group, []).append(name) + + def classify(name: str, instance: BaseSettings, defaults: BaseSettings) -> None: + attr = declared[name.upper()].name + same = getattr(instance, attr) == getattr(defaults, attr) + findings[name] = Finding( + name=name, verdict=Verdict.REDUNDANT if same else Verdict.PINNED + ) + + for group, names in by_group.items(): + defaults, error = _build(group, {}, declared) + if defaults is None: + raise RuntimeError(f"{group.__name__} rejects its own defaults: {error}") + instance, error = _build(group, {n: known[n] for n in names}, declared) + if instance is not None: + for name in names: + classify(name, instance, defaults) + continue + alone_invalid = False + for name in names: + single, single_error = _build(group, {name: known[name]}, declared) + if single is None: + alone_invalid = True + findings[name] = Finding( + name=name, verdict=Verdict.INVALID, detail=single_error + ) + else: + classify(name, single, defaults) + if not alone_invalid: + for name in names: + findings[name] = Finding( + name=name, + verdict=Verdict.INVALID, + detail=f"rejected in combination: {error}", + ) + + valid = { + n: v + for n, v in known.items() + if findings[n].verdict in (Verdict.REDUNDANT, Verdict.PINNED) + } + config_errors: list[str] = [] + _, error = _build(Config, valid, declared) + if error: + config_errors.append(error) + + return AuditReport( + findings=[findings[name] for name in assignments], + config_errors=config_errors, + ) diff --git a/ontocast/config/settings.py b/ontocast/config/settings.py index a56fc37..409420d 100644 --- a/ontocast/config/settings.py +++ b/ontocast/config/settings.py @@ -6,13 +6,14 @@ from __future__ import annotations +import json import logging from enum import StrEnum from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal from pydantic import AliasChoices, Field, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from ontocast.onto.constants import ( DEFAULT_CACHE_MAX_BYTES, @@ -97,28 +98,37 @@ class LLMModelNameAbstract(StrEnum): class OpenAIModel(LLMModelNameAbstract): """OpenAI model names""" - # Flagship & Specialized Reasoning + # GPT-6: astra (most capable), sol (balanced), luna (high volume) + GPT6_ASTRA = "gpt-6-astra" + GPT6_SOL = "gpt-6-sol" + GPT6_LUNA = "gpt-6-luna" + + # GPT-5.6: sol (deepest reasoning; the bare alias), terra, luna (cheapest) + GPT5_6 = "gpt-5.6" + GPT5_6_SOL = "gpt-5.6-sol" + GPT5_6_TERRA = "gpt-5.6-terra" + GPT5_6_LUNA = "gpt-5.6-luna" + GPT5_4_PRO = "gpt-5.4-pro" GPT5_4_THINKING = "gpt-5.4-thinking" GPT5_4 = "gpt-5.4" - - # Cost-Optimized Lineup GPT5_4_MINI = "gpt-5.4-mini" GPT5_4_NANO = "gpt-5.4-nano" - GPT4_O = "gpt-4o" - GPT4_O_MINI = "gpt-4o-mini" - GPT4_1 = "gpt-41" - GPT4_1_MINI = "gpt-41-mini" GPT5 = "gpt-5" GPT5_MINI = "gpt-5-mini" GPT5_NANO = "gpt-5-nano" + GPT4_1 = "gpt-4.1" + GPT4_1_MINI = "gpt-4.1-mini" + GPT4_O = "gpt-4o" + GPT4_O_MINI = "gpt-4o-mini" class OllamaModel(LLMModelNameAbstract): """Ollama model names""" # Meta + MUSE_GLIMMER = "muse-glimmer" LLAMA4_SCOUT = "llama4-scout:17b" LLAMA3_3 = "llama3.3" LLAMA3_3_70B = "llama3.3:70b" @@ -126,6 +136,9 @@ class OllamaModel(LLMModelNameAbstract): LLAMA3_1_70B = "llama3.1:70b" # Alibaba Qwen + QWEN3_8 = "qwen3.8" + QWEN3_8_27B = "qwen3.8:27b" + QWEN3_8_FLASH_NEXT = "qwen3.8-flash-next" QWEN3_6 = "qwen3.6" QWEN3_6_LATEST = "qwen3.6:latest" QWEN3_6_27B = "qwen3.6:27b" @@ -138,12 +151,24 @@ class OllamaModel(LLMModelNameAbstract): QWEN2_5_CODER = "qwen2.5-coder" QWEN2_5_72B = "qwen2.5:72b" + # Zhipu GLM + GLM5_3 = "glm-5.3" + GLM5_3_FLASH = "glm-5.3-flash" + # IBM Granite + GRANITE4_2_3B = "granite4.2:3b" + GRANITE4_2_8B = "granite4.2:8b" + GRANITE4_2_30B = "granite4.2:30b" GRANITE4_1_3B = "granite4.1:3b" GRANITE4_1_8B = "granite4.1:8b" GRANITE4_1_30B = "granite4.1:30b" + # NVIDIA / OpenAI open weights + NEMOTRON3_5_LIGHTNING = "nemotron-3.5-lightning" + GPT_OSS_20B = "gpt-oss:20b" + # Moonshot / DeepSeek + DEEPSEEK_V4_1_FLASH = "deepseek-v4.1-flash" DEEPSEEK_R1 = "deepseek-r1" DEEPSEEK_V3 = "deepseek-v3" KIMI_K3 = "kimi-k3" @@ -156,40 +181,33 @@ class OllamaModel(LLMModelNameAbstract): class ClaudeModel(LLMModelNameAbstract): """Anthropic Claude model names""" - CLAUDE_SONNET_4 = "claude-sonnet-4-20250514" - CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest" - CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest" - - # Frontier Flagships (High Intelligence / Reasoning) - CLAUDE_4_7_OPUS = "claude-4.7-opus-latest" - CLAUDE_4_6_OPUS = "claude-4.6-opus-latest" - - # Balanced Production Sweet Spot - CLAUDE_4_6_SONNET = "claude-4.6-sonnet-latest" - CLAUDE_4_5_SONNET = "claude-4.5-sonnet-latest" - - # Ultra-Fast / Cost-Effective - CLAUDE_4_5_HAIKU = "claude-4.5-haiku-latest" + CLAUDE_FABLE_5_1 = "claude-fable-5-1" + CLAUDE_OPUS_5_5 = "claude-opus-5-5" + CLAUDE_OPUS_5 = "claude-opus-5" + CLAUDE_OPUS_4_8 = "claude-opus-4-8" + CLAUDE_OPUS_4_7 = "claude-opus-4-7" + CLAUDE_OPUS_4_6 = "claude-opus-4-6" + CLAUDE_SONNET_5_5 = "claude-sonnet-5-5" + CLAUDE_SONNET_5 = "claude-sonnet-5" + CLAUDE_SONNET_4_6 = "claude-sonnet-4-6" + CLAUDE_HAIKU_4_5 = "claude-haiku-4-5" class GeminiModel(LLMModelNameAbstract): """Google Gemini model names""" - GEMINI_2_0_FLASH = "gemini-2.0-flash" - GEMINI_1_5_PRO = "gemini-1.5-pro" - # Frontier Intelligence & Reasoning GEMINI_3_1_PRO = "gemini-3.1-pro" - GEMINI_2_5_PRO = "gemini-2.5-pro" + GEMINI_3_1_PRO_PREVIEW = "gemini-3.1-pro-preview" # Speed & Multimodal Agents + GEMINI_3_7_FLASH = "gemini-3.7-flash" GEMINI_3_5_FLASH = "gemini-3.5-flash" GEMINI_3_FLASH = "gemini-3-flash" - GEMINI_2_5_FLASH = "gemini-2.5-flash" + GEMINI_3_FLASH_PREVIEW = "gemini-3-flash-preview" # Ultra Budget & Low-Latency GEMINI_3_1_FLASH_LITE = "gemini-3.1-flash-lite" - GEMINI_2_5_FLASH_LITE = "gemini-2.5-flash-lite" #: The enums above are *presets*, not a whitelist: any string is accepted, and @@ -271,7 +289,7 @@ class LLMConfig(BaseSettings): ), ) model_name: LLMModelName = Field( - default=OpenAIModel.GPT5_4, + default=OpenAIModel.GPT5_6_LUNA, description=( "Model name passed to the provider. Any name the provider accepts " "works; a name OntoCast does not know is passed through with a " @@ -410,12 +428,12 @@ class LLMConfig(BaseSettings): ), ) reasoning_effort: ( - Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None + Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] | None ) = Field( default=None, description=( "How much the model reasons before answering: none, minimal, low," - " medium, high or xhigh. Sent to OpenAI reasoning models as " + " medium, high, xhigh or max. Sent to OpenAI reasoning models as " "reasoning_effort and to Gemini 3+ as thinking_level; which " "levels a model accepts is the provider's decision, and a " "rejected level fails the request. Reasoning tokens are billed as" @@ -1467,13 +1485,22 @@ class WebSearchConfig(BaseSettings): le=1000, description="Minimum snippet length to keep a search hit", ) - allowed_domains: list[str] = Field( + # NoDecode hands the raw environment string to `parse_domains`; without it + # the settings source JSON-decodes first and rejects the comma-separated + # and empty forms. + allowed_domains: Annotated[list[str], NoDecode] = Field( default_factory=list, - description="Optional allowlist of source domains for evidence", + description=( + "Optional allowlist of source domains for evidence: comma-separated " + "or a JSON list" + ), ) - blocked_domains: list[str] = Field( + blocked_domains: Annotated[list[str], NoDecode] = Field( default_factory=list, - description="Optional blocklist of source domains for evidence", + description=( + "Optional blocklist of source domains for evidence: comma-separated " + "or a JSON list" + ), ) region: str = Field( default="wt-wt", @@ -1489,6 +1516,8 @@ class WebSearchConfig(BaseSettings): @field_validator("allowed_domains", "blocked_domains", mode="before") @classmethod def parse_domains(cls, value: str | list[str]) -> list[str]: + if isinstance(value, str) and value.lstrip().startswith("["): + value = json.loads(value) if isinstance(value, list): return [entry.strip().lower() for entry in value if entry.strip()] if isinstance(value, str): diff --git a/ontocast/tool/llm.py b/ontocast/tool/llm.py index c9cc48d..05faff2 100644 --- a/ontocast/tool/llm.py +++ b/ontocast/tool/llm.py @@ -213,6 +213,50 @@ def llm_cache_config( # silently forcing it to 1.0 would make its runs unrepeatable. _TEMPERATURE_PINNED_TO_ONE = re.compile(r"^gpt-5(-|$)") +# Version parsers for the families whose providers reject `temperature` -- any +# value, or any but the default. The single-digit major keeps legacy names such +# as `gpt-41` out; the one- or two-digit minor keeps a dated snapshot +# (`claude-opus-4-20250514`) from reading as a minor version. +_OPENAI_VERSION = re.compile(r"^gpt-(\d)(?:\.(\d+))?(?!\d)") +_CLAUDE_VERSION = re.compile( + r"^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?!\d)" +) + + +def _rejects_temperature( + provider: str, model_name: str, reasoning_effort: str | None = None +) -> bool: + """Whether the provider rejects ``temperature`` for ``model_name``. + + Such models get no temperature at all and sample at their own default; a + sent value fails the request. OpenAI rejects it from GPT-5.5 on while the + model reasons, and accepts it at ``reasoning_effort="none"`` (the bare + gpt-5 series is pinned to 1.0 instead). Anthropic rejects it from Opus 4.7, + Sonnet 5 and every Fable/Mythos model. Read from the version in the name, + so a generation newer than this package is classified too. + """ + name = str(model_name).strip().lower() + if provider == LLMProvider.OPENAI: + match = _OPENAI_VERSION.match(name) + return ( + match is not None + and (int(match.group(1)), int(match.group(2) or 0)) >= (5, 5) + and reasoning_effort != "none" + ) + if provider == LLMProvider.ANTHROPIC: + match = _CLAUDE_VERSION.match(name) + if match is None: + return False + family = match.group(1) + version = (int(match.group(2)), int(match.group(3) or 0)) + if family in ("fable", "mythos"): + return True + if family == "opus": + return version >= (4, 7) + if family == "sonnet": + return version >= (5, 0) + return False + def _reads_thinking_level(model_name: str) -> bool: """Whether ``model_name`` is a Gemini generation that reads thinking_level. @@ -625,14 +669,28 @@ async def setup(self): if self.config.max_retries is not None: retry_kwargs["max_retries"] = self.config.max_retries self._warn_ignored_reasoning_knobs() + if self.config.provider == LLMProvider.OPENAI and ( + _TEMPERATURE_PINNED_TO_ONE.match(str(self.config.model_name)) + ): + self.config.temperature = 1.0 + logger.warning( + f"Setting temperature to {self.config.temperature} for gpt-5 class " + f"model {self.config.model_name}" + ) + temperature: float | None = self.config.temperature + if _rejects_temperature( + self.config.provider, + self.config.model_name, + self.config.reasoning_effort, + ): + temperature = None + logger.info( + "Not sending temperature to %s: the provider rejects it for this " + "model at this reasoning effort, so it samples at its own default", + self.config.model_name, + ) if self.config.provider == LLMProvider.OPENAI: - if _TEMPERATURE_PINNED_TO_ONE.match(str(self.config.model_name)): - self.config.temperature = 1.0 - logger.warning( - f"Setting temperature to {self.config.temperature} for gpt-5 class " - f"model {self.config.model_name}" - ) ChatOpenAI = require( "langchain_openai", feature="The OpenAI LLM provider" ).ChatOpenAI @@ -658,7 +716,7 @@ async def setup(self): reasoning_kwargs["reasoning_effort"] = self.config.reasoning_effort self._llm = ChatOpenAI( model=self.config.model_name, - temperature=self.config.temperature, + temperature=temperature, base_url=self.config.base_url, api_key=( SecretStr(self.config.api_key) if self.config.api_key else None @@ -687,7 +745,7 @@ async def setup(self): elif self.config.provider == LLMProvider.ANTHROPIC: anthropic_kwargs: dict[str, Any] = { "model": self.config.model_name, - "temperature": self.config.temperature, + "temperature": temperature, } if self.config.api_key: anthropic_kwargs["anthropic_api_key"] = SecretStr(self.config.api_key) diff --git a/pyproject.toml b/pyproject.toml index 16b9044..661a30b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "langgraph>=1.0,<2", "numpy>=1.26,<3", "oxrdflib>=0.5.0,<0.6", - "pydantic-settings>=2.5.0,<3", + "pydantic-settings>=2.7.0,<3", "pydantic>=2.11.9,<3", "pyld>=3.0,<4", "pyoxigraph>=0.5.8,<0.6", @@ -65,7 +65,7 @@ license-files = ["LICENSE"] name = "ontocast" readme = "README.md" requires-python = ">=3.12,<4.0" -version = "0.6.5" +version = "0.6.6" [project.optional-dependencies] # `plot` is not included: it only serves `plot-graph`, which regenerates the diff --git a/test/test_cli_env_files.py b/test/test_cli_env_files.py new file mode 100644 index 0000000..c091d07 --- /dev/null +++ b/test/test_cli_env_files.py @@ -0,0 +1,181 @@ +"""Layered settings files: ``ontocast --env-file`` and ``ontocast config check``.""" + +import logging +import os +from collections.abc import Iterator +from io import StringIO +from pathlib import Path + +import pytest +from click.testing import CliRunner +from dotenv import dotenv_values + +from ontocast.cli.env_files import apply_env_files, merge_env_files +from ontocast.cli.server import cli +from ontocast.config.env_audit import Verdict, audit_assignments +from ontocast.config.settings import WebSearchConfig + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def restored_environ() -> Iterator[None]: + """`apply_env_files` writes ``os.environ`` directly; undo it after the test.""" + saved = dict(os.environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(saved) + + +def _write(path: Path, text: str) -> Path: + path.write_text(text) + return path + + +def _verdicts(text: str) -> dict[str, Verdict]: + report = audit_assignments(dict(dotenv_values(stream=StringIO(text)))) + return {f.name: f.verdict for f in report.findings} + + +def test_later_file_wins(tmp_path: Path) -> None: + first = _write(tmp_path / "first.conf", "CHUNK_MIN_SIZE=1000\nPARALLEL_WORKERS=2\n") + second = _write(tmp_path / "second.conf", "CHUNK_MIN_SIZE=2000\n") + + merged = merge_env_files([first, second]) + + assert merged == {"CHUNK_MIN_SIZE": "2000", "PARALLEL_WORKERS": "2"} + + +def test_shell_export_wins_over_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, restored_environ: None +) -> None: + monkeypatch.setenv("CHUNK_MIN_SIZE", "4321") + monkeypatch.delenv("PARALLEL_WORKERS", raising=False) + conf = _write(tmp_path / "p.conf", "CHUNK_MIN_SIZE=1000\nPARALLEL_WORKERS=2\n") + + apply_env_files([conf]) + + assert os.environ["CHUNK_MIN_SIZE"] == "4321" + assert os.environ["PARALLEL_WORKERS"] == "2" + + +def test_unknown_variable_is_named_but_its_value_is_not( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + restored_environ: None, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.delenv("ONTOCAST_NO_SUCH_KNOB", raising=False) + conf = _write(tmp_path / "p.conf", "ONTOCAST_NO_SUCH_KNOB=s3cr3t-value\n") + + with caplog.at_level(logging.WARNING, logger="ontocast.cli.env_files"): + apply_env_files([conf]) + + assert "ONTOCAST_NO_SUCH_KNOB" in caplog.text + assert "s3cr3t-value" not in caplog.text + # Exported anyway: libraries read variables OntoCast does not declare. + assert os.environ["ONTOCAST_NO_SUCH_KNOB"] == "s3cr3t-value" + + +def test_group_option_loads_files_before_the_subcommand( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, restored_environ: None +) -> None: + monkeypatch.delenv("CHUNK_MIN_SIZE", raising=False) + conf = _write(tmp_path / "p.conf", "CHUNK_MIN_SIZE=1234\n") + + result = CliRunner().invoke(cli, ["--env-file", str(conf), "cache", "--help"]) + + assert result.exit_code == 0, result.output + assert os.environ["CHUNK_MIN_SIZE"] == "1234" + + +def test_audit_classifies_each_verdict() -> None: + verdicts = _verdicts( + "CONVERTER_PROFILE=auto\n" + "CHUNK_MAX_SIZE=99999\n" + "WEB_SEARCH_TOP_K=not-a-number\n" + "ONTOCAST_NO_SUCH_KNOB=1\n" + ) + + assert verdicts == { + "CONVERTER_PROFILE": Verdict.REDUNDANT, + "CHUNK_MAX_SIZE": Verdict.PINNED, + "WEB_SEARCH_TOP_K": Verdict.INVALID, + "ONTOCAST_NO_SUCH_KNOB": Verdict.UNKNOWN, + } + + +def test_audit_ignores_the_ambient_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A value is judged against the code default, not the caller's shell.""" + monkeypatch.setenv("CONVERTER_PROFILE", "ocr") + + assert _verdicts("CONVERTER_PROFILE=auto\n") == { + "CONVERTER_PROFILE": Verdict.REDUNDANT + } + assert os.environ["CONVERTER_PROFILE"] == "ocr" + + +def test_audit_reports_removed_value_without_echoing_input() -> None: + report = audit_assignments({"CONVERTER_PROFILE": "born_digital"}) + + (finding,) = report.findings + assert finding.verdict is Verdict.INVALID + assert "removed" in finding.detail + assert not report.ok + + +def test_audit_reports_values_rejected_only_in_combination() -> None: + report = audit_assignments( + { + "LLM_PROVIDER": "google", + "LLM_REASONING_EFFORT": "low", + "LLM_THINKING_BUDGET": "512", + } + ) + + assert {f.verdict for f in report.findings} == {Verdict.INVALID} + assert all("combination" in f.detail for f in report.findings) + + +def test_audit_reports_cross_group_errors() -> None: + report = audit_assignments( + {"QDRANT_URI": "http://localhost:6333", "LANCEDB_ENABLED": "true"} + ) + + assert {f.verdict for f in report.findings} == {Verdict.PINNED} + assert report.config_errors + assert not report.ok + + +def test_config_check_exit_status(tmp_path: Path) -> None: + clean = _write(tmp_path / "clean.conf", "CHUNK_MAX_SIZE=99999\n") + stale = _write(tmp_path / "stale.conf", "CHUNK_BREAKPOINT_THRESHOLD_TYPE=x\n") + runner = CliRunner() + + ok = runner.invoke(cli, ["config", "check", str(clean)]) + bad = runner.invoke(cli, ["config", "check", str(clean), str(stale)]) + + assert ok.exit_code == 0, ok.output + assert "pinned CHUNK_MAX_SIZE" in ok.output + assert bad.exit_code == 1 + assert "unknown CHUNK_BREAKPOINT_THRESHOLD_TYPE" in bad.output + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("", []), + ("a.com, B.org", ["a.com", "b.org"]), + ('["a.com"]', ["a.com"]), + ], +) +def test_web_search_domains_parse_from_environment( + monkeypatch: pytest.MonkeyPatch, raw: str, expected: list[str] +) -> None: + monkeypatch.setenv("WEB_SEARCH_ALLOWED_DOMAINS", raw) + + assert WebSearchConfig().allowed_domains == expected diff --git a/test/test_env_example_coverage.py b/test/test_env_example_coverage.py index 27f4775..cec690d 100644 --- a/test/test_env_example_coverage.py +++ b/test/test_env_example_coverage.py @@ -10,10 +10,13 @@ """ import re +from io import StringIO from pathlib import Path import pytest +from dotenv import dotenv_values +from ontocast.config.env_audit import Verdict, audit_assignments from ontocast.config.env_names import iter_settings_fields from ontocast.config.settings import Config @@ -203,6 +206,47 @@ def test_quoted_variable_counts_are_accurate() -> None: ) +_ASSIGNMENT_LINE = re.compile(r"^#?\s*([A-Z][A-Z0-9_]*=.*)$") + + +@pytest.mark.parametrize("filename", [".env.example", ".env.example.minimal"]) +def test_example_values_are_accepted(filename: str) -> None: + """The name checks above cannot see a value that went stale. + + `.env.example.minimal` kept advertising `CONVERTER_PROFILE=default` after + that profile was removed, and `.env.example` shipped an empty list setting + that failed to parse at startup. Every line is checked on its own, + commented ones included -- they are what a reader uncomments. An empty + commented value documents "unset" and is skipped. + """ + path = Path(__file__).resolve().parents[1] / filename + rejected: list[str] = [] + for lineno, line in enumerate(path.read_text().splitlines(), start=1): + match = _ASSIGNMENT_LINE.match(line) + if match is None: + continue + parsed = dotenv_values(stream=StringIO(match.group(1))) + if line.startswith("#") and not any(parsed.values()): + continue + report = audit_assignments(parsed) + rejected.extend( + f"{filename}:{lineno} {f.name} {f.verdict.value}: {f.detail}" + for f in report.findings + if f.verdict in (Verdict.UNKNOWN, Verdict.INVALID) + ) + assert rejected == [] + + +@pytest.mark.parametrize("filename", [".env.example", ".env.example.minimal"]) +def test_example_file_builds_a_config(filename: str) -> None: + """Copied verbatim, an example file must start OntoCast.""" + path = Path(__file__).resolve().parents[1] / filename + report = audit_assignments(dotenv_values(path)) + + assert report.config_errors == [] + assert [f.name for f in report.findings if f.verdict is Verdict.INVALID] == [] + + def test_minimal_env_example_stays_minimal() -> None: minimal = _documented_env_vars(".env.example.minimal") diff --git a/test/test_llm_config.py b/test/test_llm_config.py index e012a27..5cbe1f1 100644 --- a/test/test_llm_config.py +++ b/test/test_llm_config.py @@ -24,8 +24,8 @@ [ (LLMProvider.OPENAI, OpenAIModel.GPT4_O_MINI), (LLMProvider.OLLAMA, OllamaModel.LLAMA3_1), - (LLMProvider.ANTHROPIC, ClaudeModel.CLAUDE_SONNET_4), - (LLMProvider.GOOGLE, GeminiModel.GEMINI_2_0_FLASH), + (LLMProvider.ANTHROPIC, ClaudeModel.CLAUDE_SONNET_4_6), + (LLMProvider.GOOGLE, GeminiModel.GEMINI_3_7_FLASH), ], ) def test_llm_config_accepts_matching_provider_and_model( @@ -41,8 +41,8 @@ def test_llm_config_accepts_matching_provider_and_model( [ (LLMProvider.OPENAI, OllamaModel.LLAMA3_1), (LLMProvider.OLLAMA, OpenAIModel.GPT4_O_MINI), - (LLMProvider.ANTHROPIC, GeminiModel.GEMINI_2_0_FLASH), - (LLMProvider.GOOGLE, ClaudeModel.CLAUDE_SONNET_4), + (LLMProvider.ANTHROPIC, GeminiModel.GEMINI_3_7_FLASH), + (LLMProvider.GOOGLE, ClaudeModel.CLAUDE_SONNET_4_6), ], ) def test_llm_config_warns_but_accepts_mismatched_provider_and_model( @@ -119,15 +119,15 @@ def _default_model_for(provider: LLMProvider): if provider == LLMProvider.OPENAI: return OpenAIModel.GPT4_O_MINI if provider == LLMProvider.ANTHROPIC: - return ClaudeModel.CLAUDE_SONNET_4 - return GeminiModel.GEMINI_2_0_FLASH + return ClaudeModel.CLAUDE_SONNET_4_6 + return GeminiModel.GEMINI_3_7_FLASH @pytest.mark.parametrize( - "effort", ["none", "minimal", "low", "medium", "high", "xhigh"] + "effort", ["none", "minimal", "low", "medium", "high", "xhigh", "max"] ) def test_llm_config_accepts_each_reasoning_effort( - effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"], + effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"], ) -> None: # The union across providers and across model generations of one provider: # the floor is spelled `minimal` on some models and `none` on others. Which @@ -141,7 +141,7 @@ def test_llm_config_rejects_an_unknown_reasoning_effort() -> None: # first call, after the ontology sync has already been paid for. with pytest.raises(ValidationError): LLMConfig.model_validate( - {"provider": LLMProvider.OPENAI, "reasoning_effort": "max"} + {"provider": LLMProvider.OPENAI, "reasoning_effort": "maximum"} ) diff --git a/test/test_llm_setup.py b/test/test_llm_setup.py index 5b7dc61..0ed19de 100644 --- a/test/test_llm_setup.py +++ b/test/test_llm_setup.py @@ -37,7 +37,7 @@ def test_setup_openai() -> None: def test_setup_anthropic() -> None: config = LLMConfig( provider=LLMProvider.ANTHROPIC, - model_name=ClaudeModel.CLAUDE_SONNET_4, + model_name=ClaudeModel.CLAUDE_SONNET_4_6, api_key="test-key", base_url="https://api.example.com", ) @@ -47,14 +47,14 @@ def test_setup_anthropic() -> None: asyncio.run(tool.setup()) mock_cls.assert_called_once() kwargs = mock_cls.call_args.kwargs - assert kwargs["model"] == ClaudeModel.CLAUDE_SONNET_4 + assert kwargs["model"] == ClaudeModel.CLAUDE_SONNET_4_6 assert kwargs["anthropic_api_url"] == "https://api.example.com" def test_setup_google() -> None: config = LLMConfig( provider=LLMProvider.GOOGLE, - model_name=GeminiModel.GEMINI_2_0_FLASH, + model_name=GeminiModel.GEMINI_3_7_FLASH, api_key="test-key", ) with patch("langchain_google_genai.ChatGoogleGenerativeAI") as mock_cls: @@ -63,7 +63,7 @@ def test_setup_google() -> None: asyncio.run(tool.setup()) mock_cls.assert_called_once() kwargs = mock_cls.call_args.kwargs - assert kwargs["model"] == GeminiModel.GEMINI_2_0_FLASH + assert kwargs["model"] == GeminiModel.GEMINI_3_7_FLASH assert kwargs["google_api_key"] == "test-key" @@ -112,7 +112,7 @@ def test_setup_openai_leaves_reasoning_effort_to_the_client_when_unset() -> None def test_setup_google_passes_thinking_budget_to_the_client() -> None: config = LLMConfig( provider=LLMProvider.GOOGLE, - model_name=GeminiModel.GEMINI_2_0_FLASH, + model_name="gemini-2.5-flash", api_key="test-key", thinking_budget=0, ) @@ -130,7 +130,7 @@ def test_setup_google_passes_thinking_budget_to_the_client() -> None: [ ( LLMProvider.ANTHROPIC, - ClaudeModel.CLAUDE_SONNET_4, + ClaudeModel.CLAUDE_SONNET_4_6, "langchain_anthropic.ChatAnthropic", {"reasoning_effort": "low"}, ), @@ -210,7 +210,7 @@ def test_setup_keeps_the_thinking_budget_on_a_gemini_2_5_model() -> None: """The generation that still reads it is unaffected by the 3+ handling.""" config = LLMConfig( provider=LLMProvider.GOOGLE, - model_name=GeminiModel.GEMINI_2_5_FLASH, + model_name="gemini-2.5-flash", api_key="test-key", thinking_budget=0, ) @@ -262,3 +262,81 @@ def test_setup_leaves_temperature_alone_for_later_families(model_name) -> None: asyncio.run(LLMTool(config=config).setup()) assert mock_cls.call_args.kwargs["temperature"] == 0.0 assert config.temperature == 0.0 + + +@pytest.mark.parametrize( + "model_name", + [ + OpenAIModel.GPT5_6_LUNA, + OpenAIModel.GPT5_6_TERRA, + OpenAIModel.GPT6_LUNA, + OpenAIModel.GPT6_ASTRA, + "gpt-7-nova", + ], +) +def test_setup_omits_temperature_while_a_newer_openai_model_reasons( + model_name, +) -> None: + """GPT-5.5 onward rejects a temperature unless reasoning is off.""" + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=model_name, + api_key="k", + temperature=0.0, + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert mock_cls.call_args.kwargs["temperature"] is None + # The configured value stays what the cache key and manifest record. + assert config.temperature == 0.0 + + +@pytest.mark.parametrize("model_name", [OpenAIModel.GPT5_6_LUNA, OpenAIModel.GPT6_SOL]) +def test_setup_keeps_temperature_at_reasoning_effort_none(model_name) -> None: + """With reasoning off the model samples, so a pinned temperature applies.""" + config = LLMConfig( + provider=LLMProvider.OPENAI, + model_name=model_name, + api_key="k", + temperature=0.0, + reasoning_effort="none", + ) + with patch("langchain_openai.ChatOpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + assert mock_cls.call_args.kwargs["temperature"] == 0.0 + + +@pytest.mark.parametrize( + ("model_name", "sent"), + [ + (ClaudeModel.CLAUDE_OPUS_5_5, False), + (ClaudeModel.CLAUDE_OPUS_4_7, False), + (ClaudeModel.CLAUDE_SONNET_5_5, False), + (ClaudeModel.CLAUDE_FABLE_5_1, False), + (ClaudeModel.CLAUDE_OPUS_4_6, True), + (ClaudeModel.CLAUDE_SONNET_4_6, True), + (ClaudeModel.CLAUDE_HAIKU_4_5, True), + # A dated snapshot's date is not a minor version. + ("claude-opus-4-20250514", True), + ], +) +def test_setup_sends_temperature_only_to_claude_models_that_accept_it( + model_name, sent: bool +) -> None: + config = LLMConfig( + provider=LLMProvider.ANTHROPIC, + model_name=model_name, + api_key="k", + temperature=0.0, + ) + with patch("langchain_anthropic.ChatAnthropic") as mock_cls: + mock_cls.return_value = MagicMock() + asyncio.run(LLMTool(config=config).setup()) + temperature = mock_cls.call_args.kwargs["temperature"] + assert temperature == (0.0 if sent else None) + + +def test_default_model_is_a_preset() -> None: + assert LLMConfig().model_name == OpenAIModel.GPT5_6_LUNA diff --git a/uv.lock b/uv.lock index a69b6b2..77e42ad 100644 --- a/uv.lock +++ b/uv.lock @@ -2751,7 +2751,7 @@ wheels = [ [[package]] name = "ontocast" -version = "0.6.5" +version = "0.6.6" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -2909,7 +2909,7 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, { name = "properdocs", marker = "extra == 'docs'", specifier = ">=1.6.7" }, { name = "pydantic", specifier = ">=2.11.9,<3" }, - { name = "pydantic-settings", specifier = ">=2.5.0,<3" }, + { name = "pydantic-settings", specifier = ">=2.7.0,<3" }, { name = "pygraphviz", marker = "extra == 'plot'", specifier = ">=2.0,<3" }, { name = "pyld", specifier = ">=3.0,<4" }, { name = "pyoxigraph", specifier = ">=0.5.8,<0.6" },