From 18dc7bc7b1e923d99be1076c4e7c71b538747a79 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Wed, 11 Mar 2026 16:54:50 +1100 Subject: [PATCH 1/8] Added tags to experiments --- alphatrion/server/graphql/types.py | 2 +- alphatrion/storage/sqlstore.py | 22 ++++++++----- dashboard/src/pages/experiments/index.tsx | 39 +++++++++++++++++++---- dashboard/src/types/index.ts | 2 +- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/alphatrion/server/graphql/types.py b/alphatrion/server/graphql/types.py index d65c41cb..6f438f7d 100644 --- a/alphatrion/server/graphql/types.py +++ b/alphatrion/server/graphql/types.py @@ -125,7 +125,7 @@ class GraphQLExperimentType(Enum): @strawberry.type class Label: name: str - value: str + value: str | None @strawberry.type diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py index 1db83359..16449a71 100644 --- a/alphatrion/storage/sqlstore.py +++ b/alphatrion/storage/sqlstore.py @@ -374,21 +374,27 @@ def create_experiment( session.add(new_exp) if labels: - # labels look like "label1:value1,label2:value2", + # labels look like "label1:value1,label2:value2,tag1,tag2" + # entries with ":" or "=" are key-value labels + # entries without a separator are tags (labels with no value) label_pairs = labels.rstrip().split(",") for pair in label_pairs: - if ":" in pair: - label_name, label_value = pair.split(":", 1) - elif "=" in pair: - label_name, label_value = pair.split("=", 1) + pair = pair.strip() + if not pair: + continue + + for sep in (":", "="): + if sep in pair: + label_name, label_value = map(str.strip, pair.split(sep, 1)) + break else: - continue # skip invalid label + label_name, label_value = pair, None exp_label = ExperimentLabel( team_id=team_id, experiment_id=uid, - label_name=label_name.strip(), - label_value=label_value.strip(), + label_name=label_name, + label_value=label_value, ) session.add(exp_label) diff --git a/dashboard/src/pages/experiments/index.tsx b/dashboard/src/pages/experiments/index.tsx index e5adc945..0b716597 100644 --- a/dashboard/src/pages/experiments/index.tsx +++ b/dashboard/src/pages/experiments/index.tsx @@ -132,21 +132,40 @@ export function ExperimentsPage() { return []; } - // Collect labels by key + // Collect labels by key, separating tags (null value) from key-value labels const labelsByKey = new Map>(); + const tagNames = new Set(); experiments.forEach(exp => { exp.labels?.forEach(label => { - if (!labelsByKey.has(label.name)) { - labelsByKey.set(label.name, new Set()); + if (label.value == null) { + tagNames.add(label.name); + } else { + if (!labelsByKey.has(label.name)) { + labelsByKey.set(label.name, new Set()); + } + labelsByKey.get(label.name)!.add(label.value); } - labelsByKey.get(label.name)!.add(label.value); }); }); // Build options grouped by key const options: { value: string; label: string; group: string }[] = []; + // Add tag options (labels with no value) + if (tagNames.size > 0) { + Array.from(tagNames) + .sort() + .forEach(tag => { + options.push({ + value: `${tag}:`, + label: tag, + group: 'Tags' + }); + }); + } + + // Add key-value label options Array.from(labelsByKey.entries()) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) .forEach(([key, values]) => { @@ -188,7 +207,7 @@ export function ExperimentsPage() { exp.id?.toLowerCase().includes(query) || exp.labels?.some(label => label.name.toLowerCase().includes(query) || - label.value.toLowerCase().includes(query) + label.value?.toLowerCase().includes(query) ) ); } @@ -208,6 +227,11 @@ export function ExperimentsPage() { if (labelValue === '*') { // "key:*" means any experiment with this key return exp.labels?.some(label => label.name === labelName); + } else if (labelValue === '') { + // "key:" means tag (label with no value) + return exp.labels?.some(label => + label.name === labelName && label.value == null + ); } else { // "key:value" means exact match return exp.labels?.some(label => @@ -403,13 +427,14 @@ export function ExperimentsPage() {
{experiment.labels.map((label, idx) => { const colors = labelKeyColorMap.get(label.name) || LABEL_COLORS[0]; + const isTag = !label.value; return ( - {label.name}: {label.value} + {isTag ? label.name : `${label.name}: ${label.value}`} ); })} diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 1605574d..8b4a951d 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -45,7 +45,7 @@ export interface User { export interface Label { name: string; - value: string; + value: string | null; } export interface TraceStats { From 6e45d69a9947379d642c36144dfc254671581ad5 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Wed, 11 Mar 2026 16:56:18 +1100 Subject: [PATCH 2/8] Fix typo --- tests/unit/experiment/{test_experimant.py => test_experiment.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/experiment/{test_experimant.py => test_experiment.py} (100%) diff --git a/tests/unit/experiment/test_experimant.py b/tests/unit/experiment/test_experiment.py similarity index 100% rename from tests/unit/experiment/test_experimant.py rename to tests/unit/experiment/test_experiment.py From 73c6678c76f65ed716f6b0e389c1e0acaa34acba Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 10:02:47 +1100 Subject: [PATCH 3/8] Revert back --- alphatrion/server/graphql/types.py | 2 +- alphatrion/storage/sqlstore.py | 22 +++++-------- dashboard/src/pages/experiments/index.tsx | 39 ++++------------------- dashboard/src/types/index.ts | 2 +- 4 files changed, 17 insertions(+), 48 deletions(-) diff --git a/alphatrion/server/graphql/types.py b/alphatrion/server/graphql/types.py index 6f438f7d..d65c41cb 100644 --- a/alphatrion/server/graphql/types.py +++ b/alphatrion/server/graphql/types.py @@ -125,7 +125,7 @@ class GraphQLExperimentType(Enum): @strawberry.type class Label: name: str - value: str | None + value: str @strawberry.type diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py index 16449a71..1db83359 100644 --- a/alphatrion/storage/sqlstore.py +++ b/alphatrion/storage/sqlstore.py @@ -374,27 +374,21 @@ def create_experiment( session.add(new_exp) if labels: - # labels look like "label1:value1,label2:value2,tag1,tag2" - # entries with ":" or "=" are key-value labels - # entries without a separator are tags (labels with no value) + # labels look like "label1:value1,label2:value2", label_pairs = labels.rstrip().split(",") for pair in label_pairs: - pair = pair.strip() - if not pair: - continue - - for sep in (":", "="): - if sep in pair: - label_name, label_value = map(str.strip, pair.split(sep, 1)) - break + if ":" in pair: + label_name, label_value = pair.split(":", 1) + elif "=" in pair: + label_name, label_value = pair.split("=", 1) else: - label_name, label_value = pair, None + continue # skip invalid label exp_label = ExperimentLabel( team_id=team_id, experiment_id=uid, - label_name=label_name, - label_value=label_value, + label_name=label_name.strip(), + label_value=label_value.strip(), ) session.add(exp_label) diff --git a/dashboard/src/pages/experiments/index.tsx b/dashboard/src/pages/experiments/index.tsx index 0b716597..e5adc945 100644 --- a/dashboard/src/pages/experiments/index.tsx +++ b/dashboard/src/pages/experiments/index.tsx @@ -132,40 +132,21 @@ export function ExperimentsPage() { return []; } - // Collect labels by key, separating tags (null value) from key-value labels + // Collect labels by key const labelsByKey = new Map>(); - const tagNames = new Set(); experiments.forEach(exp => { exp.labels?.forEach(label => { - if (label.value == null) { - tagNames.add(label.name); - } else { - if (!labelsByKey.has(label.name)) { - labelsByKey.set(label.name, new Set()); - } - labelsByKey.get(label.name)!.add(label.value); + if (!labelsByKey.has(label.name)) { + labelsByKey.set(label.name, new Set()); } + labelsByKey.get(label.name)!.add(label.value); }); }); // Build options grouped by key const options: { value: string; label: string; group: string }[] = []; - // Add tag options (labels with no value) - if (tagNames.size > 0) { - Array.from(tagNames) - .sort() - .forEach(tag => { - options.push({ - value: `${tag}:`, - label: tag, - group: 'Tags' - }); - }); - } - - // Add key-value label options Array.from(labelsByKey.entries()) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) .forEach(([key, values]) => { @@ -207,7 +188,7 @@ export function ExperimentsPage() { exp.id?.toLowerCase().includes(query) || exp.labels?.some(label => label.name.toLowerCase().includes(query) || - label.value?.toLowerCase().includes(query) + label.value.toLowerCase().includes(query) ) ); } @@ -227,11 +208,6 @@ export function ExperimentsPage() { if (labelValue === '*') { // "key:*" means any experiment with this key return exp.labels?.some(label => label.name === labelName); - } else if (labelValue === '') { - // "key:" means tag (label with no value) - return exp.labels?.some(label => - label.name === labelName && label.value == null - ); } else { // "key:value" means exact match return exp.labels?.some(label => @@ -427,14 +403,13 @@ export function ExperimentsPage() {
{experiment.labels.map((label, idx) => { const colors = labelKeyColorMap.get(label.name) || LABEL_COLORS[0]; - const isTag = !label.value; return ( - {isTag ? label.name : `${label.name}: ${label.value}`} + {label.name}: {label.value} ); })} diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 8b4a951d..1605574d 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -45,7 +45,7 @@ export interface User { export interface Label { name: string; - value: string | null; + value: string; } export interface TraceStats { From 0b679c6818b66cb12ad8e32f0abaab04b8189712 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 10:56:16 +1100 Subject: [PATCH 4/8] Add tag as new field --- alphatrion/experiment/base.py | 2 + alphatrion/experiment/craft_experiment.py | 2 + alphatrion/server/graphql/resolvers.py | 18 +++++++- alphatrion/server/graphql/schema.py | 2 + alphatrion/server/graphql/types.py | 6 +++ alphatrion/storage/metastore.py | 1 + alphatrion/storage/sql_models.py | 16 +++++++ alphatrion/storage/sqlstore.py | 55 +++++++++++++++++++++++ tests/unit/experiment/test_experiment.py | 24 ++++++++++ 9 files changed, 125 insertions(+), 1 deletion(-) diff --git a/alphatrion/experiment/base.py b/alphatrion/experiment/base.py index ec11e5f7..f7fe68bc 100644 --- a/alphatrion/experiment/base.py +++ b/alphatrion/experiment/base.py @@ -206,6 +206,7 @@ def _start( name: str, description: str | None = None, labels: str | None = None, + tags: list[str] | None = None, meta: dict | None = None, params: dict | None = None, ): @@ -244,6 +245,7 @@ def _start( user_id=self._runtime._user_id, description=description, labels=labels, + tags=tags, meta=meta, params=params, status=Status.RUNNING, diff --git a/alphatrion/experiment/craft_experiment.py b/alphatrion/experiment/craft_experiment.py index 84c9fd7e..d84aca71 100644 --- a/alphatrion/experiment/craft_experiment.py +++ b/alphatrion/experiment/craft_experiment.py @@ -15,6 +15,7 @@ def start( name: str, description: str | None = None, labels: str | None = None, + tags: list[str] | None = None, meta: dict | None = None, params: dict | None = None, config: base.ExperimentConfig | None = None, @@ -29,6 +30,7 @@ def start( name=name, description=description, labels=labels, + tags=tags, meta=meta, params=params, ) diff --git a/alphatrion/server/graphql/resolvers.py b/alphatrion/server/graphql/resolvers.py index 1850a939..f77c593c 100644 --- a/alphatrion/server/graphql/resolvers.py +++ b/alphatrion/server/graphql/resolvers.py @@ -101,6 +101,12 @@ def list_labels_by_exp_id(experiment_id: strawberry.ID) -> list[Label]: for label in labels ] + @staticmethod + def list_tags_by_exp_id(experiment_id: strawberry.ID) -> list[str]: + metadb = runtime.storage_runtime().metadb + tags = metadb.list_tags_by_exp_id(experiment_id=experiment_id) + return [t.tag for t in tags] + @staticmethod def list_experiments( team_id: strawberry.ID, @@ -110,9 +116,19 @@ def list_experiments( order_desc: bool = True, label_name: str | None = None, label_value: str | None = None, + tag: str | None = None, ) -> list[Experiment]: metadb = runtime.storage_runtime().metadb - if label_name: + if tag: + exps = metadb.list_exps_by_tag( + team_id=uuid.UUID(team_id), + tag=tag, + page=page, + page_size=page_size, + order_by=order_by, + order_desc=order_desc, + ) + elif label_name: exps = metadb.list_exps_by_label( team_id=uuid.UUID(team_id), label_name=label_name, diff --git a/alphatrion/server/graphql/schema.py b/alphatrion/server/graphql/schema.py index 8c1cad47..d7c25d49 100644 --- a/alphatrion/server/graphql/schema.py +++ b/alphatrion/server/graphql/schema.py @@ -38,6 +38,7 @@ def experiments( order_desc: bool = True, label_name: str | None = None, label_value: str | None = None, + tag: str | None = None, ) -> list[Experiment]: return GraphQLResolvers.list_experiments( team_id=team_id, @@ -47,6 +48,7 @@ def experiments( order_desc=order_desc, label_name=label_name, label_value=label_value, + tag=tag, ) experiment: Experiment | None = strawberry.field( diff --git a/alphatrion/server/graphql/types.py b/alphatrion/server/graphql/types.py index d65c41cb..fb9b5521 100644 --- a/alphatrion/server/graphql/types.py +++ b/alphatrion/server/graphql/types.py @@ -150,6 +150,12 @@ def labels(self) -> list[Label]: return GraphQLResolvers.list_labels_by_exp_id(experiment_id=self.id) + @strawberry.field + def tags(self) -> list[str]: + from .resolvers import GraphQLResolvers + + return GraphQLResolvers.list_tags_by_exp_id(experiment_id=self.id) + @strawberry.field def metrics(self) -> list["Metric"]: from .resolvers import GraphQLResolvers diff --git a/alphatrion/storage/metastore.py b/alphatrion/storage/metastore.py index f0c9d751..785adf32 100644 --- a/alphatrion/storage/metastore.py +++ b/alphatrion/storage/metastore.py @@ -73,6 +73,7 @@ def create_experiment( name: str, description: str | None = None, labels: str | None = None, + tags: list[str] | None = None, meta: dict | None = None, params: dict | None = None, ) -> int: diff --git a/alphatrion/storage/sql_models.py b/alphatrion/storage/sql_models.py index 9ede15fe..8bd2f4ff 100644 --- a/alphatrion/storage/sql_models.py +++ b/alphatrion/storage/sql_models.py @@ -303,6 +303,22 @@ class ExperimentLabel(Base): ) +class ExperimentTag(Base): + __tablename__ = "experiment_tags" + + uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + team_id = Column(UUID(as_uuid=True), nullable=False) + experiment_id = Column(UUID(as_uuid=True), nullable=False) + tag = Column(String, nullable=False) + + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + + __table_args__ = ( + Index("idx_experiment_tag_lookup", "experiment_id", "tag"), + Index("idx_experiment_tag_team", "team_id", "tag"), + ) + + class Dataset(Base): __tablename__ = "datasets" diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py index 1db83359..da91cb7e 100644 --- a/alphatrion/storage/sqlstore.py +++ b/alphatrion/storage/sqlstore.py @@ -10,6 +10,7 @@ Dataset, Experiment, ExperimentLabel, + ExperimentTag, Metric, Run, Status, @@ -340,6 +341,7 @@ def create_experiment( user_id: uuid.UUID, description: str | None = None, labels: str | None = None, + tags: list[str] | None = None, meta: dict | None = None, params: dict | None = None, status: Status = Status.PENDING, @@ -392,6 +394,16 @@ def create_experiment( ) session.add(exp_label) + if tags: + for tag in tags: + if tag.strip(): + exp_tag = ExperimentTag( + team_id=team_id, + experiment_id=uid, + tag=tag.strip(), + ) + session.add(exp_tag) + session.commit() exp_id = new_exp.uuid @@ -501,6 +513,49 @@ def list_exps_by_label( session.close() return exps + def list_tags_by_exp_id(self, experiment_id: uuid.UUID) -> list[ExperimentTag]: + session = self._session() + tags = ( + session.query(ExperimentTag) + .filter(ExperimentTag.experiment_id == experiment_id) + .order_by(ExperimentTag.created_at.asc()) + .all() + ) + session.close() + return tags + + def list_exps_by_tag( + self, + team_id: uuid.UUID, + tag: str, + page: int = 0, + page_size: int = 10, + order_by: str = "created_at", + order_desc: bool = True, + ) -> list[Experiment]: + session = self._session() + query = ( + session.query(Experiment) + .join(ExperimentTag, ExperimentTag.experiment_id == Experiment.uuid) + .filter( + Experiment.team_id == team_id, + Experiment.is_del == 0, + ExperimentTag.tag == tag, + ) + ) + exps = ( + query.order_by( + getattr(Experiment, order_by).desc() + if order_desc + else getattr(Experiment, order_by) + ) + .offset(page * page_size) + .limit(page_size) + .all() + ) + session.close() + return exps + def update_experiment(self, experiment_id: uuid.UUID, **kwargs) -> None: session = self._session() exp = ( diff --git a/tests/unit/experiment/test_experiment.py b/tests/unit/experiment/test_experiment.py index fe075d2d..aac6abad 100644 --- a/tests/unit/experiment/test_experiment.py +++ b/tests/unit/experiment/test_experiment.py @@ -370,3 +370,27 @@ async def test_experiment_with_labels(): ) assert len(exp_labels) == 1 + + +@pytest.mark.asyncio +async def test_experiment_with_labels(): + team_id = uuid.uuid4() + user_id = uuid.uuid4() + init( + team_id=team_id, + user_id=user_id, + ) + + async with CraftExperiment.start( + name="first-experiment", + tags=["foo", "bar"], + ) as exp: + exp_obj = exp._get_obj() + assert exp_obj is not None + + exp_tags = exp._runtime.metadb.list_exps_by_tag( + team_id=team_id, + tag="foo", + ) + + assert len(exp_tags) == 1 From 0e5928e1403987ec4b98790d1b18d1f39dbaad08 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 11:46:09 +1100 Subject: [PATCH 5/8] Update dashboard with tags --- dashboard/src/lib/graphql-client.ts | 6 +- dashboard/src/pages/experiments/index.tsx | 70 +++++++++++++++++++++-- dashboard/src/types/index.ts | 1 + 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/dashboard/src/lib/graphql-client.ts b/dashboard/src/lib/graphql-client.ts index 3a892395..6e1fe4b4 100644 --- a/dashboard/src/lib/graphql-client.ts +++ b/dashboard/src/lib/graphql-client.ts @@ -148,8 +148,8 @@ export const queries = { `, listExperiments: ` - query ListExperiments($teamId: ID!, $labelName: String, $labelValue: String, $page: Int, $pageSize: Int) { - experiments(teamId: $teamId, labelName: $labelName, labelValue: $labelValue, page: $page, pageSize: $pageSize) { + query ListExperiments($teamId: ID!, $labelName: String, $labelValue: String, $tag: String, $page: Int, $pageSize: Int) { + experiments(teamId: $teamId, labelName: $labelName, labelValue: $labelValue, tag: $tag, page: $page, pageSize: $pageSize) { id teamId userId @@ -162,6 +162,7 @@ export const queries = { name value } + tags duration status createdAt @@ -185,6 +186,7 @@ export const queries = { name value } + tags duration status createdAt diff --git a/dashboard/src/pages/experiments/index.tsx b/dashboard/src/pages/experiments/index.tsx index e5adc945..3232f8cf 100644 --- a/dashboard/src/pages/experiments/index.tsx +++ b/dashboard/src/pages/experiments/index.tsx @@ -78,12 +78,15 @@ const LABEL_COLORS = [ { bg: 'bg-stone-100', text: 'text-stone-700', border: 'border-stone-300' }, ]; +const TAG_COLOR = { bg: 'bg-stone-100', text: 'text-stone-700', arrow: 'border-r-stone-100' }; + const PAGE_SIZE = 10; export function ExperimentsPage() { const { selectedTeamId } = useTeamContext(); const [statusFilter, setStatusFilter] = useState('ALL'); const [labelFilters, setLabelFilters] = useState([]); + const [tagFilters, setTagFilters] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(0); const [selectedExperiments, setSelectedExperiments] = useState>(new Set()); @@ -172,6 +175,22 @@ export function ExperimentsPage() { return options; }, [experiments]); + // Build tag options from all experiments + const tagOptions = useMemo(() => { + if (!experiments || experiments.length === 0) return []; + + const uniqueTags = new Set(); + experiments.forEach(exp => { + exp.tags?.forEach(tag => uniqueTags.add(tag)); + }); + + return Array.from(uniqueTags).sort().map(tag => ({ + value: tag, + label: tag, + group: 'Tags', + })); + }, [experiments]); + // Filter and sort experiments const filteredExperiments = useMemo(() => { if (!experiments) return []; @@ -189,7 +208,8 @@ export function ExperimentsPage() { exp.labels?.some(label => label.name.toLowerCase().includes(query) || label.value.toLowerCase().includes(query) - ) + ) || + exp.tags?.some(tag => tag.toLowerCase().includes(query)) ); } @@ -218,11 +238,18 @@ export function ExperimentsPage() { }); } + // Apply tag filters (AND logic - experiment must have ALL selected tags) + if (tagFilters.length > 0) { + filtered = filtered.filter(exp => + tagFilters.every(tag => exp.tags?.includes(tag)) + ); + } + // Sort by creation time descending (newest first) filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return filtered; - }, [experiments, statusFilter, labelFilters, searchQuery]); + }, [experiments, statusFilter, labelFilters, tagFilters, searchQuery]); // Check if all filtered experiments are selected const allSelected = filteredExperiments.length > 0 && @@ -301,10 +328,21 @@ export function ExperimentsPage() { values={labelFilters} onChange={(values) => setLabelFilters(values)} options={labelOptions} - className="w-64" + className="w-48" placeholder="Filter by labels..." /> + {/* Tag Filter */} + {tagOptions.length > 0 && ( + setTagFilters(values)} + options={tagOptions} + className="w-48" + placeholder="Filter by tags..." + /> + )} + {/* Status Filter */}
- {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 ? ( + {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 || tagFilters.length > 0 ? ( ) : ( )}

- {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 + {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 || tagFilters.length > 0 ? 'No experiments match your filters' : 'No experiments found'}

- {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 + {searchQuery.trim() || statusFilter !== 'ALL' || labelFilters.length > 0 || tagFilters.length > 0 ? 'Try adjusting your filters or search query' : 'Experiments will appear here once created'}

@@ -369,6 +407,7 @@ export function ExperimentsPage() { Name Labels + Tags Status Created @@ -418,6 +457,25 @@ export function ExperimentsPage() { - )} + + {experiment.tags && experiment.tags.length > 0 ? ( +
+ {experiment.tags.map((tag, idx) => ( + + + + {tag} + + + ))} +
+ ) : ( + - + )} +
{experiment.status} diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 1605574d..20b15783 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -64,6 +64,7 @@ export interface Experiment { meta: Record | null; params: Record | null; labels: Label[]; + tags: string[]; duration: number; status: Status; createdAt: string; From b5b68472e2544c75b5931f17e41a7aa64f9a88a9 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 22:49:00 +1100 Subject: [PATCH 6/8] Fixed most comments --- alphatrion/storage/sql_models.py | 5 +++++ alphatrion/storage/sqlstore.py | 8 ++++---- tests/unit/experiment/test_experiment.py | 7 ++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/alphatrion/storage/sql_models.py b/alphatrion/storage/sql_models.py index 8bd2f4ff..5c0d8597 100644 --- a/alphatrion/storage/sql_models.py +++ b/alphatrion/storage/sql_models.py @@ -312,6 +312,11 @@ class ExperimentTag(Base): tag = Column(String, nullable=False) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) __table_args__ = ( Index("idx_experiment_tag_lookup", "experiment_id", "tag"), diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py index da91cb7e..3b9d772e 100644 --- a/alphatrion/storage/sqlstore.py +++ b/alphatrion/storage/sqlstore.py @@ -395,12 +395,12 @@ def create_experiment( session.add(exp_label) if tags: - for tag in tags: - if tag.strip(): + for tag in [t.strip() for t in tags]: + if tag: exp_tag = ExperimentTag( team_id=team_id, experiment_id=uid, - tag=tag.strip(), + tag=tag, ) session.add(exp_tag) @@ -539,8 +539,8 @@ def list_exps_by_tag( .join(ExperimentTag, ExperimentTag.experiment_id == Experiment.uuid) .filter( Experiment.team_id == team_id, - Experiment.is_del == 0, ExperimentTag.tag == tag, + Experiment.is_del == 0, ) ) exps = ( diff --git a/tests/unit/experiment/test_experiment.py b/tests/unit/experiment/test_experiment.py index aac6abad..c501d3bc 100644 --- a/tests/unit/experiment/test_experiment.py +++ b/tests/unit/experiment/test_experiment.py @@ -373,7 +373,7 @@ async def test_experiment_with_labels(): @pytest.mark.asyncio -async def test_experiment_with_labels(): +async def test_experiment_with_tags(): team_id = uuid.uuid4() user_id = uuid.uuid4() init( @@ -394,3 +394,8 @@ async def test_experiment_with_labels(): ) assert len(exp_tags) == 1 + + all_tags = exp._runtime.metadb.list_tags_by_exp_id( + experiment_id=exp.id, + ) + assert len(all_tags) == 2 From 3eef41cc7d38f8ba6e213a9876a6e8b0e5df4539 Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 23:17:31 +1100 Subject: [PATCH 7/8] Fix comments --- alphatrion/server/graphql/resolvers.py | 37 +++------- alphatrion/storage/sqlstore.py | 94 ++++++------------------ tests/unit/experiment/test_experiment.py | 4 +- 3 files changed, 34 insertions(+), 101 deletions(-) diff --git a/alphatrion/server/graphql/resolvers.py b/alphatrion/server/graphql/resolvers.py index f77c593c..f7c8d8cd 100644 --- a/alphatrion/server/graphql/resolvers.py +++ b/alphatrion/server/graphql/resolvers.py @@ -119,33 +119,16 @@ def list_experiments( tag: str | None = None, ) -> list[Experiment]: metadb = runtime.storage_runtime().metadb - if tag: - exps = metadb.list_exps_by_tag( - team_id=uuid.UUID(team_id), - tag=tag, - page=page, - page_size=page_size, - order_by=order_by, - order_desc=order_desc, - ) - elif label_name: - exps = metadb.list_exps_by_label( - team_id=uuid.UUID(team_id), - label_name=label_name, - label_value=label_value, - page=page, - page_size=page_size, - order_by=order_by, - order_desc=order_desc, - ) - else: - exps = metadb.list_exps_by_team_id( - team_id=uuid.UUID(team_id), - page=page, - page_size=page_size, - order_by=order_by, - order_desc=order_desc, - ) + exps = metadb.list_experiments( + team_id=uuid.UUID(team_id), + label_name=label_name, + label_value=label_value, + tag=tag, + page=page, + page_size=page_size, + order_by=order_by, + order_desc=order_desc, + ) return [ Experiment( diff --git a/alphatrion/storage/sqlstore.py b/alphatrion/storage/sqlstore.py index 3b9d772e..2c9648ce 100644 --- a/alphatrion/storage/sqlstore.py +++ b/alphatrion/storage/sqlstore.py @@ -442,19 +442,37 @@ def get_exp_by_name( session.close() return trial - def list_exps_by_team_id( + def list_experiments( self, team_id: uuid.UUID, + label_name: str | None = None, + label_value: str | None = None, + tag: str | None = None, page: int = 0, page_size: int = 10, order_by: str = "created_at", order_desc: bool = True, ) -> list[Experiment]: session = self._session() + query = session.query(Experiment).filter( + Experiment.team_id == team_id, + Experiment.is_del == 0, + ) + + if label_name: + query = query.join( + ExperimentLabel, ExperimentLabel.experiment_id == Experiment.uuid + ).filter(ExperimentLabel.label_name == label_name) + if label_value is not None: + query = query.filter(ExperimentLabel.label_value == label_value) + + if tag: + query = query.join( + ExperimentTag, ExperimentTag.experiment_id == Experiment.uuid + ).filter(ExperimentTag.tag == tag) + exps = ( - session.query(Experiment) - .filter(Experiment.team_id == team_id, Experiment.is_del == 0) - .order_by( + query.order_by( getattr(Experiment, order_by).desc() if order_desc else getattr(Experiment, order_by) @@ -477,42 +495,6 @@ def list_labels_by_exp_id(self, experiment_id: uuid.UUID) -> list[ExperimentLabe session.close() return labels - def list_exps_by_label( - self, - team_id: uuid.UUID, - label_name: str, - label_value: str | None = None, - page: int = 0, - page_size: int = 10, - order_by: str = "created_at", - order_desc: bool = True, - ) -> list[Experiment]: - session = self._session() - query = ( - session.query(Experiment) - .join(ExperimentLabel, ExperimentLabel.experiment_id == Experiment.uuid) - .filter( - Experiment.team_id == team_id, - Experiment.is_del == 0, - ExperimentLabel.label_name == label_name, - ) - ) - if label_value is not None: - query = query.filter(ExperimentLabel.label_value == label_value) - - exps = ( - query.order_by( - getattr(Experiment, order_by).desc() - if order_desc - else getattr(Experiment, order_by) - ) - .offset(page * page_size) - .limit(page_size) - .all() - ) - session.close() - return exps - def list_tags_by_exp_id(self, experiment_id: uuid.UUID) -> list[ExperimentTag]: session = self._session() tags = ( @@ -524,38 +506,6 @@ def list_tags_by_exp_id(self, experiment_id: uuid.UUID) -> list[ExperimentTag]: session.close() return tags - def list_exps_by_tag( - self, - team_id: uuid.UUID, - tag: str, - page: int = 0, - page_size: int = 10, - order_by: str = "created_at", - order_desc: bool = True, - ) -> list[Experiment]: - session = self._session() - query = ( - session.query(Experiment) - .join(ExperimentTag, ExperimentTag.experiment_id == Experiment.uuid) - .filter( - Experiment.team_id == team_id, - ExperimentTag.tag == tag, - Experiment.is_del == 0, - ) - ) - exps = ( - query.order_by( - getattr(Experiment, order_by).desc() - if order_desc - else getattr(Experiment, order_by) - ) - .offset(page * page_size) - .limit(page_size) - .all() - ) - session.close() - return exps - def update_experiment(self, experiment_id: uuid.UUID, **kwargs) -> None: session = self._session() exp = ( diff --git a/tests/unit/experiment/test_experiment.py b/tests/unit/experiment/test_experiment.py index c501d3bc..8047f887 100644 --- a/tests/unit/experiment/test_experiment.py +++ b/tests/unit/experiment/test_experiment.py @@ -363,7 +363,7 @@ async def test_experiment_with_labels(): exp_obj = exp._get_obj() assert exp_obj is not None - exp_labels = exp._runtime.metadb.list_exps_by_label( + exp_labels = exp._runtime.metadb.list_experiments( team_id=team_id, label_name="foo", label_value="bar", @@ -388,7 +388,7 @@ async def test_experiment_with_tags(): exp_obj = exp._get_obj() assert exp_obj is not None - exp_tags = exp._runtime.metadb.list_exps_by_tag( + exp_tags = exp._runtime.metadb.list_experiments( team_id=team_id, tag="foo", ) From a35b8221b2d0dd07a03e35ceb98ce9564ce6860b Mon Sep 17 00:00:00 2001 From: Kerry He Date: Thu, 12 Mar 2026 23:52:05 +1100 Subject: [PATCH 8/8] Add alembic --- .../faa471d8accb_add_new_table_tags.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 migrations/versions/faa471d8accb_add_new_table_tags.py diff --git a/migrations/versions/faa471d8accb_add_new_table_tags.py b/migrations/versions/faa471d8accb_add_new_table_tags.py new file mode 100644 index 00000000..e08329e6 --- /dev/null +++ b/migrations/versions/faa471d8accb_add_new_table_tags.py @@ -0,0 +1,34 @@ +"""add new table tags + +Revision ID: faa471d8accb +Revises: 467107424ef6 +Create Date: 2026-03-12 23:51:17.473606 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'faa471d8accb' +down_revision: Union[str, Sequence[str], None] = '467107424ef6' +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.drop_constraint(op.f('unique_experiment_tag'), 'experiment_tags', type_='unique') + op.create_index('idx_experiment_tag_lookup', 'experiment_tags', ['experiment_id', 'tag'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_experiment_tag_lookup', table_name='experiment_tags') + op.create_unique_constraint(op.f('unique_experiment_tag'), 'experiment_tags', ['experiment_id', 'tag'], postgresql_nulls_not_distinct=False) + # ### end Alembic commands ###