Skip to content

Commit 23c326b

Browse files
author
kappa90
committed
feat(ph-ai): agent artifacts API endpoint
1 parent 81c7220 commit 23c326b

File tree

7 files changed

+440
-4
lines changed

7 files changed

+440
-4
lines changed

ee/api/agent_artifact.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from rest_framework import serializers, status
2+
from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin
3+
from rest_framework.response import Response
4+
from rest_framework.viewsets import GenericViewSet
5+
6+
from posthog.api.routing import TeamAndOrgViewSetMixin
7+
8+
from ee.models.assistant import AgentArtifact
9+
10+
11+
class AgentArtifactSerializer(serializers.ModelSerializer):
12+
class Meta:
13+
model = AgentArtifact
14+
fields = ["id", "short_id", "name", "type", "content", "conversation", "team", "created_at"]
15+
read_only_fields = ["id", "short_id", "name", "type", "content", "conversation", "team", "created_at"]
16+
17+
18+
class AgentArtifactMinimalSerializer(serializers.ModelSerializer):
19+
class Meta:
20+
model = AgentArtifact
21+
fields = ["short_id", "name", "type", "created_at"]
22+
23+
24+
class AgentArtifactViewSet(
25+
TeamAndOrgViewSetMixin,
26+
RetrieveModelMixin,
27+
ListModelMixin,
28+
DestroyModelMixin,
29+
GenericViewSet,
30+
):
31+
scope_object = "INTERNAL"
32+
serializer_class = AgentArtifactSerializer
33+
queryset = AgentArtifact.objects.all()
34+
lookup_field = "short_id"
35+
lookup_url_kwarg = "short_id"
36+
37+
def safely_get_queryset(self, queryset):
38+
queryset = queryset.filter(conversation__user=self.request.user, conversation__team=self.team)
39+
40+
conversation_id = self.request.query_params.get("conversation")
41+
if conversation_id:
42+
queryset = queryset.filter(conversation_id=conversation_id)
43+
44+
return queryset.order_by("-created_at")
45+
46+
def get_serializer_class(self):
47+
if self.action == "list":
48+
return AgentArtifactMinimalSerializer
49+
return AgentArtifactSerializer
50+
51+
def destroy(self, request, *args, **kwargs):
52+
instance = self.get_object()
53+
54+
if instance.conversation.user != request.user:
55+
return Response(
56+
{"error": "Cannot delete other users' artifacts"},
57+
status=status.HTTP_403_FORBIDDEN,
58+
)
59+
60+
return super().destroy(request, *args, **kwargs)

