Skip to content

Commit 807df32

Browse files
authored
Merge pull request #278 from xmican10/LEADS-488-multiple-tags-support
[LEADS-488] Adding support for multiple tags
2 parents 87492de + c029567 commit 807df32

14 files changed

Lines changed: 191 additions & 46 deletions

File tree

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ storage:
459459
| CSV column name | Description |
460460
|-----------------|-------------|
461461
| conversation_group_id | Conversation group id |
462-
| tag | Tag for grouping eval conversations |
462+
| tag | Tag(s) for grouping and filtering conversations (string or list of strings; stored as JSON array) |
463463
| turn_id | Turn id |
464464
| metric_identifier | Metric name |
465465
| result | Result -- PASS/FAIL/ERROR/SKIPPED |

src/lightspeed_evaluation/core/models/data.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15+
def _normalize_tag(v: str | list) -> set[str]:
16+
"""Normalize tag to set[str] for backward compatibility with single string."""
17+
if isinstance(v, str):
18+
items: list = [v]
19+
else:
20+
items = list(v)
21+
invalid = [item for item in items if not isinstance(item, str)]
22+
if invalid:
23+
raise ValueError(f"tag items must be strings, got: {invalid}")
24+
result = {item.strip() for item in items if item.strip()}
25+
if not result:
26+
raise ValueError("tag must contain at least one non-empty string")
27+
return result
28+
29+
1530
class ConversationMetadata(BaseModel):
1631
"""Optional user-defined metadata for a conversation group."""
1732

