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
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.git
.venv
**/node_modules
**/.next
**/out
**/__pycache__
**/.pytest_cache
**/.ruff_cache
.env*
**/.env*
.deployment
.deployment-build.log
supabase/.temp
data/raw
data/work
data/derived/f2
docs/screenshots
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
33 changes: 33 additions & 0 deletions .github/workflows/runtime-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Build deployment runtime
on:
workflow_dispatch:
push:
branches: [codex/deploy-stratalab-cc]
paths: [services/**, data/**, docs/methods/**, .dockerignore, .github/workflows/runtime-image.yml]
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: services/worker/Dockerfile
push: true
tags: |
ghcr.io/gothicscientist/strata-runtime:${{ github.sha }}
ghcr.io/gothicscientist/strata-runtime:portfolio
labels: |
org.opencontainers.image.source=https://github.com/gothicscientist/strata
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ node_modules/
out/
.env
.env.local
.deployment/
.deployment-build.log
supabase/.temp/
data/raw/
data/work/
*.egg-info/
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Full stack: `docker compose -f infra/docker-compose.yml --env-file .env up --bui
Production on Supabase, a container host and Cloudflare Pages:
[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). Every environment variable is documented in
[`.env.example`](.env.example).
The credit-protected Azure/Cloudflare deployment for stratalab.cc is documented in
[`infra/azure/README.md`](infra/azure/README.md).

## Tests

Expand Down
2 changes: 2 additions & 0 deletions apps/web/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
es5-ext: false
62 changes: 49 additions & 13 deletions apps/web/src/app/assistant/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,14 @@ export default function AssistantPage() {
const [shared, setShared] = useState<string[]>([]);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<unknown>(null);
const [provider, setProvider] = useState<"anthropic" | "openai">("openai");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const abortRef = useRef<AbortController | null>(null);
const endRef = useRef<HTMLDivElement>(null);

const configured = health ? health.capabilities.llm_configured : null;
const configured = Boolean(health?.capabilities.llm_configured || apiKey.trim().length >= 8);
const connected = session.status === "guest" || session.status === "signed-in";

// Restore the conversation from this tab's previous visit.
useEffect(() => {
Expand All @@ -246,7 +250,7 @@ export default function AssistantPage() {

async function send() {
const content = input.trim();
if (!content || streaming) return;
if (!content || streaming || !configured || !connected) return;
setError(null);
setStreaming(true);
const ctrl = new AbortController();
Expand All @@ -261,7 +265,10 @@ export default function AssistantPage() {
}
setInput("");
setTurns((t) => [...t, { role: "user", content }, emptyAssistant()]);
for await (const ev of streamChat(sid, content, ctrl.signal)) {
const credentials = apiKey.trim()
? { provider, api_key: apiKey.trim(), ...(model.trim() ? { model: model.trim() } : {}) }
: undefined;
for await (const ev of streamChat(sid, content, ctrl.signal, credentials)) {
setTurns((t) => {
const last = t[t.length - 1];
if (!last || last.role !== "assistant") return t;
Expand Down Expand Up @@ -307,15 +314,43 @@ export default function AssistantPage() {
}
/>

{configured === false ? (
<Caveat title="The assistant is not configured on this server">
No language-model provider has an API key, so questions cannot be answered. The operator
can set one as described in <code>docs/DEPLOYMENT.md</code>. All analyses remain available
from the <Link href="/analyses/new/">composer</Link>.
</Caveat>
) : null}

<div className="max-w-[46rem]">
<fieldset className="mb-8 border-b border-rule pb-6" disabled={streaming}>
<legend className="text-h3 italic">Use your own API key</legend>
<p id="api-key-help" className="mb-4 mt-2 text-small text-ink-2">
Your provider bills your API account. The key stays in this page's memory and is
sent securely to Strata for each request, then to your selected provider. It is
never saved. Reloading the page clears it. All analyses also work without AI in
the <Link href="/analyses/new/">composer</Link>.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="label">Provider</span>
<select className="field" value={provider} onChange={(e) => {
setProvider(e.target.value as "anthropic" | "openai");
setApiKey("");
setModel("");
}}>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</label>
<label className="block">
<span className="label">Model (optional)</span>
<input className="field" value={model} maxLength={120}
placeholder={provider === "openai" ? "gpt-5" : "claude-opus-5-5"}
onChange={(e) => setModel(e.target.value)} />
</label>
</div>
<label className="mt-4 block">
<span className="label">API key</span>
<input className="field" type="password" autoComplete="off" spellCheck={false}
aria-describedby="api-key-help" value={apiKey} maxLength={4096}
onChange={(e) => setApiKey(e.target.value)} placeholder="Paste your provider API key" />
</label>
{apiKey ? <button type="button" className="linkish mt-3 text-small"
onClick={() => setApiKey("")}>Clear API key</button> : null}
</fieldset>
{turns.length === 0 ? (
<div className="mb-10 text-small text-ink-2">
<p className="mb-2 italic text-ink">For example</p>
Expand Down Expand Up @@ -394,7 +429,7 @@ export default function AssistantPage() {
className="field h-28 resize-y !font-serif !text-body !leading-[1.5]"
value={input}
maxLength={8000}
disabled={configured === false}
disabled={!connected || streaming}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
Expand All @@ -410,7 +445,7 @@ export default function AssistantPage() {
Stop
</button>
) : (
<button type="submit" className="btn" disabled={!input.trim() || configured === false}>
<button type="submit" className="btn" disabled={!input.trim() || !configured || !connected}>
Ask
</button>
)}
Expand All @@ -420,6 +455,7 @@ export default function AssistantPage() {
</button>
) : null}
</div>
{!configured ? <p className="mt-3 text-small text-ink-2">Enter an API key above to ask the assistant.</p> : null}
</form>
</div>
</>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/QcReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const KNOWN: Array<[keyof Dataset["qc"], string, (v: unknown) => string]> = [
["n_no_calls", "No-calls", (v) => int(v as number)],
["n_indels_dropped", "Indels dropped", (v) => int(v as number)],
["het_rate", "Heterozygosity", (v) => fixed(v as number, 4)],
["panel_overlap", "SNPs on reference panel", (v) => int(v as number)],
["panel_overlap", "Panel coverage", (v) => pct(v as number, 1)],
["panel_size", "Reference panel size", (v) => int(v as number)],
];

Expand All @@ -24,11 +24,11 @@ export function QcReport({ dataset, n = 1 }: { dataset: Dataset; n?: number | st
const v = qc[key];
if (v === null || v === undefined || v === "") continue;
if (key === "n_variants" && dataset.kind === "g25") continue;
if (key === "panel_overlap" && typeof qc.overlap?.n_usable === "number") {
rows.push(["SNPs on reference panel", int(qc.overlap.n_usable)]);
}
rows.push([label, fmt(v)]);
}
if (qc.panel_overlap && qc.panel_size) {
rows.push(["Panel coverage", pct(qc.panel_overlap / qc.panel_size, 1)]);
}
if (qc.sex_chrom_counts) {
rows.push([
"Sex-chromosome calls",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function SiteFooter() {
<dd>{caps ? (caps.qpadm ? "available" : "unavailable on this server") : "—"}</dd>
<dt>Assistant</dt>
<dd>
{caps ? (caps.llm_providers.length ? caps.llm_providers.join(", ") : "no provider configured") : "—"}
{caps ? (caps.llm_providers.length ? caps.llm_providers.join(", ") : "bring your own API key") : "—"}
</dd>
</dl>
</div>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/results/AnalysisDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ export function ReportButtons({ id }: { id: string }) {
export function ProvenanceList({ provenance }: { provenance: Record<string, unknown> | null | undefined }) {
if (!provenance || Object.keys(provenance).length === 0) return null;
return (
<dl className="grid max-w-[48rem] grid-cols-[minmax(8rem,auto)_1fr] gap-x-6 gap-y-1 text-small">
<dl className="grid max-w-[48rem] grid-cols-[minmax(8rem,auto)_minmax(0,1fr)] gap-x-6 gap-y-1 text-small">
{describeParams(provenance).map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-ink-3">{k}</dt>
<dd className="num break-words">{v}</dd>
<dd className="num min-w-0 [overflow-wrap:anywhere]">{v}</dd>
</div>
))}
</dl>
Expand Down Expand Up @@ -109,11 +109,11 @@ export function AnalysisDocument({ analysis }: { analysis: Analysis }) {

<Section n={1} title="Methods">
{params.length ? (
<dl className="grid max-w-[48rem] grid-cols-[minmax(8rem,auto)_1fr] gap-x-6 gap-y-1 text-small">
<dl className="grid max-w-[48rem] grid-cols-[minmax(8rem,auto)_minmax(0,1fr)] gap-x-6 gap-y-1 text-small">
{params.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-ink-3">{k}</dt>
<dd className="num break-words">{v}</dd>
<dd className="num min-w-0 [overflow-wrap:anywhere]">{v}</dd>
</div>
))}
</dl>
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,20 @@ export function decodeChatEvent(event: string, data: string): ChatEvent | null {
}

/** POST a user message and stream the assistant's events. */
export interface ChatCredentials {
provider: "anthropic" | "openai";
api_key: string;
model?: string;
}

export async function* streamChat(
sessionId: string,
content: string,
signal?: AbortSignal,
credentials?: ChatCredentials,
): AsyncGenerator<ChatEvent> {
const res = await request<Response>(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, {
json: { content },
json: { content, ...credentials },
raw: true,
accept: "text/event-stream",
signal,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface Capabilities {
qpadm: boolean;
llm_providers: string[];
llm_configured: boolean;
llm_byok?: boolean;
reference_version: string | null;
reference_loaded: boolean;
snp_panel_loaded: boolean;
Expand Down Expand Up @@ -180,8 +181,10 @@ export interface QCReport {
n_indels_dropped?: number | null;
het_rate?: number | null;
sex_chrom_counts?: Record<string, number> | null;
/** Fraction of reference-panel SNPs usable, between 0 and 1. */
panel_overlap?: number | null;
panel_size?: number | null;
overlap?: { n_usable?: number; [key: string]: unknown } | null;
n_samples?: number | null;
warnings: string[];
[key: string]: unknown;
Expand Down
14 changes: 13 additions & 1 deletion apps/web/test/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { QcReport } from "@/components/QcReport";
import { ResultView } from "@/components/results/ResultView";
import { ErrorNote, KindMark } from "@/components/typography";
Expand All @@ -18,6 +18,18 @@ describe("components", () => {
expect(screen.getByText("synthetic warning")).toBeInTheDocument();
});

it.each([
{ usable: 30_000, fraction: 30_000 / 417_935, count: /30\s000/, coverage: "7.2%" },
{ usable: 0, fraction: 0, count: /^0$/, coverage: "0.0%" },
])("shows the API's SNP count and coverage separately ($usable usable)", ({ usable, fraction, count, coverage }) => {
render(<QcReport dataset={{ ...synthDataset, qc: {
...synthDataset.qc, panel_size: 417_935, panel_overlap: fraction,
overlap: { n_usable: usable },
} }} />);
expect(within(screen.getByRole("row", { name: /SNPs on reference panel/ })).getByRole("cell", { name: count })).toBeInTheDocument();
expect(screen.getByRole("row", { name: /Panel coverage/ })).toHaveTextContent(coverage);
});

it("labels results as exploratory or formal", () => {
render(<KindMark kind="formal" />);
expect(screen.getByText("formal")).toHaveAttribute("title", expect.stringMatching(/qpAdm/));
Expand Down
Loading
Loading