Interfaces¶
See Interfaces for how to use each surface.
Error messages¶
helioai.interfaces.errors ¶
One sentence for a failed turn, shared by the CLI, the web UI and Jupyter.
The first thing a new install does is fail: no key, a local server not started, a key pasted with a space. Each interface used to show that failure its own way — 264 lines of traceback in the terminal, the SDK's bare "Connection error." in the browser — and none of them said what to do next. The translation lives here, once, so the three surfaces say the same thing and the fix is written next to the problem.
setup_problem ¶
What stops provider from answering before a single request is sent, or None.
build_llm_client already refuses a missing key. A missing model it lets through,
on purpose for OpenCode: the gateway has no sensible default, so its model is empty
until the user picks one — and the request then fails at the gateway with an error
that does not name the variable to set. Checked here, before the question is spent.
Source code in helioai/interfaces/errors.py
describe_llm_error ¶
Turn an exception raised while answering a question into one actionable line.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exc
|
BaseException
|
What |
required |
provider
|
str | None
|
The provider the turn ran on; the configured one when omitted. |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A sentence saying what failed and how to fix it. Errors this function does not |
str
|
recognise keep their type and message, and point at DEBUG logging for the |
str
|
traceback, rather than being dressed up as a diagnosis they are not. |
Source code in helioai/interfaces/errors.py
Command line¶
helioai.interfaces.cli ¶
Interactive CLI for HelioAI.
Usage
helioai # interactive session (/help lists its commands)
helioai "your query" # one-shot query
helioai --resume # pick a past session and continue it
helioai history # list sessions
helioai history delete
main ¶
Entry point for the helioai command.
Routes subcommands (index, export, history, profile, serve, doctor, ...) and otherwise runs either a one-shot query or the interactive prompt.
--help is answered before anything else runs. The default branch of this
router treats an unrecognised argument as a question, so until it was
handled, helioai --help created a workspace and billed an LLM call to ask
the model what --help meant — the first thing anyone types after
pip install. Printing __doc__ keeps the help and the module's own
documentation as one string. Only a standalone --help token counts: a quoted
question that happens to contain the words is still a question.
Source code in helioai/interfaces/cli.py
Jupyter magic¶
helioai.interfaces.jupyter_magic ¶
Jupyter IPython magics for HelioAI.
Load with
%load_ext helioai.interfaces.jupyter_magic
Cell magic
%%helioai solar wind density ACE 2005-01-17
Line magics
%helioai_session new|reset|delete
HelioAIMagics ¶
Bases: Magics
IPython magics exposing the agent inside a notebook.
Source code in helioai/interfaces/jupyter_magic.py
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 | |
helioai ¶
%%helioai — send a natural-language query to the agent.
Figures render inline; parameter cards and catalog previews render as HTML.
Example
%load_ext helioai.interfaces.jupyter_magic
%%helioai Download ACE IMF for the 2015-03-17 storm, plot Bz and mark the shock arrival.
Source code in helioai/interfaces/jupyter_magic.py
helioai_session ¶
%helioai_session new|reset|delete <id> — manage the active session.
new starts a fresh conversation and keeps the previous one; reset deletes
the current one first. The distinction matters at the end of a demo: the
analysis just exported must survive the next question.
Source code in helioai/interfaces/jupyter_magic.py
helioai_provider ¶
%helioai_provider [name] — show or switch the LLM provider for the next cells.
The choice lives on this magics instance, so it lasts for the kernel and is
handed to build_llm_client on every cell. The provider names come from the
factory rather than a list kept here, which is how ollama went missing once.
Source code in helioai/interfaces/jupyter_magic.py
helioai_history ¶
%helioai_history — list recent sessions.
Source code in helioai/interfaces/jupyter_magic.py
helioai_profile ¶
%helioai_profile — show or edit the user profile.
Source code in helioai/interfaces/jupyter_magic.py
helioai_export ¶
%helioai_export — export the session as a standalone notebook.
Source code in helioai/interfaces/jupyter_magic.py
helioai_resume ¶
%helioai_resume — pick a previous session to continue.
Source code in helioai/interfaces/jupyter_magic.py
helioai_dev ¶
%helioai_dev <token> — unlock unrestricted mode for this session.
Source code in helioai/interfaces/jupyter_magic.py
load_ipython_extension ¶
Register the HelioAI magics. Called by %load_ext.
Also sweeps expired session workspaces: a notebook kernel is the one HelioAI process the CLI's startup sweep never runs in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ipython
|
Any
|
The active InteractiveShell, supplied by IPython itself. This hook name and signature are IPython's contract, not ours. |
required |
Source code in helioai/interfaces/jupyter_magic.py
Web application¶
helioai.interfaces.web.app ¶
FastAPI web interface for HelioAI.
Single-user, no auth. Streams agent events as SSE.
Figures from the sandbox are served via /figure?path=
require_user
async
¶
Resolve the caller's user_id from the X-Helio-Token header.
No users configured (local dev) → single shared user, no auth. Once HELIOAI_USERS is set (deployment), a valid nominative token is required.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x_helio_token
|
str | None
|
The |
Header(default=None)
|
Returns:
| Type | Description |
|---|---|
str
|
The user id owning storage for this request. |
Raises:
| Type | Description |
|---|---|
HTTPException
|
401 when users are configured and the token is unknown. |
Source code in helioai/interfaces/web/app.py
harden_for_host ¶
Add the middleware a given bind address calls for, and return the app.
Kept apart from serve_web so a test can build exactly what uvicorn will serve:
added inside serve_web, the host guard was never on the app the TestClient
imported, and the DNS-rebinding defence went untested for a year.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
FastAPI
|
The FastAPI application. |
required |
host
|
str
|
The address about to be bound. |
required |
Returns:
| Type | Description |
|---|---|
FastAPI
|
The same app, so the call reads as an expression. |
Source code in helioai/interfaces/web/app.py
index
async
¶
favicon
async
¶
The icon browsers ask for at the root whatever the page links, and 404'd on.
health
async
¶
api_config
async
¶
Server-side settings the UI cannot know on its own — never a secret, only whether one is set.
The provider selector used to default to whichever option came first in the markup
— azure — and sent it on every message, so a server configured for another
provider was quietly overridden by the browser. providers says which of them this
server can actually reach, so the selector stops offering a key nobody set.
auth and dev_token decide what the sidebar's token field is for: with
HELIOAI_USERS it is the access token every request needs, with only
HELIOAI_DEV_TOKEN it is the optional dev token, and with neither it is hidden —
a field asking a local user for a token that does not exist was the first thing a
newcomer asked about.
Source code in helioai/interfaces/web/app.py
chat_stream
async
¶
chat_stream(req: _ChatRequest, x_helio_dev_token: str | None = Header(default=None), user_id: str = Depends(require_user)) -> StreamingResponse
Stream one agent turn as Server-Sent Events.
Each agent event — tool calls, results, artifacts, sub-agent activity — is forwarded as it happens, which is what drives the live activity dock.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
req
|
_ChatRequest
|
Body carrying the question and the session to continue. |
required |
x_helio_dev_token
|
str | None
|
Optional dev token lifting the scope guardrail. |
Header(default=None)
|
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
StreamingResponse
|
A |
Source code in helioai/interfaces/web/app.py
me
async
¶
Who the caller is and what they have spent.
The first thing a per-user quota needs is a number to compare against; until now nothing summed the token counts the providers report. Totals for today (UTC-ish: the last 24 h), the last 30 days and all time.
Returns:
| Type | Description |
|---|---|
dict
|
|
dict
|
prompt/completion/cached tokens and call count. |
Source code in helioai/interfaces/web/app.py
list_sessions
async
¶
List the calling user's sessions, most recent first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
list
|
Session summaries, newest first. |
Source code in helioai/interfaces/web/app.py
get_session_events
async
¶
Replay a session from its journal: every event the live stream showed, in order.
The browser renders these with the same function as the live stream, so a reloaded session shows the plan, the provenance verdict, the figure reviews and the sub-agent trace exactly as they appeared.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Session to replay. |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
dict
|
|
dict
|
which the browser then fetches through |
Source code in helioai/interfaces/web/app.py
get_session_messages
async
¶
Replay a session recorded before the event journal, from its messages.
Kept for those sessions only — see legacy_replay. A session with a journal is
served by /events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Session to replay. |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
dict
|
|
dict
|
carrying the artifacts the tool calls before it produced. |
Source code in helioai/interfaces/web/app.py
get_profile
async
¶
Return the caller's profile markdown.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Source code in helioai/interfaces/web/app.py
put_profile
async
¶
Replace the caller's profile markdown.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
_ProfileBody
|
New profile content, replacing the previous one wholesale. |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Source code in helioai/interfaces/web/app.py
delete_session
async
¶
Delete one of the caller's sessions and its workspace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Session to delete. Sanitised through |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
dict
|
|
dict
|
learns nothing about other users' session ids from the answer. |
Source code in helioai/interfaces/web/app.py
export_notebook
async
¶
Export a session as a standalone .ipynb and return it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Session to export. |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
FileResponse
|
The notebook as a file download, built in memory rather than written to |
FileResponse
|
the workspace. |
Source code in helioai/interfaces/web/app.py
serve_code
async
¶
serve_code(path: str, full: bool = False, user_id: str = Depends(require_user)) -> PlainTextResponse
Return a generated script, rewritten to standalone form.
Ownership is checked against the caller before anything is read, so a path
outside the caller's workspace is a 404 rather than a leak. A run_recipe run of an
unmodified shipped recipe is shown as its own lines, the recipe read from the
installed package (export.recipe_run_view) — six hundred lines of recipe buried the
five the model wrote — and the X-HelioAI-Full-Lines header tells the panel that the
whole script is one full=true away.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Absolute path of the generated script, as the artifact reported it. |
required |
full
|
bool
|
Return the whole script even when a short recipe view exists. |
False
|
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
PlainTextResponse
|
The script rewritten to standalone speasy calls. |
Raises:
| Type | Description |
|---|---|
HTTPException
|
404 for a path outside the caller's workspace or absent. |
Source code in helioai/interfaces/web/app.py
serve_figure
async
¶
Serve a figure (PNG or PDF) from the caller's workspace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Absolute path of the figure, as the artifact reported it. |
required |
user_id
|
str
|
Resolved by |
Depends(require_user)
|
Returns:
| Type | Description |
|---|---|
FileResponse
|
The file, with a content type derived from its extension. Only PNG and |
FileResponse
|
PDF are served, so a traversal that reached another file type still |
FileResponse
|
returns nothing. |
Raises:
| Type | Description |
|---|---|
HTTPException
|
404 outside the caller's workspace, or absent. |
Source code in helioai/interfaces/web/app.py
serve_web ¶
Run the web UI with uvicorn.
Binds to localhost by default. The open-source build ships no authentication
and run_python executes model-written code, so do not expose this on a
network without putting auth in front of it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
Bind address. Anything but loopback exposes an arbitrary code executor; read SECURITY.md before changing it. |
'127.0.0.1'
|
port
|
int
|
TCP port. |
7890
|
Source code in helioai/interfaces/web/app.py
refuse_unauthenticated_public_bind ¶
Exit rather than serve run_python to a network with no one authenticated.
The same rule helioai-mcp --http applies to a bind without a token: a public
address with no HELIOAI_USERS is a deployment error, and a warning someone might
read after the fact is not a boundary. HELIOAI_ALLOW_UNAUTHENTICATED_PUBLIC=1 is
the explicit opt-out for a container that binds 0.0.0.0 behind a loopback publish.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
The address about to be bound. |
required |
Raises:
| Type | Description |
|---|---|
SystemExit
|
On a non-loopback host with neither users nor the opt-out. |
Source code in helioai/interfaces/web/app.py
MCP server¶
helioai.mcp_server ¶
MCP server for HelioAI — exposes registered tools and read-only resources (recipes, skills) via stdio or HTTP streamable transport.
Usage
helioai serve # stdio (Claude Desktop / claude CLI) helioai serve --http # HTTP streamable on 127.0.0.1:8765 helioai serve --http --host 0.0.0.0 --port 9000 # requires HELIOAI_MCP_TOKEN helioai-mcp [--http ...] # direct entry point, same flags
Skills are listed from a process-lifetime-cached index (skills_loader._discover is lru_cache'd): a skill added or edited after this process started is invisible until restart. Recipes re-glob the filesystem on every call and need no restart.
serve_stdio
async
¶
Run the MCP server over stdio, for clients like Claude Desktop.
Blocks until the client closes the pipe. All registry tools and the recipe/skill resources are exposed — over stdio the client owns the process, so no auth applies.
Source code in helioai/mcp_server.py
build_http_app ¶
Build the streamable-HTTP ASGI app exposing the MCP server.
Returns a Starlette app mounting the MCP session manager at /mcp, wrapped in a
Bearer-token check when HELIOAI_MCP_TOKEN is set, suitable for any ASGI server
(serve_http wraps it in uvicorn).
Source code in helioai/mcp_server.py
serve_http ¶
Run the MCP server over streamable HTTP.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
Bind address. |
required |
port
|
int
|
TCP port. |
required |
main ¶
Entry point for the helioai-mcp command.
--help and --version are answered before anything else: an MCP server on stdio
reads the terminal as its protocol stream, so the reflex helioai-mcp --help used
to start a server that sat waiting for JSON-RPC, with nothing on screen to say so.
Example
helioai-mcp # stdio (Claude Desktop, claude CLI) helioai-mcp --http --port 8765 # streamable HTTP on 127.0.0.1:8765
Source code in helioai/mcp_server.py
Indexer¶
helioai.indexer ¶
Build the speasy catalog ChromaDB index.
Usage
helioai index # incremental (skip existing) helioai index --rebuild # wipe and rebuild
MEASUREMENT_TYPES
module-attribute
¶
MEASUREMENT_TYPES: tuple[str, ...] = ('MagneticField', 'ElectricField', 'ThermalPlasma', 'EnergeticParticles', 'IonComposition', 'Ephemeris', 'Waves', 'Spectrum', 'NeutralGas', 'InstrumentStatus', 'Irradiance', 'Radiance')
The SPASE MeasurementType vocabulary the index already carries (AMDA, CSA), as the closed set a classifier chooses from — so a filled field is usable as an exact filter.
REGIONS
module-attribute
¶
REGIONS: tuple[str, ...] = ('Sun', 'Sun.Corona', 'Heliosphere', 'Heliosphere.Inner', 'Heliosphere.NearEarth', 'Heliosphere.Remote1AU', 'Heliosphere.Outer', 'Earth', 'Earth.Magnetosphere', 'Earth.Magnetosheath', 'Earth.Magnetosphere.Polar', 'Earth.Magnetosphere.Magnetotail', 'Earth.Magnetosphere.RadiationBelt', 'Earth.NearSurface', 'Earth.NearSurface.Ionosphere', 'Earth.NearSurface.AuroralRegion', 'Earth.NearSurface.EquatorialRegion', 'Earth.NearSurface.PolarCap', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Jupiter.Io', 'Jupiter.Europa', 'Jupiter.Ganymede', 'Jupiter.Callisto', 'Saturn', 'Saturn.Enceladus', 'Uranus', 'Neptune', 'Pluto', 'Comet')
The SPASE Region vocabulary AMDA publishes as dataset targets (30 values on 8 435 products) plus the two the indexer's table uses and AMDA does not — the closed set a classifier chooses from.
SHIPPED_JUDGED
module-attribute
¶
Every question the judge has been asked about a product, with its answer, shipped with
the package: one gzipped JSON line per product (id, name, then one {choice,
confidence} per question asked — mtype, region), after a first line of provenance
(meta: date, models, floors, count). It is the part of the index that cannot be rebuilt
from code — 82 266 requests, US$ 2.4 on 2026-09-22 — kept as the judge's raw answers, not
as decided fields, so the policy (floors, never overwriting a published label) lives in
code and can change without asking again. Abstentions are in it too: a question already
asked is not paid for twice. helioai index applies it to every product the archive left
untyped; --classify asks only what no record answers and appends to the local copy.
build_index ¶
build_index(rebuild: bool = False, batch_size: int = 128, verbose: bool = True, classify: bool = False) -> int
Walk the speasy inventory and index all parameters into ChromaDB.
Backs helioai index and must run once before search_parameters works;
the index persists under settings.rag.chroma_dir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rebuild
|
bool
|
Drop and re-create the collection instead of appending. |
False
|
batch_size
|
int
|
Documents per ChromaDB insert. |
128
|
verbose
|
bool
|
Print per-provider progress to stdout. |
True
|
classify
|
bool
|
Ask the judgment backend, before embedding, for the SPASE measurement
type of every product the archive leaves untyped and for the SPASE region of
every product whose region is the table's guess ( |
False
|
Returns:
| Type | Description |
|---|---|
int
|
Number of parameters indexed (0 when speasy or chromadb is missing). |
Example
build_index(rebuild=True) # equivalent to: helioai index --rebuild 82433
Source code in helioai/indexer.py
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 | |
local_judged_path ¶
Where --classify writes the answers it obtains: beside the data, not the index.
The Chroma directory is wiped by --rebuild; the data root is not. A user with a key
who classifies a new provider keeps those answers across every rebuild, and they take
precedence over the shipped file for the same id.
Source code in helioai/indexer.py
load_judged ¶
Read the judge's recorded answers from each file in turn, later files overriding earlier ones id by id; a missing or unreadable file contributes nothing.
Returns:
| Type | Description |
|---|---|
dict
|
|
dict[str, dict]
|
|
Source code in helioai/indexer.py
save_judged ¶
Write the answers as load_judged reads them, sorted by id, provenance first.
Source code in helioai/indexer.py
apply_judged ¶
Apply the recorded answers to every walked doc they name.
A record whose name no longer matches the product's is skipped: the id was reused
for something else, and a type decided about the old content is not evidence about
the new. A record without a name (none of the first pass had one) is applied.
Returns:
| Type | Description |
|---|---|
int
|
How many docs received at least one field. |
Source code in helioai/indexer.py
classify_products
async
¶
classify_products(docs: list[dict], record_dir: Path | str, *, judged: dict[str, dict] | None = None, judged_meta: dict | None = None, verbose: bool = False) -> list[dict]
Ask the judge what no record has answered yet, apply it, and keep the answers.
Two closed questions per product: the SPASE measurement type where the archive left it empty, and the SPASE region where the indexer had only guessed. Neither answer ever overwrites anything the archive published.
Measurement type. The field is indexed on 15.6 % of the products — AMDA and CSA —
and on none of CDA's 68 000, so every ranking signal built on it (_rerank_penalty)
and every filter reaches a sixth of the catalogue. Measured on 2026-09-22 against 200
products the archive had labelled, label stripped from the text before asking: 76.5 %
agreement, 89 % where the judge's confidence is at least 0.9 (72 % of the items) —
and the remaining confident disagreements were the archive's errors (MMS FPI plasma
moments labelled MagneticField, a JADE density labelled EnergeticParticles, a
Langmuir-probe density labelled ElectricField). So the judge is better than its ground
truth, and the floor is 0.9: below it the field stays empty — abstention is a type —
and a published label the judge contradicts at or above it is kept and flagged as
measurement_type_jev, for a person to adjudicate, never replaced.
Region. _get_region guesses from a 40-entry table matched as a substring; against
AMDA's 8 435 published targets it agrees on 26.9 %, is silent on 41 % and wrong on 32 %
("ac" inside "cce_mepa_ion_act" made AMPTE/CCE a near-Earth heliospheric product).
Measured the same day on 200 of those products, target stripped: the judge agrees
exactly on 70 %, on the body (Earth, Jupiter, Heliosphere…) on 97.1 % at confidence
≥ 0.9, and where judge and table differ the judge is right 75 times to the table's
one. Its confident disagreements with the archive are granularity, in both directions
(Helios filed as Heliosphere, a Galileo Io flyby read as Jupiter), so a published target
is never flagged — it stands. The table's guess is not a publication: at or above the
floor the judge's region replaces it, or fills the silence, with region_source: "jev"
and the confidence; below it the guess stays, marked as the guess it is.
What is asked. A product is asked only the questions no record in judged answers
for it — the shipped file plus the local one — so a second pass over the same
catalogue costs nothing, a new provider costs its own products, and adding a question
costs one request per product for that question alone. Both sentences the text carried
are stripped before asking, so the judge reads the product, not the labels. Every call
is recorded to judgment_index.jsonl in the index directory with the product id as its
key, and every answer — abstentions included — is appended to the local
judged_products.jsonl.gz for the next rebuild. ~2.9 ¢ per 1 000 requests (metered
2026-09-22: 838 tokens a request, the instruction being most of it).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
docs
|
list[dict]
|
|
required |
record_dir
|
Path | str
|
Where the calls are recorded (the Chroma directory). |
required |
judged
|
dict[str, dict] | None
|
The answers already on record, updated in place. |
None
|
judged_meta
|
dict | None
|
Their provenance, carried into the saved file. |
None
|
verbose
|
bool
|
Print the counts. |
False
|
Returns:
| Type | Description |
|---|---|
list[dict]
|
The same docs, metadata and text amended in place. |
Source code in helioai/indexer.py
680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 | |
open_collections ¶
open_collections(chroma_dir: Path | str, names: list[str], *, verbose: bool = False) -> tuple[Any, list[Any]]
Open or create the index's collections so that every write is persisted at once, and so that the dense search looks wide enough to find a near-twin.
Chroma's local HNSW segment persists to disk only every sync_threshold writes — 1000
by default. Whatever follows the last persist stays in the write-ahead log and is
replayed into the in-memory graph at every process start, in an order that varies, so
the graph varies and the ranking with it. Measured on 2026-09-22: the 325 SSCWeb
trajectories added after the last persist gave five different dense top-50 lists in five
processes for one query embedding, ssc/mms1 at rank 1 in four of them and absent from
the fifth. The catalogue collection, 221 entries, had never been persisted at all. A
threshold of one costs 0.11 s per batch on the full 82k index and leaves nothing to
replay, so a search ranks the same in every process and a read-only process never
writes to the index.
ef_search is raised from Chroma's 100 to 400. The catalogue is full of near-twins —
314 SSCWeb trajectories that differ by a spacecraft name, hundreds of housekeeping
variables that differ by a suffix — and an approximate search with a narrow beam loses
the exact twin: ssc/mms1, the true nearest neighbour of "MMS1 spacecraft position GSE
2019", was absent from the dense top-50 at 100 and is rank 1 at 400, for 0.7 → 1.2 ms per
query (measured 2026-09-22). On the 30 HelioBench n1 queries the change moved recall@1
from 53.3 % to 56.7 %.
A collection created before these settings keeps the ones it was loaded with, so it is
modified and the client reopened: the replay on reopen persists its tail. Reopening
clears Chroma's process-wide client cache — fine in helioai index, and the reason this
is not done lazily by a process that also serves searches.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chroma_dir
|
Path | str
|
The Chroma directory, created when absent. |
required |
names
|
list[str]
|
Collection names, opened in order. |
required |
verbose
|
bool
|
Print when a legacy collection is settled. |
False
|
Returns:
| Type | Description |
|---|---|
tuple[Any, list[Any]]
|
|
Source code in helioai/indexer.py
Index snapshots¶
helioai.index_snapshot ¶
Share a built index: export it to files, fetch a published one, import it.
Building the index walks the speasy inventory and embeds ~83 000 products — 7 to 10 minutes
on a recent machine, longer on a modest one, before a researcher can ask a first question. For a given catalogue
and embedding model the result is the same on every machine, so CI builds it once per
release (.github/workflows/index.yml) and publishes a snapshot on the Hugging Face Hub;
helioai index on an empty index fetches that snapshot instead of building.
A snapshot is the content of the collections — ids, documents, metadata, embeddings — and
not Chroma's directory: a store written by one Chroma release is not guaranteed to open
in an older one, and chromadb carries no upper bound. Importing re-creates the
collections through open_collections, with the HNSW settings a local build uses.
index_is_empty ¶
Whether the product collection is absent or holds nothing.
This is the condition under which helioai index fetches the published snapshot
rather than walking the inventory, and a fetch replaces the index. So a store that
exists but cannot be read — corrupt, or written by a Chroma this one cannot open — is
not empty: helioai index then builds on it and fails loudly, rather than discarding
products a user built or classified.
Source code in helioai/index_snapshot.py
export_index ¶
Write the index as a snapshot: per collection a gzipped JSONL and a float32 .npy.
The records keep Chroma's order, so an import inserts in the order the build did. The manifest carries a SHA-256 per file — the import refuses a truncated download rather than serving a partial index — and the embedding model, since vectors from another model would be silently meaningless against this install's query embeddings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
out_dir
|
Path
|
Destination directory, created when absent. |
required |
chroma_dir
|
Path | None
|
The index to export; |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
The manifest written to |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
No index at |
ValueError
|
The product collection is empty. |
Source code in helioai/index_snapshot.py
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | |
import_index ¶
Replace the index with a snapshot's content, and return the number of entries.
The collections are filled in a staging directory beside the index and swapped in
only once complete: an interrupted import leaves the previous index — or none — and
never a partial one that helioai index would then take for up to date and merely
top up. The previous index is set aside as <index>.previous until the new one is in
place, and put back if the swap fails — on Windows a store another process holds open
cannot be renamed. A .previous already there, left by a crash, is never deleted:
the import refuses instead. The judge's local answers (judgment_index.jsonl) are
carried across the swap, as --rebuild does, since nothing but a paid request can
recreate them.
Raises:
| Type | Description |
|---|---|
ValueError
|
Unknown format, another embedding model, or a checksum mismatch. |
Source code in helioai/index_snapshot.py
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | |
download_index ¶
Fetch the snapshot published for this release, or the latest one when there is none.
CI tags each snapshot with the release it was built by (v0.4.0), so an installed
release gets the index its own code describes. A development install has no tag of
its own and gets main, the most recent snapshot: an index that at worst predates
some describing change, which helioai index --rebuild catches up with.
The Hub caches the files, so a second fetch of the same revision downloads nothing.
Returns:
| Type | Description |
|---|---|
tuple[Path, str]
|
|
Source code in helioai/index_snapshot.py
fetch_index ¶
Download the published snapshot from settings.rag.index_repo and import it.
Returns:
| Type | Description |
|---|---|
int
|
Number of entries imported. |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Exception
|
Whatever the Hub raises — network, unknown repository. |
Source code in helioai/index_snapshot.py
publish_index ¶
Upload a snapshot to main of the Hub dataset repo, and tag it with a release.
The upload is refused when the new snapshot holds fewer than MIN_KEPT of the
products expected. The build walks five archives over the network, and one of them
answering 502 for the length of the walk yields an index without that archive and no
error; published, it would replace a complete index for every new install. Expected
is what the dataset already holds, or — for the first publication, which happens
unattended at a release tag — the products the shipped classification answered for
(judged_products.jsonl.gz, one line per product the paid pass saw). A tag already
on the dataset is looked up and moved to the new commit, so re-running a release
publishes that release's index; nothing depends on which error the Hub raises for
an absent tag. The dataset card (README.md, from .github/hf-index-card.md) goes
with the snapshot when its directory holds one, so the card is reviewed in the
repository rather than edited on the Hub.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snapshot_dir
|
Path
|
What |
required |
repo
|
str
|
|
required |
tag
|
str | None
|
The release, |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The commit id on the Hub. |