@@ -520,10 +535,10 @@ class EvaluationData(BaseModel):
520535
min_length=1,
521536
description="Optional description of the conversation group",
522537
)
523-
tag: str = Field(
524-
default="eval",
538+
tag: set[str] = Field(
539+
default={"eval"},
525540
min_length=1,
526-
description="Tag for grouping and filtering conversations",
541+
description="Tag(s) for grouping and filtering conversations",
527542
)
528543
skip: bool = Field(
529544
default=False,
@@ -586,6 +601,12 @@ def is_metric_invalid(self, metric: str) -> bool:
586601
"""Returns True if the metric didn't pass the validation."""
587602
return metric in self._invalid_metrics
588603

604+
@field_validator("tag", mode="before")
605+
@classmethod
606+
def _validate_tag(cls, v: str | list) -> set[str]:
607+
"""Normalize tag to set[str] for backward compatibility with single string."""
608+
return _normalize_tag(v)
609+
589610
@field_validator("conversation_metrics")
590611
@classmethod
591612
def validate_conversation_metrics(
@@ -659,10 +680,10 @@ class EvaluationResult(MetricResult, StreamingMetricsMixin):
659680
conversation_group_id: str = Field(
660681
..., min_length=1, description="Conversation group identifier"
661682
)
662-
tag: str = Field(
663-
default="eval",
683+
tag: set[str] = Field(
684+
default={"eval"},
664685
min_length=1,
665-
description="Tag for grouping and filtering results",
686+
description="Tag(s) for grouping and filtering results",
666687
)
667688
turn_id: Optional[str] = Field(
668689
default=None, description="Turn ID if turn-level evaluation"
@@ -714,6 +735,12 @@ class EvaluationResult(MetricResult, StreamingMetricsMixin):
714735
default=None, description="Expected tool calls formatted as string"
715736
)
716737

738+
@field_validator("tag", mode="before")
739+
@classmethod
740+
def _validate_tag(cls, v: str | list) -> set[str]:
741+
"""Normalize tag to set[str] for backward compatibility with single string."""
742+
return _normalize_tag(v)
743+
717744

718745
class EvaluationScope(BaseModel):
719746
"""Scope and parameters for metric evaluation."""

src/lightspeed_evaluation/core/output/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def result_to_json_dict(r: EvaluationResult) -> dict[str, Any]:
2727
"""
2828
return {
2929
"conversation_group_id": r.conversation_group_id,
30-
"tag": r.tag,
30+
"tag": sorted(r.tag),
3131
"turn_id": r.turn_id,
3232
"metric_identifier": r.metric_identifier,
3333
"result": r.result,

src/lightspeed_evaluation/core/output/statistics.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ def compute_tag_stats(
222222

223223
grouped: dict[str, list[EvaluationResult]] = {}
224224
for r in results:
225-
grouped.setdefault(r.tag, []).append(r)
225+
for t in r.tag:
226+
grouped.setdefault(t, []).append(r)
226227

227228
tag_stats: dict[str, TagStats] = {}
228229
for tag in sorted(grouped):

src/lightspeed_evaluation/core/storage/langfuse_storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from __future__ import annotations
2424

25-
import importlib
25+
import importlib.util
2626
import logging
2727
from typing import Any, Optional
2828

src/lightspeed_evaluation/core/storage/sql_storage.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class EvaluationResultDB(Base): # pylint: disable=too-few-public-methods
4747
timestamp = Column(DateTime, nullable=False, index=True)
4848

4949
conversation_group_id = Column(String(255), nullable=False, index=True)
50-
tag = Column(String(100), nullable=True)
50+
tag = Column(Text, nullable=True)
5151
turn_id = Column(String(100), nullable=True)
5252
metric_identifier = Column(String(255), nullable=False, index=True)
5353
metric_metadata = Column(Text, nullable=True)
@@ -309,7 +309,7 @@ def _result_to_db_record(self, result: EvaluationResult) -> EvaluationResultDB:
309309
run_id=self._run_info.run_id,
310310
timestamp=datetime.now(UTC),
311311
conversation_group_id=result.conversation_group_id,
312-
tag=result.tag,
312+
tag=json.dumps(sorted(result.tag)),
313313
turn_id=result.turn_id,
314314
metric_identifier=result.metric_identifier,
315315
metric_metadata=result.metric_metadata,

src/lightspeed_evaluation/core/system/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ def _filter_by_scope(
408408
filtered = [
409409
conv_data
410410
for conv_data in evaluation_data
411-
if conv_data.tag in tag_set
411+
if any(t in tag_set for t in conv_data.tag)
412412
or conv_data.conversation_group_id in conv_id_set
413413
]
414414

src/lightspeed_evaluation/pipeline/evaluation/errors.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def _create_result( # pylint: disable=too-many-arguments,too-many-positional-ar
2222
reason: str,
2323
result_status: str,
2424
*,
25-
tag: str = "eval",
25+
tag: set[str],
2626
turn_id: Optional[str] = None,
2727
query: str = "",
2828
) -> EvaluationResult:
@@ -33,7 +33,7 @@ def _create_result( # pylint: disable=too-many-arguments,too-many-positional-ar
3333
metric_id: Metric identifier
3434
reason: Reason for the result
3535
result_status: Result status (ERROR, SKIPPED, etc.)
36-
tag: Tag for grouping and filtering results
36+
tag: Tag(s) for grouping and filtering results
3737
turn_id: Turn ID (None for conversation-level)
3838
query: Query text
3939
"""
@@ -53,7 +53,7 @@ def create_error_result( # pylint: disable=too-many-arguments,too-many-position
5353
metric_id: str,
5454
reason: str,
5555
*,
56-
tag: str = "eval",
56+
tag: set[str],
5757
turn_id: Optional[str] = None,
5858
query: str = "",
5959
) -> EvaluationResult:
@@ -63,7 +63,7 @@ def create_error_result( # pylint: disable=too-many-arguments,too-many-position
6363
conv_id: Conversation group ID
6464
metric_id: Metric identifier
6565
reason: Error reason
66-
tag: Tag for grouping and filtering results
66+
tag: Tag(s) for grouping and filtering results
6767
turn_id: Turn ID (None for conversation-level)
6868
query: Query text
6969
"""
@@ -77,7 +77,7 @@ def create_skipped_result( # pylint: disable=too-many-arguments,too-many-positi
7777
metric_id: str,
7878
reason: str,
7979
*,
80-
tag: str = "eval",
80+
tag: set[str],
8181
turn_id: Optional[str] = None,
8282
query: str = "",
8383
) -> EvaluationResult:
@@ -87,7 +87,7 @@ def create_skipped_result( # pylint: disable=too-many-arguments,too-many-positi
8787
conv_id: Conversation group ID
8888
metric_id: Metric identifier
8989
reason: Skip reason
90-
tag: Tag for grouping and filtering results
90+
tag: Tag(s) for grouping and filtering results
9191
turn_id: Turn ID (None for conversation-level)
9292
query: Query text
9393
"""

src/lightspeed_evaluation/pipeline/evaluation/processor.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def _evaluate_turn(
283283
conv_data.conversation_group_id,
284284
metric_identifier,
285285
error_reason,
286+
tag=conv_data.tag,
286287
turn_id=turn_data.turn_id,
287288
query=turn_data.query or "",
288289
)
@@ -311,7 +312,10 @@ def _evaluate_conversation(
311312
logger.error(error_reason)
312313
results.append(
313314
self.components.error_handler.create_error_result(
314-
conv_data.conversation_group_id, metric_identifier, error_reason
315+
conv_data.conversation_group_id,
316+
metric_identifier,
317+
error_reason,
318+
tag=conv_data.tag,
315319
)
316320
)
317321
continue

tests/unit/core/models/test_data.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -436,12 +436,12 @@ def test_valid_creation(self) -> None:
436436
conversation_group_id="conv1",
437437
turns=turns,
438438
description="Test conversation",
439-
tag="test_tag",
439+
tag={"test_tag"},
440440
conversation_metrics=["deepeval:conversation_completeness"],
441441
)
442442

443443
assert eval_data.conversation_group_id == "conv1"
444-
assert eval_data.tag == "test_tag"
444+
assert eval_data.tag == {"test_tag"}
445445
assert len(eval_data.turns) == 2
446446
assert eval_data.description == "Test conversation"
447447
assert eval_data.conversation_metrics is not None
@@ -452,14 +452,58 @@ def test_default_tag_value(self) -> None:
452452
turn = TurnData(turn_id="turn1", query="Query")
453453
eval_data = EvaluationData(conversation_group_id="conv1", turns=[turn])
454454

455-
assert eval_data.tag == "eval"
455+
assert eval_data.tag == {"eval"}
456456

457-
def test_empty_tag_rejected(self) -> None:
458-
"""Test that empty tag is rejected."""
457+
def test_single_string_tag_normalized_to_set(self) -> None:
458+
"""Test that a single string tag is normalized to a set."""
459+
turn = TurnData(turn_id="turn1", query="Query")
460+
eval_data = EvaluationData(
461+
conversation_group_id="conv1", turns=[turn], tag="basic" # type: ignore[arg-type]
462+
)
463+
464+
assert eval_data.tag == {"basic"}
465+
466+
def test_list_tag_accepted(self) -> None:
467+
"""Test that a list of tags is accepted."""
468+
turn = TurnData(turn_id="turn1", query="Query")
469+
eval_data = EvaluationData(
470+
conversation_group_id="conv1", turns=[turn], tag={"basic", "advanced"}
471+
)
472+
473+
assert eval_data.tag == {"basic", "advanced"}
474+
475+
def test_empty_tag_list_rejected(self) -> None:
476+
"""Test that empty list tag is rejected."""
459477
turn = TurnData(turn_id="turn1", query="Query")
460478

461479
with pytest.raises(ValidationError):
462-
EvaluationData(conversation_group_id="conv1", turns=[turn], tag="")
480+
EvaluationData(
481+
conversation_group_id="conv1",
482+
turns=[turn],
483+
tag=[], # type: ignore[arg-type]
484+
)
485+
486+
def test_whitespace_only_tag_rejected(self) -> None:
487+
"""Test that a list of only whitespace strings is rejected."""
488+
turn = TurnData(turn_id="turn1", query="Query")
489+
490+
with pytest.raises(ValidationError):
491+
EvaluationData(
492+
conversation_group_id="conv1",
493+
turns=[turn],
494+
tag=[" "], # type: ignore[arg-type]
495+
)
496+
497+
def test_non_string_tag_items_rejected(self) -> None:
498+
"""Test that non-string items in tag list are rejected."""
499+
turn = TurnData(turn_id="turn1", query="Query")
500+
501+
with pytest.raises(ValidationError):
502+
EvaluationData(
503+
conversation_group_id="conv1",
504+
turns=[turn],
505+
tag=[1, "prod"], # type: ignore[list-item]
506+
)
463507

464508
def test_empty_conversation_id_rejected(self) -> None:
465509
"""Test that empty conversation_group_id is rejected."""
@@ -500,7 +544,7 @@ def test_default_values(self) -> None:
500544
)
501545

502546
# Test meaningful defaults
503-
assert result.tag == "eval"
547+
assert result.tag == {"eval"}
504548
assert result.score is None
505549
assert result.reason == ""
506550
assert result.evaluation_latency == 0
@@ -509,21 +553,45 @@ def test_explicit_tag_value(self) -> None:
509553
"""Test EvaluationResult with explicit tag value."""
510554
result = EvaluationResult(
511555
conversation_group_id="conv1",
512-
tag="custom_tag",
556+
tag={"custom_tag"},
513557
turn_id="turn1",
514558
metric_identifier="metric1",
515559
result="PASS",
516560
threshold=0.7,
517561
)
518562

519-
assert result.tag == "custom_tag"
563+
assert result.tag == {"custom_tag"}
564+
565+
def test_empty_tag_list_rejected(self) -> None:
566+
"""Test that empty tag list is rejected."""
567+
with pytest.raises(ValidationError):
568+
EvaluationResult(
569+
conversation_group_id="conv1",
570+
tag=[], # type: ignore[arg-type]
571+
turn_id="turn1",
572+
metric_identifier="metric1",
573+
result="PASS",
574+
threshold=0.7,
575+
)
576+
577+
def test_whitespace_only_tag_rejected(self) -> None:
578+
"""Test that a list of only whitespace strings is rejected."""
579+
with pytest.raises(ValidationError):
580+
EvaluationResult(
581+
conversation_group_id="conv1",
582+
tag=[" "], # type: ignore[arg-type]
583+
turn_id="turn1",
584+
metric_identifier="metric1",
585+
result="PASS",
586+
threshold=0.7,
587+
)
520588

521-
def test_empty_tag_rejected(self) -> None:
522-
"""Test that empty tag is rejected."""
589+
def test_non_string_tag_items_rejected(self) -> None:
590+
"""Test that non-string items in tag list are rejected."""
523591
with pytest.raises(ValidationError):
524592
EvaluationResult(
525593
conversation_group_id="conv1",
526-
tag="",
594+
tag=[1, "prod"], # type: ignore[list-item]
527595
turn_id="turn1",
528596
metric_identifier="metric1",
529597
result="PASS",

0 commit comments

Comments
 (0)