Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .dockerignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
.git
.github
.claude
node_modules
.next
coverage
test-results
playwright-report
scratch
.tmp-visual
sample-documents
dev-server.log
*.log
.env
.env.*
!.env.example
.vscode
.idea
Dockerfile
Dockerfile.worker
.dockerignore
121 changes: 121 additions & 0 deletions .github/workflows/eval-canary.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
# Nightly production eval canary. See docs/observability-slos.md §3.
#
# Runs the golden retrieval eval (which PR CI can never run — it needs live
# Supabase + OpenAI keys) plus a small answer-quality subset against the live
# project, and fails loudly on regression: red run + a GitHub issue on
# scheduled failures.
#
# The schedule only fires from the default branch. After merging, trigger one
# workflow_dispatch run and confirm it is green before trusting the nightly
# cadence.
name: Eval Canary

on:
workflow_dispatch:
inputs:
answer_case_limit:
description: "Number of answer-quality cases to run (--limit)"
required: false
default: "8"
schedule:
# 18:00 UTC = 02:00 Australia/Perth — off-peak for clinicians.
- cron: "0 18 * * *"

concurrency:
group: eval-canary
cancel-in-progress: false

permissions:
contents: read
issues: write

env:
NEXT_PUBLIC_SUPABASE_URL: https://sjrfecxgysukkwxsowpy.supabase.co
SUPABASE_PROJECT_REF: sjrfecxgysukkwxsowpy
SUPABASE_PROJECT_NAME: Clinical KB Database
# Evals use the service-role admin client; the publishable key only needs to
# satisfy env validation (same placeholder approach as ci.yml).
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder-ci-anon-key
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RAG_EVAL_OWNER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}

jobs:
eval-canary:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Preflight required secrets
run: |
missing=""
[ -z "$SUPABASE_SERVICE_ROLE_KEY" ] && missing="$missing SUPABASE_SERVICE_ROLE_KEY"
[ -z "$OPENAI_API_KEY" ] && missing="$missing OPENAI_API_KEY"
[ -z "$RAG_EVAL_OWNER_EMAIL" ] && missing="$missing E2E_USER_EMAIL"
if [ -n "$missing" ]; then
echo "::error::Eval canary cannot run — missing repo secrets:$missing"
exit 1
fi

- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: npm
cache-dependency-path: package-lock.json

- name: Install dependencies
run: npm ci

- name: Guard Supabase project identity
run: npm run check:supabase-project

- name: Golden retrieval eval (live corpus)
run: npm run eval:retrieval:quality -- --fail-on-threshold

- name: Answer-quality subset (live generation)
run: npm run eval:quality -- --rag-only --limit ${{ github.event.inputs.answer_case_limit || '8' }} --fail-on-threshold

- name: Open or update regression issue
if: failure() && github.event_name == 'schedule'
uses: actions/github-script@v8
with:
script: |
const label = "eval-canary";
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
`Nightly eval canary failed on ${new Date().toISOString()}.`,
"",
`Run: ${runUrl}`,
"",
"Triage order (docs/observability-slos.md §3): rerun via workflow_dispatch,",
"check hybrid_rpc_errors and `npm run check:indexing`, then bisect code.",
"A failure can be corpus-state-dependent — confirm before reverting anything.",
].join("\n");
const { data: existing } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
labels: label,
});
if (existing.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing[0].number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Eval canary regression: nightly golden eval failed",
labels: [label],
body,
});
}
61 changes: 61 additions & 0 deletions Dockerfile
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1
# Clinical KB app tier (Next.js). See docs/deployment-architecture.md.
#
# The repo is engine-strict (Node 24.x / npm 11.x via .npmrc + preinstall
# guard), so every stage pins the same Node 24 base image. The build stage
# runs the repo's own `npm run build` (guard-next-build + next build) so the
# image build fails exactly where a local build would.
#
# NEXT_PUBLIC_* values are inlined into the client bundle at build time.
# The publishable key is public by design; pass the real one for a
# production image:
# docker build \
# --build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... \
# -t clinical-kb-app .
# Server-side secrets (SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, ...) are
# NEVER baked into the image — inject them at run time from the host's
# secret store.

FROM node:24-bookworm-slim AS deps
WORKDIR /app
# check-node-engine.cjs runs as the npm preinstall hook, so it must be in
# place before `npm ci`.
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
RUN npm ci

FROM node:24-bookworm-slim AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co
ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-build-publishable-key
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}
# The repo build script allocates an 8 GiB heap; give the builder >= 10 GiB.
RUN npm run build

FROM node:24-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
RUN npm ci --omit=dev

FROM node:24-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/.next ./.next
COPY public ./public
COPY package.json next.config.ts ./
USER node
EXPOSE 3000
# /api/health is the app's own ops health route.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Bypass scripts/dev-free-port.mjs (a local-dev port picker): a container has
# exactly one app, so bind 0.0.0.0 on $PORT directly.
CMD ["sh", "-c", "node node_modules/next/dist/bin/next start -H 0.0.0.0 -p ${PORT:-3000}"]
38 changes: 38 additions & 0 deletions Dockerfile.worker
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
# Clinical KB ingestion worker (Node pipeline + Python OCR stack).
# See docs/deployment-architecture.md for why the worker ships as a
# container instead of completing the edge-agent migration.
#
# Runtime contents:
# - Node 24 + full (dev-inclusive) node_modules: the worker runs through
# tsx, which is a devDependency.
# - Tesseract OCR (Debian package, bundles English language data).
# - Python venv with worker/python/requirements.txt (PyMuPDF, Pillow,
# pytesseract). The venv's `python` matches the PYTHON_BIN default.
#
# Build: docker build -f Dockerfile.worker -t clinical-kb-worker .
# Run: docker run --env-file <secrets> clinical-kb-worker
# Secrets are injected at run time; nothing is baked into the image.

FROM node:24-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
RUN npm ci

FROM node:24-bookworm-slim AS runner
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-venv tesseract-ocr \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY worker/python/requirements.txt worker/python/requirements.txt
RUN python3 -m venv /opt/ocr-venv \
&& /opt/ocr-venv/bin/pip install --no-cache-dir -r worker/python/requirements.txt
ENV PATH="/opt/ocr-venv/bin:${PATH}"
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
# Long-poll worker; WORKER_* env vars control claim batch size, concurrency,
# and the stale-claim window (see src/lib/env.ts).
CMD ["node", "node_modules/tsx/dist/cli.mjs", "worker/index.ts"]
Loading
Loading