diff --git a/.gitignore b/.gitignore index f87aa10..818082d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ CLAUDE.md # Local data static/ storage/ + +# Large embedding caches and test artifacts +data/ref_cache/ +apps/web/test-results/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f43228 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute +this software, either in source code form or as a compiled binary, for any +purpose, commercial or non-commercial, and by any means. + +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md index df4dc9c..9cefcd8 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ PROTEA provides a unified backend for ingesting protein data from UniProt, compu ## Getting started -### Docker (recommended) +### Docker + +> **Not yet validated.** The Docker configuration exists but has not been tested end-to-end. It will likely need adjustments before it works out of the box — contributions welcome. ```bash git clone https://github.com/frapercan/PROTEA.git @@ -52,7 +54,7 @@ Services available at: - API: http://localhost:8000 - RabbitMQ management: http://localhost:15672 (guest/guest) -### From source +### From source (recommended) **Requirements:** Python 3.12, PostgreSQL 16 + pgvector, RabbitMQ 3.x @@ -118,4 +120,4 @@ PROTEA is the natural evolution of two prior systems developed at **Ana Rojas' L PROTEA was designed to unify and supersede both systems under a single, maintainable codebase — removing the tight coupling between infrastructure, orchestration, and domain logic that accumulated across those projects. -The evaluation pipeline and scoring methodology are directly informed by our participation in **CAFA6** (Critical Assessment of protein Function Annotation, 6th edition). The competition provided real-world benchmarking experience that shaped PROTEA's prediction and evaluation architecture, including the integration of [cafaeval](https://github.com/claradepaolis/CAFA-evaluator-PK) for standardised GO term prediction assessment. +The evaluation pipeline and scoring methodology are directly informed by following the **CAFA** (Critical Assessment of protein Function Annotation) competition series. This benchmarking framework shaped PROTEA's prediction and evaluation architecture, including the integration of [cafaeval](https://github.com/claradepaolis/CAFA-evaluator-PK) for standardised GO term prediction assessment. diff --git a/alembic/versions/489835ed5b31_add_composite_index_pga_set_accession.py b/alembic/versions/489835ed5b31_add_composite_index_pga_set_accession.py new file mode 100644 index 0000000..b99dc62 --- /dev/null +++ b/alembic/versions/489835ed5b31_add_composite_index_pga_set_accession.py @@ -0,0 +1,32 @@ +"""add_composite_index_pga_set_accession + +Revision ID: 489835ed5b31 +Revises: 7737a352d4fe +Create Date: 2026-03-15 11:17:30.865922 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '489835ed5b31' +down_revision: Union[str, Sequence[str], None] = '7737a352d4fe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_index( + "ix_pga_set_accession", + "protein_go_annotation", + ["annotation_set_id", "protein_accession"], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_pga_set_accession", table_name="protein_go_annotation") diff --git a/alembic/versions/513355a1d933_add_scoring_config_id_to_evaluation_.py b/alembic/versions/513355a1d933_add_scoring_config_id_to_evaluation_.py new file mode 100644 index 0000000..1890a22 --- /dev/null +++ b/alembic/versions/513355a1d933_add_scoring_config_id_to_evaluation_.py @@ -0,0 +1,38 @@ +"""add scoring_config_id to evaluation_result + +Revision ID: 513355a1d933 +Revises: 489835ed5b31 +Create Date: 2026-03-15 12:37:19.930750 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '513355a1d933' +down_revision: Union[str, Sequence[str], None] = '489835ed5b31' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('evaluation_result', sa.Column('scoring_config_id', sa.UUID(), nullable=True)) + op.create_index(op.f('ix_evaluation_result_scoring_config_id'), 'evaluation_result', ['scoring_config_id'], unique=False) + op.create_foreign_key(None, 'evaluation_result', 'scoring_config', ['scoring_config_id'], ['id'], ondelete='SET NULL') + op.drop_index(op.f('ix_pga_set_accession'), table_name='protein_go_annotation') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_pga_set_accession'), 'protein_go_annotation', ['annotation_set_id', 'protein_accession'], unique=False) + op.drop_constraint(None, 'evaluation_result', type_='foreignkey') + op.drop_index(op.f('ix_evaluation_result_scoring_config_id'), table_name='evaluation_result') + op.drop_column('evaluation_result', 'scoring_config_id') + # ### end Alembic commands ### diff --git a/alembic/versions/54e758c210c8_add_ia_url_to_ontology_snapshot.py b/alembic/versions/54e758c210c8_add_ia_url_to_ontology_snapshot.py new file mode 100644 index 0000000..cde8fca --- /dev/null +++ b/alembic/versions/54e758c210c8_add_ia_url_to_ontology_snapshot.py @@ -0,0 +1,41 @@ +"""add_ia_url_to_ontology_snapshot + +Revision ID: 54e758c210c8 +Revises: c1d2e3f4a5b6 +Create Date: 2026-03-16 11:42:10.636169 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '54e758c210c8' +down_revision: Union[str, Sequence[str], None] = 'c1d2e3f4a5b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('ontology_snapshot', sa.Column('ia_url', sa.String(), nullable=True, comment='URL of the Information Accretion TSV for this ontology release (two columns: go_id, ia_value). Used by run_cafa_evaluation to weight GO terms by information content. NULL means uniform IC=1.')) + op.alter_column('scoring_config', 'evidence_weights', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + comment=None, + existing_comment='Optional per-GO-evidence-code quality multipliers in [0, 1]. NULL means use the system defaults defined in DEFAULT_EVIDENCE_WEIGHTS. Partial dicts are allowed; absent codes fall back to the system table.', + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('scoring_config', 'evidence_weights', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + comment='Optional per-GO-evidence-code quality multipliers in [0, 1]. NULL means use the system defaults defined in DEFAULT_EVIDENCE_WEIGHTS. Partial dicts are allowed; absent codes fall back to the system table.', + existing_nullable=True) + op.drop_column('ontology_snapshot', 'ia_url') + # ### end Alembic commands ### diff --git a/alembic/versions/7737a352d4fe_merge_scoring_config_branch.py b/alembic/versions/7737a352d4fe_merge_scoring_config_branch.py new file mode 100644 index 0000000..f759c30 --- /dev/null +++ b/alembic/versions/7737a352d4fe_merge_scoring_config_branch.py @@ -0,0 +1,28 @@ +"""merge_scoring_config_branch + +Revision ID: 7737a352d4fe +Revises: 47de89cf6fec, b1c2d3e4f5a6 +Create Date: 2026-03-15 10:11:56.507967 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7737a352d4fe' +down_revision: Union[str, Sequence[str], None] = ('47de89cf6fec', 'b1c2d3e4f5a6') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/7c19ca08d5d4_add_support_entry_table.py b/alembic/versions/7c19ca08d5d4_add_support_entry_table.py new file mode 100644 index 0000000..599214c --- /dev/null +++ b/alembic/versions/7c19ca08d5d4_add_support_entry_table.py @@ -0,0 +1,37 @@ +"""add support_entry table + +Revision ID: 7c19ca08d5d4 +Revises: 513355a1d933 +Create Date: 2026-03-15 12:42:43.832417 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7c19ca08d5d4' +down_revision: Union[str, Sequence[str], None] = '513355a1d933' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('support_entry', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('support_entry') + # ### end Alembic commands ### diff --git a/alembic/versions/b1c2d3e4f5a6_add_scoring_config.py b/alembic/versions/b1c2d3e4f5a6_add_scoring_config.py new file mode 100644 index 0000000..5eae559 --- /dev/null +++ b/alembic/versions/b1c2d3e4f5a6_add_scoring_config.py @@ -0,0 +1,38 @@ +"""add scoring_config table + +Revision ID: b1c2d3e4f5a6 +Revises: a7b8c9d0e1f2 +Create Date: 2026-03-15 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "b1c2d3e4f5a6" +down_revision = "a7b8c9d0e1f2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scoring_config", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("formula", sa.String(50), nullable=False, server_default="linear"), + sa.Column("weights", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("scoring_config") diff --git a/alembic/versions/c1d2e3f4a5b6_add_evidence_weights_to_scoring_config.py b/alembic/versions/c1d2e3f4a5b6_add_evidence_weights_to_scoring_config.py new file mode 100644 index 0000000..4a3a4c9 --- /dev/null +++ b/alembic/versions/c1d2e3f4a5b6_add_evidence_weights_to_scoring_config.py @@ -0,0 +1,49 @@ +"""Add evidence_weights column to scoring_config. + +Revision ID: c1d2e3f4a5b6 +Revises: 7c19ca08d5d4 +Create Date: 2026-03-16 + +Motivation +---------- +``ScoringConfig`` previously hard-coded the per-evidence-code quality +weights inside the Python scoring engine, making them invisible to users +and impossible to customise without a code change. + +This migration adds an optional ``evidence_weights`` JSONB column that +stores per-code overrides at the config level. Existing rows receive +``NULL``, which is interpreted by the engine as "use system defaults" +(:data:`protea.infrastructure.orm.models.embedding.scoring_config.DEFAULT_EVIDENCE_WEIGHTS`). +The change is therefore fully backwards-compatible with all existing +``ScoringConfig`` rows. +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "c1d2e3f4a5b6" +down_revision = "7c19ca08d5d4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "scoring_config", + sa.Column( + "evidence_weights", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment=( + "Optional per-GO-evidence-code quality multipliers in [0, 1]. " + "NULL means use the system defaults defined in DEFAULT_EVIDENCE_WEIGHTS. " + "Partial dicts are allowed; absent codes fall back to the system table." + ), + ), + ) + + +def downgrade() -> None: + op.drop_column("scoring_config", "evidence_weights") diff --git a/apps/web/app/annotations/page.tsx b/apps/web/app/[locale]/annotations/page.tsx similarity index 64% rename from apps/web/app/annotations/page.tsx rename to apps/web/app/[locale]/annotations/page.tsx index d352126..73be48b 100644 --- a/apps/web/app/annotations/page.tsx +++ b/apps/web/app/[locale]/annotations/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { listAnnotationSets, listOntologySnapshots, + setSnapshotIaUrl, deleteAnnotationSet, createJob, AnnotationSet, @@ -12,6 +13,7 @@ import { } from "@/lib/api"; import { useToast } from "@/components/Toast"; import { SkeletonTableRow } from "@/components/Skeleton"; +import { useTranslations } from "next-intl"; type Tab = "sets" | "snapshots" | "load-snapshot" | "load-goa" | "load-quickgo"; @@ -28,6 +30,7 @@ function shortId(id: string) { } export default function AnnotationsPage() { + const t = useTranslations("annotations"); const toast = useToast(); const [activeTab, setActiveTab] = useState("sets"); @@ -36,6 +39,27 @@ export default function AnnotationsPage() { const [loadingSets, setLoadingSets] = useState(true); const [loadingSnaps, setLoadingSnaps] = useState(true); + // IA URL inline edit state: snapshotId → current input value (undefined = not editing) + const [iaEditId, setIaEditId] = useState(null); + const [iaEditValue, setIaEditValue] = useState(""); + const [iaSaving, setIaSaving] = useState(false); + + async function handleSaveIa(snapshotId: string) { + setIaSaving(true); + try { + const result = await setSnapshotIaUrl(snapshotId, iaEditValue.trim() || null); + setSnapshots((prev) => + prev.map((s) => (s.id === snapshotId ? { ...s, ia_url: result.ia_url } : s)) + ); + setIaEditId(null); + toast("IA URL saved", "success"); + } catch (err: any) { + toast(String(err), "error"); + } finally { + setIaSaving(false); + } + } + // Load Snapshot form const [oboUrl, setOboUrl] = useState("http://purl.obolibrary.org/obo/go/go-basic.obo"); const [snapResult, setSnapResult] = useState<{ id: string } | null>(null); @@ -90,8 +114,8 @@ export default function AnnotationsPage() { const s = sets.find((a) => a.id === id); const count = s?.annotation_count ?? 0; const msg = count > 0 - ? `Delete this annotation set and its ${count.toLocaleString()} GO annotations? This cannot be undone.` - : "Delete this annotation set?"; + ? t("setsTab.deleteConfirm", { count: count.toLocaleString() }) + : t("setsTab.deleteConfirmNoAnnotations"); if (!confirm(msg)) return; try { const r = await deleteAnnotationSet(id); @@ -164,31 +188,31 @@ export default function AnnotationsPage() { } const tabs: { key: Tab; label: string }[] = [ - { key: "sets", label: "Annotation Sets" }, - { key: "snapshots", label: "Ontology Snapshots" }, - { key: "load-snapshot", label: "Load Snapshot" }, - { key: "load-goa", label: "Load GOA" }, - { key: "load-quickgo", label: "Load QuickGO" }, + { key: "sets", label: t("tabs.sets") }, + { key: "snapshots", label: t("tabs.snapshots") }, + { key: "load-snapshot", label: t("tabs.loadSnapshot") }, + { key: "load-goa", label: t("tabs.loadGoa") }, + { key: "load-quickgo", label: t("tabs.loadQuickgo") }, ]; return ( <>
-

