Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ properties:
properties:
provider:
type: string
enum: ["openai", "ollama"]
# `openai` and `openai-responses` are the same endpoint through the two
# API shapes. Pick the one the endpoint publishes: a gateway that has no
# chat/completions operation returns its own 404 regardless of the model,
# and Azure's Codex deployments are Responses-only in any case.
enum: ["openai", "openai-responses", "ollama"]
model:
type: string
description: "e.g. gpt-4o-mini, gpt-5.6-luna. LLM_MODEL overrides this."
Expand Down
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ embeddings/
embeddings_bak/
csv_files/
records/
.dockerignore
sockets/
.venv/
.env
.env.*
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,43 @@ jobs:
- name: Run tests
run: poetry run pytest

schema:
# initdb/init.sql is hand-written and Chainlit's data layer sends every
# non-None key of the dict it is handed, so a key added upstream becomes
# an INSERT into a column that does not exist here. That went unnoticed
# for 22 months and surfaced as UndefinedColumn on every user request.
# The guard has to run against a database actually built from init.sql,
# which is what this job is.
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ci-only
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python and Poetry
uses: ./.github/actions/install_python_poetry

- name: Create the databases from initdb/init.sql
run: |
psql postgresql://postgres:ci-only@localhost:5432/postgres \
-v ON_ERROR_STOP=1 -f initdb/init.sql

- name: Check the schema against the installed Chainlit
run: |
PYTHONPATH=./src poetry run python bin/check_schema.py \
--dsn postgresql://postgres:ci-only@localhost:5432/chatbio_chainlit


poetry-check:
# No event filter. It used to run only on pull_request and
# workflow_dispatch, so every push to main skipped it -- and GitHub counts
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,12 @@ data/
# belongs in the repository.
*.pem
*.key

# The plantreactomedev deployment's file-based Docker secrets, which
# deploy/plantreactome/compose.plantreactome.yaml mounts in place of compose.yaml's Swarm-only external
# ones. Only the empty placeholder belongs in git -- it is what the six secrets
# the environment already supplies point at, and `docker compose up` fails if it
# is missing. Anything else dropped in here is secret material by definition.
deploy/plantreactome/secrets/*
!deploy/plantreactome/secrets/empty
sockets/
64 changes: 63 additions & 1 deletion bin/chat-chainlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@
from handoff.store import AnalysisHandoff, handoffs
from handoff.window import acknowledgement, claimed_id
from util import captcha_cookie
from util.branding import branding_for
from util.chainlit_helpers import (
PrefixedS3StorageClient,
TextStreamCallbackHandler,
is_feature_enabled,
message_rate_limited,
save_openai_metrics,
Expand Down Expand Up @@ -89,9 +91,69 @@
f"{len(_mounted)} of {len(SECRET_NAMES)} secrets supplied as Docker secrets"
)


def _sanitize_llm_settings() -> None:
"""Stop non-JSON values reaching the persisted generation settings.

`_build_llm_settings` is the one place in chainlit's GenerationHelper that
does not run its output through `ensure_values_serializable` -- `_start_trace`
does, for run.inputs. So anything unserializable goes straight into
SQLAlchemyDataLayer.create_step's json.dumps and the INSERT dies, which
deletes the llm step from thread history entirely.

Ours is `response_format`: with_structured_output(method="json_schema") binds
the pydantic CLASS and serializes it only at request time. That method is
deliberate -- the gpt-5.6 family refuses function tools on
/v1/chat/completions -- so the class stays in the kwargs and is made safe
here instead.

Patched at the tracer rather than in a data-layer subclass because the same
dict is also handed to context.emitter.update_step, so the socket sees it.
"""
from chainlit.langchain.callbacks import GenerationHelper

original = GenerationHelper._build_llm_settings

def patched(
self: Any,
serialized: dict[str, Any],
invocation_params: dict[str, Any] | None = None,
) -> tuple[Any, ...]:
result: tuple[Any, ...] = original(self, serialized, invocation_params)
if len(result) != 4:
return result
provider, model, tools, settings = result
return (
provider,
model,
tools,
self.ensure_values_serializable(settings) if settings else settings,
)

GenerationHelper._build_llm_settings = patched # type: ignore[method-assign]


_sanitize_llm_settings()

config: Config | None = Config.from_yaml()

profiles: list[ProfileName] = config.profiles if config else [ProfileName.React_to_Me]
BRANDING = branding_for(profiles)

# Chainlit's own chrome -- the login page, the tab title, the header -- reads
# `config.ui` at request time, so setting it here, at import, reaches every page
# it serves. `util.branding` is the one place a deployment says who it is, and
# `profiles:` in config.yml already selects it for the landing page and the
# captcha error pages (bin/chat-fastapi.py). A reader who was just handed a
# Plant Reactome landing page cannot then be walked into a Reactome login page.
#
# Not the logo: that is served from public/ by chainlit's /logo route, and a
# Plant Reactome deployment supplies its own file there (deploy/plantreactome/compose.plantreactome.yaml)
# rather than making the login page depend on plantreactome.gramene.org being
# up. `logo_file_url` is the override if that ever has to change at runtime.
chainlit_config.ui.name = BRANDING.chatbot_name
chainlit_config.ui.login_page_image = BRANDING.login_page_image

# Not built here any more. Building at module scope meant importing this file
# -- which mount_chainlit does -- constructed the graph and every BM25 index,
# 85 seconds before the app could serve or a test could load it. The FastAPI
Expand Down Expand Up @@ -552,7 +614,7 @@ async def answer_with_model(content: str, message_id: str) -> None:

thread_id: str = current_thread_id()

chainlit_cb = cl.AsyncLangchainCallbackHandler(
chainlit_cb = TextStreamCallbackHandler(
stream_final_answer=True,
force_stream_final_answer=True, # we're not using prefix tokens
)
Expand Down
Loading
Loading