Skip to content

Commit 6dcd332

Browse files
authored
feat(py): Add multipart feedback ingestion (#1129)
When multipart is enabled, use same tracing_queue and multipart endpoint to send feedback as well.
2 parents a0f99d9 + 30d402a commit 6dcd332

File tree

6 files changed

+178
-81
lines changed

6 files changed

+178
-81
lines changed

python/langsmith/_internal/_background_thread.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ def _tracing_thread_handle_batch(
6969
) -> None:
7070
create = [it.item for it in batch if it.action == "create"]
7171
update = [it.item for it in batch if it.action == "update"]
72+
feedback = [it.item for it in batch if it.action == "feedback"]
7273
try:
7374
if use_multipart:
74-
client.multipart_ingest_runs(create=create, update=update, pre_sampled=True)
75+
client.multipart_ingest(
76+
create=create, update=update, feedback=feedback, pre_sampled=True
77+
)
7578
else:
7679
client.batch_ingest_runs(create=create, update=update, pre_sampled=True)
7780
except Exception:

python/langsmith/client.py

Lines changed: 121 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,34 @@ def _run_transform(
11221122

11231123
return run_create
11241124

1125+
def _feedback_transform(
1126+
self,
1127+
feedback: Union[ls_schemas.Feedback, dict],
1128+
) -> dict:
1129+
"""Transform the given feedback object into a dictionary representation.
1130+
1131+
Args:
1132+
feedback (Union[ls_schemas.Feedback, dict]): The feedback object to transform.
1133+
update (bool, optional): Whether the payload is for an "update" event.
1134+
copy (bool, optional): Whether to deepcopy feedback inputs/outputs.
1135+
attachments_collector (Optional[dict[str, ls_schemas.Attachments]]):
1136+
A dictionary to collect attachments. If not passed, attachments
1137+
will be dropped.
1138+
1139+
Returns:
1140+
dict: The transformed feedback object as a dictionary.
1141+
"""
1142+
if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")):
1143+
feedback_create: dict = feedback.dict() # type: ignore
1144+
else:
1145+
feedback_create = cast(dict, feedback)
1146+
if "id" not in feedback_create:
1147+
feedback_create["id"] = uuid.uuid4()
1148+
elif isinstance(feedback_create["id"], str):
1149+
feedback_create["id"] = uuid.UUID(feedback_create["id"])
1150+
1151+
return feedback_create
1152+
11251153
@staticmethod
11261154
def _insert_runtime_env(runs: Sequence[dict]) -> None:
11271155
runtime_env = ls_env.get_runtime_environment()
@@ -1408,14 +1436,15 @@ def _post_batch_ingest_runs(self, body: bytes, *, _context: str):
14081436
except Exception:
14091437
logger.warning(f"Failed to batch ingest runs: {repr(e)}")
14101438

1411-
def multipart_ingest_runs(
1439+
def multipart_ingest(
14121440
self,
14131441
create: Optional[
14141442
Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]]
14151443
] = None,
14161444
update: Optional[
14171445
Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]]
14181446
] = None,
1447+
feedback: Optional[Sequence[Union[ls_schemas.Feedback, Dict]]] = None,
14191448
*,
14201449
pre_sampled: bool = False,
14211450
) -> None:
@@ -1442,7 +1471,7 @@ def multipart_ingest_runs(
14421471
- The run objects MUST contain the dotted_order and trace_id fields
14431472
to be accepted by the API.
14441473
"""
1445-
if not create and not update:
1474+
if not (create or update or feedback):
14461475
return
14471476
# transform and convert to dicts
14481477
all_attachments: Dict[str, ls_schemas.Attachments] = {}
@@ -1454,6 +1483,7 @@ def multipart_ingest_runs(
14541483
self._run_transform(run, update=True, attachments_collector=all_attachments)
14551484
for run in update or EMPTY_SEQ
14561485
]
1486+
feedback_dicts = [self._feedback_transform(f) for f in feedback or EMPTY_SEQ]
14571487
# require trace_id and dotted_order
14581488
if create_dicts:
14591489
for run in create_dicts:
@@ -1491,21 +1521,26 @@ def multipart_ingest_runs(
14911521
if not pre_sampled:
14921522
create_dicts = self._filter_for_sampling(create_dicts)
14931523
update_dicts = self._filter_for_sampling(update_dicts, patch=True)
1494-
if not create_dicts and not update_dicts:
1524+
if not create_dicts and not update_dicts and not feedback_dicts:
14951525
return
14961526
# insert runtime environment
14971527
self._insert_runtime_env(create_dicts)
14981528
self._insert_runtime_env(update_dicts)
14991529
# send the runs in multipart requests
15001530
acc_context: List[str] = []
15011531
acc_parts: MultipartParts = []
1502-
for event, payloads in (("post", create_dicts), ("patch", update_dicts)):
1532+
for event, payloads in (
1533+
("post", create_dicts),
1534+
("patch", update_dicts),
1535+
("feedback", feedback_dicts),
1536+
):
15031537
for payload in payloads:
15041538
# collect fields to be sent as separate parts
15051539
fields = [
15061540
("inputs", payload.pop("inputs", None)),
15071541
("outputs", payload.pop("outputs", None)),
15081542
("events", payload.pop("events", None)),
1543+
("feedback", payload.pop("feedback", None)),
15091544
]
15101545
# encode the main run payload
15111546
payloadb = _dumps_json(payload)
@@ -4115,6 +4150,7 @@ def _submit_feedback(**kwargs):
41154150
),
41164151
feedback_source_type=ls_schemas.FeedbackSourceType.MODEL,
41174152
project_id=project_id,
4153+
trace_id=run.trace_id if run else None,
41184154
)
41194155
return results
41204156

@@ -4185,6 +4221,7 @@ def create_feedback(
41854221
project_id: Optional[ID_TYPE] = None,
41864222
comparative_experiment_id: Optional[ID_TYPE] = None,
41874223
feedback_group_id: Optional[ID_TYPE] = None,
4224+
trace_id: Optional[ID_TYPE] = None,
41884225
**kwargs: Any,
41894226
) -> ls_schemas.Feedback:
41904227
"""Create a feedback in the LangSmith API.
@@ -4194,6 +4231,8 @@ def create_feedback(
41944231
run_id : str or UUID
41954232
The ID of the run to provide feedback for. Either the run_id OR
41964233
the project_id must be provided.
4234+
trace_id : str or UUID
4235+
The trace ID of the run to provide feedback for. This is optional.
41974236
key : str
41984237
The name of the metric or 'aspect' this feedback is about.
41994238
score : float or int or bool or None, default=None
@@ -4241,66 +4280,87 @@ def create_feedback(
42414280
f" endpoint: {sorted(kwargs)}",
42424281
DeprecationWarning,
42434282
)
4244-
if not isinstance(feedback_source_type, ls_schemas.FeedbackSourceType):
4245-
feedback_source_type = ls_schemas.FeedbackSourceType(feedback_source_type)
4246-
if feedback_source_type == ls_schemas.FeedbackSourceType.API:
4247-
feedback_source: ls_schemas.FeedbackSourceBase = (
4248-
ls_schemas.APIFeedbackSource(metadata=source_info)
4283+
try:
4284+
if not isinstance(feedback_source_type, ls_schemas.FeedbackSourceType):
4285+
feedback_source_type = ls_schemas.FeedbackSourceType(
4286+
feedback_source_type
4287+
)
4288+
if feedback_source_type == ls_schemas.FeedbackSourceType.API:
4289+
feedback_source: ls_schemas.FeedbackSourceBase = (
4290+
ls_schemas.APIFeedbackSource(metadata=source_info)
4291+
)
4292+
elif feedback_source_type == ls_schemas.FeedbackSourceType.MODEL:
4293+
feedback_source = ls_schemas.ModelFeedbackSource(metadata=source_info)
4294+
else:
4295+
raise ValueError(f"Unknown feedback source type {feedback_source_type}")
4296+
feedback_source.metadata = (
4297+
feedback_source.metadata if feedback_source.metadata is not None else {}
42494298
)
4250-
elif feedback_source_type == ls_schemas.FeedbackSourceType.MODEL:
4251-
feedback_source = ls_schemas.ModelFeedbackSource(metadata=source_info)
4252-
else:
4253-
raise ValueError(f"Unknown feedback source type {feedback_source_type}")
4254-
feedback_source.metadata = (
4255-
feedback_source.metadata if feedback_source.metadata is not None else {}
4256-
)
4257-
if source_run_id is not None and "__run" not in feedback_source.metadata:
4258-
feedback_source.metadata["__run"] = {"run_id": str(source_run_id)}
4259-
if feedback_source.metadata and "__run" in feedback_source.metadata:
4260-
# Validate that the linked run ID is a valid UUID
4261-
# Run info may be a base model or dict.
4262-
_run_meta: Union[dict, Any] = feedback_source.metadata["__run"]
4263-
if hasattr(_run_meta, "dict") and callable(_run_meta):
4264-
_run_meta = _run_meta.dict()
4265-
if "run_id" in _run_meta:
4266-
_run_meta["run_id"] = str(
4267-
_as_uuid(
4268-
feedback_source.metadata["__run"]["run_id"],
4269-
"feedback_source.metadata['__run']['run_id']",
4299+
if source_run_id is not None and "__run" not in feedback_source.metadata:
4300+
feedback_source.metadata["__run"] = {"run_id": str(source_run_id)}
4301+
if feedback_source.metadata and "__run" in feedback_source.metadata:
4302+
# Validate that the linked run ID is a valid UUID
4303+
# Run info may be a base model or dict.
4304+
_run_meta: Union[dict, Any] = feedback_source.metadata["__run"]
4305+
if hasattr(_run_meta, "dict") and callable(_run_meta):
4306+
_run_meta = _run_meta.dict()
4307+
if "run_id" in _run_meta:
4308+
_run_meta["run_id"] = str(
4309+
_as_uuid(
4310+
feedback_source.metadata["__run"]["run_id"],
4311+
"feedback_source.metadata['__run']['run_id']",
4312+
)
42704313
)
4314+
feedback_source.metadata["__run"] = _run_meta
4315+
feedback = ls_schemas.FeedbackCreate(
4316+
id=_ensure_uuid(feedback_id),
4317+
# If run_id is None, this is interpreted as session-level
4318+
# feedback.
4319+
run_id=_ensure_uuid(run_id, accept_null=True),
4320+
trace_id=_ensure_uuid(trace_id, accept_null=True),
4321+
key=key,
4322+
score=score,
4323+
value=value,
4324+
correction=correction,
4325+
comment=comment,
4326+
feedback_source=feedback_source,
4327+
created_at=datetime.datetime.now(datetime.timezone.utc),
4328+
modified_at=datetime.datetime.now(datetime.timezone.utc),
4329+
feedback_config=feedback_config,
4330+
session_id=_ensure_uuid(project_id, accept_null=True),
4331+
comparative_experiment_id=_ensure_uuid(
4332+
comparative_experiment_id, accept_null=True
4333+
),
4334+
feedback_group_id=_ensure_uuid(feedback_group_id, accept_null=True),
4335+
)
4336+
4337+
feedback_block = _dumps_json(feedback.dict(exclude_none=True))
4338+
use_multipart = (self.info.batch_ingest_config or {}).get(
4339+
"use_multipart_endpoint", False
4340+
)
4341+
4342+
if (
4343+
use_multipart
4344+
and self.tracing_queue is not None
4345+
and feedback.trace_id is not None
4346+
):
4347+
self.tracing_queue.put(
4348+
TracingQueueItem(str(feedback.id), "feedback", feedback)
42714349
)
4272-
feedback_source.metadata["__run"] = _run_meta
4273-
feedback = ls_schemas.FeedbackCreate(
4274-
id=_ensure_uuid(feedback_id),
4275-
# If run_id is None, this is interpreted as session-level
4276-
# feedback.
4277-
run_id=_ensure_uuid(run_id, accept_null=True),
4278-
key=key,
4279-
score=score,
4280-
value=value,
4281-
correction=correction,
4282-
comment=comment,
4283-
feedback_source=feedback_source,
4284-
created_at=datetime.datetime.now(datetime.timezone.utc),
4285-
modified_at=datetime.datetime.now(datetime.timezone.utc),
4286-
feedback_config=feedback_config,
4287-
session_id=_ensure_uuid(project_id, accept_null=True),
4288-
comparative_experiment_id=_ensure_uuid(
4289-
comparative_experiment_id, accept_null=True
4290-
),
4291-
feedback_group_id=_ensure_uuid(feedback_group_id, accept_null=True),
4292-
)
4293-
feedback_block = _dumps_json(feedback.dict(exclude_none=True))
4294-
self.request_with_retries(
4295-
"POST",
4296-
"/feedback",
4297-
request_kwargs={
4298-
"data": feedback_block,
4299-
},
4300-
stop_after_attempt=stop_after_attempt,
4301-
retry_on=(ls_utils.LangSmithNotFoundError,),
4302-
)
4303-
return ls_schemas.Feedback(**feedback.dict())
4350+
else:
4351+
self.request_with_retries(
4352+
"POST",
4353+
"/feedback",
4354+
request_kwargs={
4355+
"data": feedback_block,
4356+
},
4357+
stop_after_attempt=stop_after_attempt,
4358+
retry_on=(ls_utils.LangSmithNotFoundError,),
4359+
)
4360+
return ls_schemas.Feedback(**feedback.dict())
4361+
except Exception as e:
4362+
logger.error("Error creating feedback", exc_info=True)
4363+
raise e
43044364

43054365
def update_feedback(
43064366
self,

python/langsmith/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,8 @@ class FeedbackBase(BaseModel):
440440
"""The time the feedback was last modified."""
441441
run_id: Optional[UUID]
442442
"""The associated run ID this feedback is logged for."""
443+
trace_id: Optional[UUID]
444+
"""The associated trace ID this feedback is logged for."""
443445
key: str
444446
"""The metric name, tag, or aspect to provide feedback on."""
445447
score: SCORE_TYPE = None

python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "langsmith"
3-
version = "0.1.137"
3+
version = "0.1.138rc1"
44
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
55
authors = ["LangChain <support@langchain.dev>"]
66
license = "MIT"

0 commit comments

Comments
 (0)