arize-phoenix 4.10.1__py3-none-any.whl → 4.10.2rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

Files changed (28) hide show
  1. {arize_phoenix-4.10.1.dist-info → arize_phoenix-4.10.2rc1.dist-info}/METADATA +4 -3
  2. {arize_phoenix-4.10.1.dist-info → arize_phoenix-4.10.2rc1.dist-info}/RECORD +27 -25
  3. phoenix/server/api/context.py +5 -7
  4. phoenix/server/api/dataloaders/__init__.py +2 -0
  5. phoenix/server/api/dataloaders/span_annotations.py +35 -0
  6. phoenix/server/api/openapi/main.py +18 -2
  7. phoenix/server/api/openapi/schema.py +12 -12
  8. phoenix/server/api/routers/v1/__init__.py +36 -83
  9. phoenix/server/api/routers/v1/dataset_examples.py +102 -123
  10. phoenix/server/api/routers/v1/datasets.py +389 -507
  11. phoenix/server/api/routers/v1/evaluations.py +74 -64
  12. phoenix/server/api/routers/v1/experiment_evaluations.py +67 -91
  13. phoenix/server/api/routers/v1/experiment_runs.py +97 -155
  14. phoenix/server/api/routers/v1/experiments.py +131 -181
  15. phoenix/server/api/routers/v1/spans.py +141 -173
  16. phoenix/server/api/routers/v1/traces.py +113 -128
  17. phoenix/server/api/routers/v1/utils.py +94 -0
  18. phoenix/server/api/types/Annotation.py +21 -0
  19. phoenix/server/api/types/Evaluation.py +4 -22
  20. phoenix/server/api/types/Span.py +15 -4
  21. phoenix/server/api/types/SpanAnnotation.py +4 -6
  22. phoenix/server/app.py +149 -174
  23. phoenix/server/thread_server.py +2 -2
  24. phoenix/version.py +1 -1
  25. phoenix/server/openapi/docs.py +0 -221
  26. {arize_phoenix-4.10.1.dist-info → arize_phoenix-4.10.2rc1.dist-info}/WHEEL +0 -0
  27. {arize_phoenix-4.10.1.dist-info → arize_phoenix-4.10.2rc1.dist-info}/licenses/IP_NOTICE +0 -0
  28. {arize_phoenix-4.10.1.dist-info → arize_phoenix-4.10.2rc1.dist-info}/licenses/LICENSE +0 -0
@@ -1,18 +1,19 @@
1
1
  import gzip
2
2
  import zlib
3
- from typing import Any, Dict, List
3
+ from typing import Any, Dict, List, Literal, Optional
4
4
 
5
+ from fastapi import APIRouter, BackgroundTasks, Header, HTTPException
5
6
  from google.protobuf.message import DecodeError
6
7
  from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
7
8
  ExportTraceServiceRequest,
8
9
  )
10
+ from pydantic import BaseModel, Field
9
11
  from sqlalchemy import select
10
- from starlette.background import BackgroundTask
11
12
  from starlette.concurrency import run_in_threadpool
12
13
  from starlette.datastructures import State
13
14
  from starlette.requests import Request