ee/api/test/test_agent_artifact.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
from posthog.test.base import APIBaseTest
2+
from unittest.mock import patch
3+
4+
from rest_framework import status
5+
6+
from ee.models.assistant import AgentArtifact, Conversation
7+
8+
9+
class TestAgentArtifactModel(APIBaseTest):
10+
def test_create_artifact(self):
11+
conversation = Conversation.objects.create(
12+
user=self.user, team=self.team, title="Test Conversation", type=Conversation.Type.ASSISTANT
13+
)
14+
15+
artifact = AgentArtifact.objects.create(
16+
name="Test Visualization",
17+
type=AgentArtifact.Type.VISUALIZATION,
18+
content={"query": {"kind": "TrendsQuery"}},
19+
conversation=conversation,
20+
team=self.team,
21+
)
22+
23+
self.assertIsNotNone(artifact.id)
24+
self.assertEqual(len(artifact.short_id), 6)
25+
self.assertEqual(artifact.name, "Test Visualization")
26+
self.assertEqual(artifact.type, AgentArtifact.Type.VISUALIZATION)
27+
self.assertEqual(artifact.content, {"query": {"kind": "TrendsQuery"}})
28+
self.assertEqual(artifact.conversation, conversation)
29+
self.assertIsNotNone(artifact.created_at)
30+
31+
def test_short_id_unique(self):
32+
conversation = Conversation.objects.create(
33+
user=self.user, team=self.team, title="Test Conversation", type=Conversation.Type.ASSISTANT
34+
)
35+
36+
artifact1 = AgentArtifact.objects.create(
37+
name="Artifact 1",
38+
type=AgentArtifact.Type.VISUALIZATION,
39+
content={},
40+
conversation=conversation,
41+
team=self.team,
42+
)
43+
44+
artifact2 = AgentArtifact.objects.create(
45+
name="Artifact 2",
46+
type=AgentArtifact.Type.DOCUMENT,
47+
content={},
48+
conversation=conversation,
49+
team=self.team,
50+
)
51+
52+
self.assertNotEqual(artifact1.short_id, artifact2.short_id)
53+
54+
def test_cascade_delete_with_conversation(self):
55+
conversation = Conversation.objects.create(
56+
user=self.user, team=self.team, title="Test Conversation", type=Conversation.Type.ASSISTANT
57+
)
58+
59+
artifact = AgentArtifact.objects.create(
60+
name="Test Artifact",
61+
type=AgentArtifact.Type.VISUALIZATION,
62+
content={},
63+
conversation=conversation,
64+
team=self.team,
65+
)
66+
67+
artifact_id = artifact.id
68+
conversation.delete()
69+
70+
self.assertFalse(AgentArtifact.objects.filter(id=artifact_id).exists())
71+
72+
def test_short_id_collision_retry(self):
73+
conversation = Conversation.objects.create(
74+
user=self.user, team=self.team, title="Test Conversation", type=Conversation.Type.ASSISTANT
75+
)
76+
77+
artifact1 = AgentArtifact.objects.create(
78+
name="Artifact 1",
79+
type=AgentArtifact.Type.VISUALIZATION,
80+
content={},
81+
conversation=conversation,
82+
team=self.team,
83+
)
84+
85+
original_short_id = artifact1.short_id
86+
87+
with patch("ee.models.assistant.generate_short_id_6", return_value="unique"):
88+
artifact2 = AgentArtifact(
89+
name="Artifact 2",
90+
type=AgentArtifact.Type.DOCUMENT,
91+
content={},
92+
conversation=conversation,
93+
team=self.team,
94+
short_id=original_short_id, # Start with duplicate
95+
)
96+
artifact2.save()
97+
98+
self.assertEqual(artifact2.short_id, "unique")
99+
100+
101+
class TestAgentArtifactAPI(APIBaseTest):
102+
def setUp(self):
103+
super().setUp()
104+
self.conversation = Conversation.objects.create(
105+
user=self.user, team=self.team, title="Test Conversation", type=Conversation.Type.ASSISTANT
106+
)
107+
108+
def test_list_artifacts(self):
109+
AgentArtifact.objects.create(
110+
name="Artifact 1",
111+
type=AgentArtifact.Type.VISUALIZATION,
112+
content={"query": {}},
113+
conversation=self.conversation,
114+
team=self.team,
115+
)
116+
117+
AgentArtifact.objects.create(
118+
name="Artifact 2",
119+
type=AgentArtifact.Type.DOCUMENT,
120+
content={"blocks": []},
121+
conversation=self.conversation,
122+
team=self.team,
123+
)
124+
125+
response = self.client.get(f"/api/environments/{self.team.id}/agent-artifacts/")
126+
127+
self.assertEqual(response.status_code, status.HTTP_200_OK)
128+
self.assertEqual(len(response.json()["results"]), 2)
129+
130+
# Check that list returns minimal serializer fields
131+
result = response.json()["results"][0]
132+
self.assertIn("short_id", result)
133+
self.assertIn("name", result)
134+
self.assertIn("type", result)
135+
self.assertIn("created_at", result)
136+
self.assertNotIn("content", result)
137+
self.assertNotIn("id", result)
138+
139+
def test_list_artifacts_filter_by_conversation(self):
140+
other_conversation = Conversation.objects.create(
141+
user=self.user, team=self.team, title="Other Conversation", type=Conversation.Type.ASSISTANT
142+
)
143+
144+
AgentArtifact.objects.create(
145+
name="Artifact 1",
146+
type=AgentArtifact.Type.VISUALIZATION,
147+
content={},
148+
conversation=self.conversation,
149+
team=self.team,
150+
)
151+
152+
AgentArtifact.objects.create(
153+
name="Artifact 2",
154+
type=AgentArtifact.Type.DOCUMENT,
155+
content={},
156+
conversation=other_conversation,
157+
team=self.team,
158+
)
159+
160+
response = self.client.get(
161+
f"/api/environments/{self.team.id}/agent-artifacts/",
162+
{"conversation": str(self.conversation.id)},
163+
)
164+
165+
self.assertEqual(response.status_code, status.HTTP_200_OK)
166+
self.assertEqual(len(response.json()["results"]), 1)
167+
self.assertEqual(response.json()["results"][0]["name"], "Artifact 1")
168+
169+
def test_retrieve_artifact(self):
170+
artifact = AgentArtifact.objects.create(
171+
name="Test Artifact",
172+
type=AgentArtifact.Type.VISUALIZATION,
173+
content={"query": {"kind": "TrendsQuery"}},
174+
conversation=self.conversation,
175+
team=self.team,
176+
)
177+
178+
response = self.client.get(f"/api/environments/{self.team.id}/agent-artifacts/{artifact.short_id}/")
179+
180+
self.assertEqual(response.status_code, status.HTTP_200_OK)
181+
data = response.json()
182+
self.assertEqual(data["short_id"], artifact.short_id)
183+
self.assertEqual(data["name"], "Test Artifact")
184+
self.assertEqual(data["type"], "visualization")
185+
self.assertEqual(data["content"], {"query": {"kind": "TrendsQuery"}})
186+
self.assertIn("id", data)
187+
self.assertIn("created_at", data)
188+
189+
def test_create_artifact_not_allowed(self):
190+
response = self.client.post(
191+
f"/api/environments/{self.team.id}/agent-artifacts/",
192+
{
193+
"name": "New Artifact",
194+
"type": "visualization",
195+
"content": {"query": {"kind": "FunnelsQuery"}},
196+
"conversation": str(self.conversation.id),
197+
},
198+
format="json",
199+
)
200+
201+
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
202+
203+
def test_delete_artifact(self):
204+
artifact = AgentArtifact.objects.create(
205+
name="To Delete",
206+
type=AgentArtifact.Type.VISUALIZATION,
207+
content={},
208+
conversation=self.conversation,
209+
team=self.team,
210+
)
211+
212+
response = self.client.delete(f"/api/environments/{self.team.id}/agent-artifacts/{artifact.short_id}/")
213+
214+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
215+
self.assertFalse(AgentArtifact.objects.filter(short_id=artifact.short_id).exists())
216+
217+
def test_cannot_access_other_user_artifacts(self):
218+
other_user = self._create_user("[email protected]")
219+
other_conversation = Conversation.objects.create(
220+
user=other_user, team=self.team, title="Other Conversation", type=Conversation.Type.ASSISTANT
221+
)
222+
223+
artifact = AgentArtifact.objects.create(
224+
name="Other User Artifact",
225+
type=AgentArtifact.Type.VISUALIZATION,
226+
content={},
227+
conversation=other_conversation,
228+
team=self.team,
229+
)
230+
231+
response = self.client.get(f"/api/environments/{self.team.id}/agent-artifacts/{artifact.short_id}/")
232+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
233+
234+
def test_cannot_access_other_team_artifacts(self):
235+
other_team = self.organization.teams.create(name="Other Team")
236+
237+
other_team_conversation = Conversation.objects.create(
238+
user=self.user, team=other_team, title="Other Team Conversation", type=Conversation.Type.ASSISTANT
239+
)
240+
241+
artifact = AgentArtifact.objects.create(
242+
name="Other Team Artifact",
243+
type=AgentArtifact.Type.VISUALIZATION,
244+
content={},
245+
conversation=other_team_conversation,
246+
team=other_team,
247+
)
248+
249+
response = self.client.get(f"/api/environments/{self.team.id}/agent-artifacts/{artifact.short_id}/")
250+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
251+
252+
def test_list_artifacts_ordered_by_created_at_desc(self):
253+
AgentArtifact.objects.create(
254+
name="First",
255+
type=AgentArtifact.Type.VISUALIZATION,
256+
content={},
257+
conversation=self.conversation,
258+
team=self.team,
259+
)
260+
261+
AgentArtifact.objects.create(
262+
name="Second",
263+
type=AgentArtifact.Type.DOCUMENT,
264+
content={},
265+
conversation=self.conversation,
266+
team=self.team,
267+
)
268+
269+
response = self.client.get(f"/api/environments/{self.team.id}/agent-artifacts/")
270+
271+
self.assertEqual(response.status_code, status.HTTP_200_OK)
272+
results = response.json()["results"]
273+
self.assertEqual(results[0]["name"], "Second")
274+
self.assertEqual(results[1]["name"], "First")
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Generated by Django 4.2.26 on 2025-11-20 14:41
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
import posthog.models.utils
7+
8+
import ee.models.assistant
9+
10+
11+
class Migration(migrations.Migration):
12+
dependencies = [
13+
("posthog", "0909_survey_add_linked_insight_id"),
14+
("ee", "0030_singlesessionsummary_distinct_id_and_more"),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name="AgentArtifact",
20+
fields=[
21+
(
22+
"id",
23+
models.UUIDField(
24+
default=posthog.models.utils.uuid7, editable=False, primary_key=True, serialize=False
25+
),
26+
),
27+
(
28+
"short_id",
29+
models.CharField(
30+
db_index=True, default=ee.models.assistant.generate_short_id_6, max_length=6, unique=True
31+
),
32+
),
33+
("name", models.CharField(max_length=400)),
34+
(
35+
"type",
36+
models.CharField(
37+
choices=[("visualization", "Visualization"), ("document", "Document")], max_length=50
38+
),
39+
),
40+
("content", models.JSONField()),
41+
("created_at", models.DateTimeField(auto_now_add=True)),
42+
(
43+
"conversation",
44+
models.ForeignKey(
45+
on_delete=django.db.models.deletion.CASCADE, related_name="artifacts", to="ee.conversation"
46+
),
47+
),
48+
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")),
49+
],
50+
options={
51+
"indexes": [
52+
models.Index(fields=["conversation", "created_at"], name="ee_agentart_convers_550347_idx"),
53+
models.Index(fields=["team", "created_at"], name="ee_agentart_team_id_3789b3_idx"),
54+
],
55+
},
56+
),
57+
]

ee/migrations/max_migration.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0030_singlesessionsummary_distinct_id_and_more
1+
0031_agentartifact

0 commit comments

Comments
 (0)