Annotations

+

{t("title")}

-
- {tabs.map((t) => ( +
+ {tabs.map((tab) => ( ))}
@@ -197,19 +221,19 @@ export default function AnnotationsPage() { {activeTab === "sets" && (
-

{sets.length} annotation set{sets.length !== 1 ? "s" : ""}

+

{t("setsTab.annotationSets", { count: sets.length })}

-
+
-
ID
Source
Version
Annotations
Meta
Created
+
{t("setsTab.tableHeaders.id")}
{t("setsTab.tableHeaders.source")}
{t("setsTab.tableHeaders.version")}
{t("setsTab.tableHeaders.annotations")}
{t("setsTab.tableHeaders.meta")}
{t("setsTab.tableHeaders.created")}
{loadingSets && Array.from({ length: 3 }).map((_, i) => )} {!loadingSets && sets.length === 0 && (
- No annotation sets yet. Load GO annotations from the Load GOA or Load QuickGO tabs. + {t("setsTab.noSetsFound")}
)} {sets.map((a) => ( @@ -236,7 +260,7 @@ export default function AnnotationsPage() { onClick={() => handleDeleteSet(a.id)} className="rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50 transition-colors" > - Delete + {t("setsTab.delete")}
@@ -249,26 +273,70 @@ export default function AnnotationsPage() { {activeTab === "snapshots" && (
-

{snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""}

+

{t("snapshotsTab.snapshots", { count: snapshots.length })}

-
-
-
ID
Version
GO Terms
Loaded
+
+
+
{t("snapshotsTab.tableHeaders.id")}
{t("snapshotsTab.tableHeaders.version")}
{t("snapshotsTab.tableHeaders.goTerms")}
{t("snapshotsTab.tableHeaders.iaUrl")}
{t("snapshotsTab.tableHeaders.loaded")}
- {loadingSnaps && Array.from({ length: 2 }).map((_, i) => )} + {loadingSnaps && Array.from({ length: 2 }).map((_, i) => )} {!loadingSnaps && snapshots.length === 0 && (
- No ontology snapshots yet. Use the Load Snapshot tab. + {t("snapshotsTab.noSnapshotsFound")}
)} {snapshots.map((s) => ( -
+
{shortId(s.id)}
{s.obo_version}
{(s.go_term_count ?? 0).toLocaleString()}
+
+ {iaEditId === s.id ? ( +
+ setIaEditValue(e.target.value)} + placeholder="https://…/IA_cafa6.tsv or file path" + className="flex-1 min-w-0 rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveIa(s.id); + if (e.key === "Escape") setIaEditId(null); + }} + /> + + +
+ ) : ( + + )} +
{formatDate(s.loaded_at)}
))} @@ -278,13 +346,13 @@ export default function AnnotationsPage() { {/* ── Load Ontology Snapshot ── */} {activeTab === "load-snapshot" && ( -
+
-

Load Ontology Snapshot

-

Downloads a GO OBO file and populates GOTerm rows.

+

{t("loadSnapshotTab.title")}

+

{t("loadSnapshotTab.description")}

- +
@@ -313,42 +381,42 @@ export default function AnnotationsPage() { {/* ── Load GOA Annotations ── */} {activeTab === "load-goa" && ( -
+
-

Load GOA Annotations

-

Bulk-loads GO annotations from a GAF file.

+

{t("loadGoaTab.title")}

+

{t("loadGoaTab.description")}

- + {snapshots.length === 0 && ( -

No snapshots — run Load Snapshot first.

+

{t("loadGoaTab.noSnapshots")}

)}
- + setGoaUrl(e.target.value)} required - placeholder="https://current.geneontology.org/annotations/goa_human.gaf.gz" + placeholder={t("loadGoaTab.gafUrlPlaceholder")} className={inputClass} />
- + setGoaVersion(e.target.value)} required - placeholder="2025-03" + placeholder={t("loadGoaTab.sourceVersionPlaceholder")} className={inputClass} />
@@ -362,7 +430,7 @@ export default function AnnotationsPage() { )}
@@ -372,31 +440,31 @@ export default function AnnotationsPage() { {/* ── Load QuickGO Annotations ── */} {activeTab === "load-quickgo" && ( -
+
-

Load QuickGO Annotations

-

Streams GO annotations from the QuickGO bulk download API.

+

{t("loadQuickgoTab.title")}

+

{t("loadQuickgoTab.description")}

- + {snapshots.length === 0 && ( -

No snapshots — run Load Snapshot first.

+

{t("loadQuickgoTab.noSnapshots")}

)}
- + setQgoVersion(e.target.value)} required - placeholder="2025-03" + placeholder={t("loadQuickgoTab.sourceVersionPlaceholder")} className={inputClass} />
@@ -410,7 +478,7 @@ export default function AnnotationsPage() { )}
diff --git a/apps/web/app/embeddings/page.tsx b/apps/web/app/[locale]/embeddings/page.tsx similarity index 80% rename from apps/web/app/embeddings/page.tsx rename to apps/web/app/[locale]/embeddings/page.tsx index 718c4fb..7110cb8 100644 --- a/apps/web/app/embeddings/page.tsx +++ b/apps/web/app/[locale]/embeddings/page.tsx @@ -2,6 +2,18 @@ import { useEffect, useState } from "react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useToast } from "@/components/Toast"; +import { SkeletonTableRow } from "@/components/Skeleton"; +import { + listEmbeddingConfigs, + createEmbeddingConfig, + deleteEmbeddingConfig, + createJob, + listQuerySets, + EmbeddingConfig, + QuerySet, +} from "@/lib/api"; type ModelPreset = { value: string; @@ -31,17 +43,6 @@ const MODEL_PRESETS: Record = { { value: "facebook/esm2_t33_650M_UR50D", label: "ESM-2 650M (auto backend)", layers: 33, defaultMaxLength: 1022 }, ], }; -import { useToast } from "@/components/Toast"; -import { SkeletonTableRow } from "@/components/Skeleton"; -import { - listEmbeddingConfigs, - createEmbeddingConfig, - deleteEmbeddingConfig, - createJob, - listQuerySets, - EmbeddingConfig, - QuerySet, -} from "@/lib/api"; type Tab = "configs" | "compute"; @@ -55,6 +56,7 @@ function shortId(id: string) { } export default function EmbeddingsPage() { + const t = useTranslations("embeddings"); const [activeTab, setActiveTab] = useState("configs"); const toast = useToast(); @@ -67,7 +69,7 @@ export default function EmbeddingsPage() { // New config form state const [showConfigForm, setShowConfigForm] = useState(false); const [cfgBackend, setCfgBackend] = useState("esm"); - const [cfgModelPreset, setCfgModelPreset] = useState(MODEL_PRESETS.esm[3].value); // 650M default + const [cfgModelPreset, setCfgModelPreset] = useState(MODEL_PRESETS.esm[3].value); const [cfgModelCustom, setCfgModelCustom] = useState(""); const cfgModelName = cfgModelPreset === "__custom__" ? cfgModelCustom : cfgModelPreset; const [cfgLayerIndices, setCfgLayerIndices] = useState("0"); @@ -94,7 +96,6 @@ export default function EmbeddingsPage() { const [cmpError, setCmpError] = useState(""); const [cmpSubmitting, setCmpSubmitting] = useState(false); - async function loadAll() { setLoading(true); setError(""); @@ -162,8 +163,8 @@ export default function EmbeddingsPage() { const cfg = configs.find((c) => c.id === id); const count = cfg?.embedding_count ?? 0; const msg = count > 0 - ? `Delete this embedding config and its ${count.toLocaleString()} stored embeddings? This cannot be undone.` - : "Delete this embedding config?"; + ? t("configsTab.deleteConfirm", { count: count.toLocaleString() }) + : t("configsTab.deleteConfirmNoEmbeddings"); if (!confirm(msg)) return; try { await deleteEmbeddingConfig(id); @@ -209,8 +210,8 @@ export default function EmbeddingsPage() { } const tabs: { key: Tab; label: string }[] = [ - { key: "configs", label: "Configs" }, - { key: "compute", label: "Compute" }, + { key: "configs", label: t("tabs.configs") }, + { key: "compute", label: t("tabs.compute") }, ]; const inputClass = @@ -220,7 +221,7 @@ export default function EmbeddingsPage() { return ( <>
-

Embeddings

+

{t("title")}

{error && ( @@ -230,18 +231,18 @@ export default function EmbeddingsPage() { )} {/* Tab bar */} -
- {tabs.map((t) => ( +
+ {tabs.map((tab) => ( ))}
@@ -250,30 +251,27 @@ export default function EmbeddingsPage() { {activeTab === "configs" && (
-

{configs.length} config{configs.length !== 1 ? "s" : ""}

+

{t("configsTab.configs", { count: configs.length })}

{showConfigForm && (
-

New Embedding Config

+

{t("configsTab.newConfigForm.title")}

{/* Layer indexing convention warning */}
- Layer indexing — reverse convention:{" "} - 0 = last (most semantic) layer,{" "} - 1 = penultimate, etc. - This matches PIS / FANTASIA. Use 0 for the standard last-layer embedding. + {t("configsTab.newConfigForm.layerIndexingWarning")}
- +
- + setCfgLayerIndices(e.target.value)} - placeholder="0 or 0,1,2" + placeholder={t("configsTab.newConfigForm.layerIndicesPlaceholder")} required className={inputClass} />
- +
- +
- +
- +
@@ -405,7 +401,7 @@ export default function EmbeddingsPage() { className="rounded" />
@@ -419,13 +415,13 @@ export default function EmbeddingsPage() { className="rounded" />
{cfgUseChunking && ( <>
- +
- + setShowConfigForm(false)} className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50" > - Cancel + {t("configsTab.cancel")}
@@ -475,20 +471,20 @@ export default function EmbeddingsPage() { )} {loading ? ( -
+
{Array.from({ length: 3 }).map((_, i) => )}
) : ( -
+
-
Description
-
Model
-
Backend
-
Layers
-
Agg
-
Pool
-
Norm
-
Created
+
{t("configsTab.tableHeaders.description")}
+
{t("configsTab.tableHeaders.model")}
+
{t("configsTab.tableHeaders.backend")}
+
{t("configsTab.tableHeaders.layers")}
+
{t("configsTab.tableHeaders.agg")}
+
{t("configsTab.tableHeaders.pool")}
+
{t("configsTab.tableHeaders.norm")}
+
{t("configsTab.tableHeaders.created")}
{configs.map((c) => ( @@ -519,9 +515,9 @@ export default function EmbeddingsPage() { ))} {configs.length === 0 && (
- No embedding configs yet.{" "} + {t("configsTab.noConfigs")}{" "}
)} @@ -532,15 +528,15 @@ export default function EmbeddingsPage() { {/* ── Compute Tab ── */} {activeTab === "compute" && ( -
+
-

Compute Embeddings

+

{t("computeTab.title")}

{loading ? ( -

Loading…

+

{t("computeTab.loading")}

) : (
- + setCmpQuerySetId(e.target.value)} className={inputClass} > - + {querySets.map((qs) => (