14
- from starlette.responses import JSONResponse, Response
15
15
  from starlette.status import (
16
+ HTTP_204_NO_CONTENT,
16
17
  HTTP_404_NOT_FOUND,
17
18
  HTTP_415_UNSUPPORTED_MEDIA_TYPE,
18
19
  HTTP_422_UNPROCESSABLE_ENTITY,
@@ -26,40 +27,49 @@ from phoenix.server.api.types.node import from_global_id_with_expected_type
26
27
  from phoenix.trace.otel import decode_otlp_span
27
28
  from phoenix.utilities.project import get_project_name
28
29
 
29
-
30
- async def post_traces(request: Request) -> Response:
31
- """
32
- summary: Send traces to Phoenix
33
- operationId: addTraces
34
- tags:
35
- - private
36
- requestBody:
37
- required: true
38
- content:
39
- application/x-protobuf:
40
- schema:
41
- type: string
42
- format: binary
43
- responses:
44
- 200:
45
- description: Success
46
- 403:
47
- description: Forbidden
48
- 415:
49
- description: Unsupported content type, only gzipped protobuf
50
- 422:
51
- description: Request body is invalid
52
- """
53
- content_type = request.headers.get("content-type")
30
+ from .utils import RequestBody, ResponseBody, add_errors_to_responses
31
+
32
+ router = APIRouter(tags=["traces"], include_in_schema=False)
33
+
34
+
35
+ @router.post(
36
+ "/traces",
37
+ operation_id="addTraces",
38
+ summary="Send traces",
39
+ status_code=HTTP_204_NO_CONTENT,
40
+ responses=add_errors_to_responses(
41
+ [
42
+ {
43
+ "status_code": HTTP_415_UNSUPPORTED_MEDIA_TYPE,
44
+ "description": (
45
+ "Unsupported content type (only `application/x-protobuf` is supported)"
46
+ ),
47
+ },
48
+ {"status_code": HTTP_422_UNPROCESSABLE_ENTITY, "description": "Invalid request body"},
49
+ ]
50
+ ),
51
+ openapi_extra={
52
+ "requestBody": {
53
+ "required": True,
54
+ "content": {
55
+ "application/x-protobuf": {"schema": {"type": "string", "format": "binary"}}
56
+ },
57
+ }
58
+ },
59
+ )
60
+ async def post_traces(
61
+ request: Request,
62
+ content_type: Optional[str] = Header(default=None),
63
+ content_encoding: Optional[str] = Header(default=None),
64
+ ) -> None:
54
65
  if content_type != "application/x-protobuf":
55
- return Response(
56
- content=f"Unsupported content type: {content_type}",
66
+ raise HTTPException(
67
+ detail=f"Unsupported content type: {content_type}",
57
68
  status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
58
69
  )
59
- content_encoding = request.headers.get("content-encoding")
60
70
  if content_encoding and content_encoding not in ("gzip", "deflate"):
61
- return Response(
62
- content=f"Unsupported content encoding: {content_encoding}",
71
+ raise HTTPException(
72
+ detail=f"Unsupported content encoding: {content_encoding}",
63
73
  status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
64
74
  )
65
75
  body = await request.body()
@@ -71,96 +81,69 @@ async def post_traces(request: Request) -> Response:
71
81
  try:
72
82
  await run_in_threadpool(req.ParseFromString, body)
73
83
  except DecodeError:
74
- return Response(
75
- content="Request body is invalid ExportTraceServiceRequest",
84
+ raise HTTPException(
85
+ detail="Request body is invalid ExportTraceServiceRequest",
76
86
  status_code=HTTP_422_UNPROCESSABLE_ENTITY,
77
87
  )
78
- return Response(background=BackgroundTask(_add_spans, req, request.state))
79
-
80
-
81
- async def annotate_traces(request: Request) -> Response:
82
- """
83
- summary: Upsert annotations for traces
84
- operationId: annotateTraces
85
- tags:
86
- - private
87
- requestBody:
88
- description: List of trace annotations to be inserted
89
- required: true
90
- content:
91
- application/json:
92
- schema:
93
- type: object
94
- properties:
95
- data:
96
- type: array
97
- items:
98
- type: object
99
- properties:
100
- trace_id:
101
- type: string
102
- description: The ID of the trace being annotated
103
- name:
104
- type: string
105
- description: The name of the annotation
106
- annotator_kind:
107
- type: string
108
- description: The kind of annotator used for the annotation ("LLM" or "HUMAN")
109
- result:
110
- type: object
111
- description: The result of the annotation
112
- properties:
113
- label:
114
- type: string
115
- description: The label assigned by the annotation
116
- score:
117
- type: number
118
- format: float
119
- description: The score assigned by the annotation
120
- explanation:
121
- type: string
122
- description: Explanation of the annotation result
123
- error:
124
- type: string
125
- description: Optional error message if the annotation encountered an error
126
- metadata:
127
- type: object
128
- description: Metadata for the annotation
129
- additionalProperties:
130
- type: string
131
- required:
132
- - trace_id
133
- - name
134
- - annotator_kind
135
- responses:
136
- 200:
137
- description: Trace annotations inserted successfully
138
- content:
139
- application/json:
140
- schema:
141
- type: object
142
- properties:
143
- data:
144
- type: array
145
- items:
146
- type: object
147
- properties:
148
- id:
149
- type: string
150
- description: The ID of the inserted trace annotation
151
- 404:
152
- description: Trace not found
153
- """
154
- payload: List[Dict[str, Any]] = (await request.json()).get("data", [])
155
- trace_gids = [GlobalID.from_id(annotation["trace_id"]) for annotation in payload]
88
+ BackgroundTasks().add_task(_add_spans, req, request.state)
89
+ return None
90
+
91
+
92
+ class AnnotationResult(BaseModel):
93
+ label: Optional[str] = Field(default=None, description="The label assigned by the annotation")
94
+ score: Optional[float] = Field(default=None, description="The score assigned by the annotation")
95
+ explanation: Optional[str] = Field(
96
+ default=None, description="Explanation of the annotation result"
97
+ )
98
+
99
+
100
+ class TraceAnnotation(BaseModel):
101
+ trace_id: str = Field(description="The ID of the trace being annotated")
102
+ name: str = Field(description="The name of the annotation")
103
+ annotator_kind: Literal["LLM", "HUMAN"] = Field(
104
+ description="The kind of annotator used for the annotation"
105
+ )
106
+ result: Optional[AnnotationResult] = Field(
107
+ default=None, description="The result of the annotation"
108
+ )
109
+ metadata: Optional[Dict[str, Any]] = Field(
110
+ default=None, description="Metadata for the annotation"
111
+ )
112
+
113
+
114
+ class AnnotateTracesRequestBody(RequestBody[List[TraceAnnotation]]):
115
+ data: List[TraceAnnotation] = Field(description="The trace annotations to be upserted")
116
+
117
+
118
+ class InsertedTraceAnnotation(BaseModel):
119
+ id: str = Field(description="The ID of the inserted trace annotation")
120
+
121
+
122
+ class AnnotateTracesResponseBody(ResponseBody[List[InsertedTraceAnnotation]]):
123
+ pass
124
+
125
+
126
+ @router.post(
127
+ "/trace_annotations",
128
+ operation_id="annotateTraces",
129
+ summary="Create or update trace annotations",
130
+ responses=add_errors_to_responses(
131
+ [{"status_code": HTTP_404_NOT_FOUND, "description": "Trace not found"}]
132
+ ),
133
+ )
134
+ async def annotate_traces(
135
+ request: Request, request_body: AnnotateTracesRequestBody
136
+ ) -> AnnotateTracesResponseBody:
137
+ trace_annotations = request_body.data
138
+ trace_gids = [GlobalID.from_id(annotation.trace_id) for annotation in trace_annotations]
156
139
 
157
140
  resolved_trace_ids = []
158
141
  for trace_gid in trace_gids:
159
142
  try:
160
143
  resolved_trace_ids.append(from_global_id_with_expected_type(trace_gid, "Trace"))
161
144
  except ValueError:
162
- return Response(
163
- content="Trace with ID {trace_gid} does not exist",
145
+ raise HTTPException(
146
+ detail="Trace with ID {trace_gid} does not exist",
164
147
  status_code=HTTP_404_NOT_FOUND,
165
148
  )
166
149
 
@@ -175,24 +158,24 @@ async def annotate_traces(request: Request) -> Response:
175
158
  missing_trace_gids = [
176
159
  str(GlobalID("Trace", str(trace_gid))) for trace_gid in missing_trace_ids
177
160
  ]
178
- return Response(
179
- content=f"Traces with IDs {', '.join(missing_trace_gids)} do not exist.",
161
+ raise HTTPException(
162
+ detail=f"Traces with IDs {', '.join(missing_trace_gids)} do not exist.",
180
163
  status_code=HTTP_404_NOT_FOUND,
181
164
  )
182
165
 
183
166
  inserted_annotations = []
184
167
 
185
- for annotation in payload:
186
- trace_gid = GlobalID.from_id(annotation["trace_id"])
168
+ for annotation in trace_annotations:
169
+ trace_gid = GlobalID.from_id(annotation.trace_id)
187
170
  trace_id = from_global_id_with_expected_type(trace_gid, "Trace")
188
171
 
189
- name = annotation["name"]
190
- annotator_kind = annotation["annotator_kind"]
191
- result = annotation.get("result")
192
- label = result.get("label") if result else None
193
- score = result.get("score") if result else None
194
- explanation = result.get("explanation") if result else None
195
- metadata = annotation.get("metadata") or {}
172
+ name = annotation.name
173
+ annotator_kind = annotation.annotator_kind
174
+ result = annotation.result
175
+ label = result.label if result else None
176
+ score = result.score if result else None
177
+ explanation = result.explanation if result else None
178
+ metadata = annotation.metadata or {}
196
179
 
197
180
  values = dict(
198
181
  trace_rowid=trace_id,
@@ -213,10 +196,12 @@ async def annotate_traces(request: Request) -> Response:
213
196
  ).returning(models.TraceAnnotation.id)
214
197
  )
215
198
  inserted_annotations.append(
216
- {"id": str(GlobalID("TraceAnnotation", str(trace_annotation_id)))}
199
+ InsertedTraceAnnotation(
200
+ id=str(GlobalID("TraceAnnotation", str(trace_annotation_id)))
201
+ )
217
202
  )
218
203
 
219
- return JSONResponse(content={"data": inserted_annotations})
204
+ return AnnotateTracesResponseBody(data=inserted_annotations)
220
205
 
221
206
 
222
207
  async def _add_spans(req: ExportTraceServiceRequest, state: State) -> None:
@@ -0,0 +1,94 @@
1
+ from typing import Any, Dict, Generic, List, Optional, TypedDict, Union
2
+
3
+ from pydantic import BaseModel
4
+ from typing_extensions import TypeAlias, TypeVar, assert_never
5
+
6
+ StatusCode: TypeAlias = int
7
+ DataType = TypeVar("DataType")
8
+ Responses: TypeAlias = Dict[
9
+ Union[int, str], Dict[str, Any]
10
+ ] # input type for the `responses` parameter of a fastapi route
11
+
12
+
13
+ class StatusCodeWithDescription(TypedDict):
14
+ """
15
+ A duck type for a status code with a description detailing under what
16
+ conditions the status code is raised.
17
+ """
18
+
19
+ status_code: StatusCode
20
+ description: str
21
+
22
+
23
+ class RequestBody(BaseModel, Generic[DataType]):
24
+ # A generic request type accepted by V1 routes.
25
+ #
26
+ # Don't use """ for this docstring or it will be included as a description
27
+ # in the generated OpenAPI schema.
28
+ data: DataType
29
+
30
+
31
+ class ResponseBody(BaseModel, Generic[DataType]):
32
+ # A generic response type returned by V1 routes.
33
+ #
34
+ # Don't use """ for this docstring or it will be included as a description
35
+ # in the generated OpenAPI schema.
36
+
37
+ data: DataType
38
+
39
+
40
+ class PaginatedResponseBody(BaseModel, Generic[DataType]):
41
+ # A generic paginated response type returned by V1 routes.
42
+ #
43
+ # Don't use """ for this docstring or it will be included as a description
44
+ # in the generated OpenAPI schema.
45
+
46
+ data: List[DataType]
47
+ next_cursor: Optional[str]
48
+
49
+
50
+ def add_errors_to_responses(
51
+ errors: List[Union[StatusCode, StatusCodeWithDescription]],
52
+ /,
53
+ *,
54
+ responses: Optional[Responses] = None,
55
+ ) -> Responses:
56
+ """
57
+ Creates or updates a patch for an OpenAPI schema's `responses` section to
58
+ include status codes in the generated OpenAPI schema.
59
+ """
60
+ output_responses: Responses = responses or {}
61
+ for error in errors:
62
+ status_code: int
63
+ description: Optional[str] = None
64
+ if isinstance(error, StatusCode):
65
+ status_code = error
66
+ elif isinstance(error, dict):
67
+ status_code = error["status_code"]
68
+ description = error["description"]
69
+ else:
70
+ assert_never(error)
71
+ if status_code not in output_responses:
72
+ output_responses[status_code] = {
73
+ "content": {"text/plain": {"schema": {"type": "string"}}}
74
+ }
75
+ if description:
76
+ output_responses[status_code]["description"] = description
77
+ return output_responses
78
+
79
+
80
+ def add_text_csv_content_to_responses(
81
+ status_code: StatusCode, /, *, responses: Optional[Responses] = None
82
+ ) -> Responses:
83
+ """
84
+ Creates or updates a patch for an OpenAPI schema's `responses` section to
85
+ ensure that the response for the given status code is marked as text/csv in
86
+ the generated OpenAPI schema.
87
+ """
88
+ output_responses: Responses = responses or {}
89
+ if status_code not in output_responses:
90
+ output_responses[status_code] = {}
91
+ output_responses[status_code]["content"] = {
92
+ "text/csv": {"schema": {"type": "string", "contentMediaType": "text/csv"}}
93
+ }
94
+ return output_responses
@@ -0,0 +1,21 @@
1
+ from typing import Optional
2
+
3
+ import strawberry
4
+
5
+
6
+ @strawberry.interface
7
+ class Annotation:
8
+ name: str = strawberry.field(
9
+ description="Name of the annotation, e.g. 'helpfulness' or 'relevance'."
10
+ )
11
+ score: Optional[float] = strawberry.field(
12
+ description="Value of the annotation in the form of a numeric score."
13
+ )
14
+ label: Optional[str] = strawberry.field(
15
+ description="Value of the annotation in the form of a string, e.g. "
16
+ "'helpful' or 'not helpful'. Note that the label is not necessarily binary."
17
+ )
18
+ explanation: Optional[str] = strawberry.field(
19
+ description="The annotator's explanation for the annotation result (i.e. "
20
+ "score or label, or both) given to the subject."
21
+ )
@@ -1,31 +1,13 @@
1
- from typing import Optional
2
-
3
1
  import strawberry
4
2
 
5
3
  import phoenix.trace.v1 as pb
6
4
  from phoenix.db.models import DocumentAnnotation, SpanAnnotation, TraceAnnotation
7
5
 
8
-
9
- @strawberry.interface
10
- class Evaluation:
11
- name: str = strawberry.field(
12
- description="Name of the evaluation, e.g. 'helpfulness' or 'relevance'."
13
- )
14
- score: Optional[float] = strawberry.field(
15
- description="Result of the evaluation in the form of a numeric score."
16
- )
17
- label: Optional[str] = strawberry.field(
18
- description="Result of the evaluation in the form of a string, e.g. "
19
- "'helpful' or 'not helpful'. Note that the label is not necessarily binary."
20
- )
21
- explanation: Optional[str] = strawberry.field(
22
- description="The evaluator's explanation for the evaluation result (i.e. "
23
- "score or label, or both) given to the subject."
24
- )
6
+ from .Annotation import Annotation
25
7
 
26
8
 
27
9
  @strawberry.type
28
- class TraceEvaluation(Evaluation):
10
+ class TraceEvaluation(Annotation):
29
11
  @staticmethod
30
12
  def from_pb_evaluation(evaluation: pb.Evaluation) -> "TraceEvaluation":
31
13
  result = evaluation.result
@@ -50,7 +32,7 @@ class TraceEvaluation(Evaluation):
50
32
 
51
33
 
52
34
  @strawberry.type
53
- class SpanEvaluation(Evaluation):
35
+ class SpanEvaluation(Annotation):
54
36
  @staticmethod
55
37
  def from_pb_evaluation(evaluation: pb.Evaluation) -> "SpanEvaluation":
56
38
  result = evaluation.result
@@ -75,7 +57,7 @@ class SpanEvaluation(Evaluation):
75
57
 
76
58
 
77
59
  @strawberry.type
78
- class DocumentEvaluation(Evaluation):
60
+ class DocumentEvaluation(Annotation):
79
61
  document_position: int = strawberry.field(
80
62
  description="The zero-based index among retrieved documents, which "
81
63
  "is collected as a list (even when ordering is not inherently meaningful)."
@@ -19,12 +19,14 @@ from phoenix.server.api.helpers.dataset_helpers import (
19
19
  get_dataset_example_input,
20
20
  get_dataset_example_output,
21
21
  )
22
- from phoenix.server.api.types.DocumentRetrievalMetrics import DocumentRetrievalMetrics
23
- from phoenix.server.api.types.Evaluation import DocumentEvaluation, SpanEvaluation
24
- from phoenix.server.api.types.ExampleRevisionInterface import ExampleRevision
25
- from phoenix.server.api.types.MimeType import MimeType
26
22
  from phoenix.trace.attributes import get_attribute_value
27
23
 
24
+ from .DocumentRetrievalMetrics import DocumentRetrievalMetrics
25
+ from .Evaluation import DocumentEvaluation, SpanEvaluation
26
+ from .ExampleRevisionInterface import ExampleRevision
27
+ from .MimeType import MimeType
28
+ from .SpanAnnotation import SpanAnnotation
29
+
28
30
  if TYPE_CHECKING:
29
31
  from phoenix.server.api.types.Project import Project
30
32
 
@@ -172,6 +174,15 @@ class Span(Node):
172
174
  async def span_evaluations(self, info: Info[Context, None]) -> List[SpanEvaluation]:
173
175
  return await info.context.data_loaders.span_evaluations.load(self.id_attr)
174
176
 
177
+ @strawberry.field(
178
+ description=(
179
+ "Annotations of the span's parent span. This encompasses both "
180
+ "LLM and human annotations."
181
+ )
182
+ ) # type: ignore
183
+ async def span_annotations(self, info: Info[Context, None]) -> List[SpanAnnotation]:
184
+ return await info.context.data_loaders.span_annotations.load(self.id_attr)
185
+
175
186
  @strawberry.field(
176
187
  description="Evaluations of the documents associated with the span, e.g. "
177
188
  "if the span is a RETRIEVER with a list of documents in its RETRIEVAL_DOCUMENTS "
@@ -6,17 +6,15 @@ from strawberry.relay import GlobalID, Node, NodeID
6
6
  from strawberry.scalars import JSON
7
7
 
8
8
  from phoenix.db import models
9
- from phoenix.server.api.types.AnnotatorKind import AnnotatorKind
9
+
10
+ from .Annotation import Annotation
11
+ from .AnnotatorKind import AnnotatorKind
10
12
 
11
13
 
12
14
  @strawberry.type
13
- class SpanAnnotation(Node):
15
+ class SpanAnnotation(Node, Annotation):
14
16
  id_attr: NodeID[int]
15
- name: str
16
17
  annotator_kind: AnnotatorKind
17
- label: Optional[str]
18
- score: Optional[float]
19
- explanation: Optional[str]
20
18
  metadata: JSON
21
19
  span_rowid: Private[Optional[int]]
22
20