Skip to content

Commit 425c157

Browse files
authored
Merge pull request #583 from Open-Source-Legal/feature/epic-546-discussion-threading
Epic #546: Discussion Threads - Core Threading & Agent Type Support
2 parents 44531df + 7e4fd52 commit 425c157

File tree

4 files changed

+969
-1
lines changed

4 files changed

+969
-1
lines changed

config/graphql/graphene_types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,16 @@ class Meta:
14011401
interfaces = [relay.Node]
14021402
connection_class = CountableConnection
14031403

1404+
@classmethod
1405+
def get_queryset(cls, queryset, info):
1406+
if issubclass(type(queryset), QuerySet):
1407+
return queryset.visible_to_user(info.context.user)
1408+
elif "RelatedManager" in str(type(queryset)):
1409+
# https://stackoverflow.com/questions/11320702/import-relatedmanager-from-django-db-models-fields-related
1410+
return queryset.all().visible_to_user(info.context.user)
1411+
else:
1412+
return queryset
1413+
14041414

14051415
class UserFeedbackType(AnnotatePermissionsForReadMixin, DjangoObjectType):
14061416
class Meta:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Generated by Django 4.2.24 on 2025-10-27 01:29
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("conversations", "0004_alter_chatmessage_options_alter_conversation_options"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="chatmessage",
16+
name="agent_type",
17+
field=models.CharField(
18+
blank=True,
19+
choices=[
20+
("document_agent", "Document Agent"),
21+
("corpus_agent", "Corpus Agent"),
22+
],
23+
help_text="The specific agent type that generated this message (for LLM messages)",
24+
max_length=32,
25+
null=True,
26+
),
27+
),
28+
migrations.AddField(
29+
model_name="chatmessage",
30+
name="deleted_at",
31+
field=models.DateTimeField(
32+
blank=True,
33+
help_text="Timestamp when the message was soft-deleted",
34+
null=True,
35+
),
36+
),
37+
migrations.AddField(
38+
model_name="chatmessage",
39+
name="parent_message",
40+
field=models.ForeignKey(
41+
blank=True,
42+
help_text="Parent message for threaded replies",
43+
null=True,
44+
on_delete=django.db.models.deletion.CASCADE,
45+
related_name="replies",
46+
to="conversations.chatmessage",
47+
),
48+
),
49+
migrations.AddField(
50+
model_name="conversation",
51+
name="conversation_type",
52+
field=models.CharField(
53+
choices=[("chat", "Chat"), ("thread", "Thread")],
54+
default="chat",
55+
help_text="Type of conversation: chat (agent-based) or thread (discussion)",
56+
max_length=32,
57+
),
58+
),
59+
migrations.AddField(
60+
model_name="conversation",
61+
name="deleted_at",
62+
field=models.DateTimeField(
63+
blank=True,
64+
help_text="Timestamp when the conversation was soft-deleted",
65+
null=True,
66+
),
67+
),
68+
]

opencontractserver/conversations/models.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22

33
import django
44
from django.contrib.auth import get_user_model
5+
from django.core.exceptions import ValidationError
56
from django.db import models
6-
from django.forms import ValidationError
77
from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase
88

99
from opencontractserver.annotations.models import Annotation
1010
from opencontractserver.corpuses.models import Corpus
1111
from opencontractserver.documents.models import Document
1212
from opencontractserver.shared.defaults import jsonfield_default_value
1313
from opencontractserver.shared.fields import NullableJSONField
14+
from opencontractserver.shared.Managers import BaseVisibilityManager
1415
from opencontractserver.shared.Models import BaseOCModel
1516

1617
User = get_user_model()
@@ -29,6 +30,82 @@ class MessageStateChoices(models.TextChoices):
2930
AWAITING_APPROVAL = "awaiting_approval", "Awaiting Approval"
3031

3132

33+
# Conversation types for distinguishing between agent chats and discussion threads
34+
class ConversationTypeChoices(models.TextChoices):
35+
CHAT = "chat", "Chat" # Default for agent-based conversations
36+
THREAD = "thread", "Thread" # For discussion threads
37+
38+
39+
# Agent types for multi-agent conversation support
40+
class AgentTypeChoices(models.TextChoices):
41+
DOCUMENT_AGENT = "document_agent", "Document Agent"
42+
CORPUS_AGENT = "corpus_agent", "Corpus Agent"
43+
44+
45+
# Custom QuerySet for soft delete functionality
46+
class SoftDeleteQuerySet(models.QuerySet):
47+
"""
48+
QuerySet that filters soft-deleted objects and implements user visibility.
49+
"""
50+
51+
def visible_to_user(self, user=None):
52+
"""
53+
Returns queryset filtered to objects visible to the user.
54+
Maintains soft-delete filtering from the base queryset.
55+
"""
56+
from django.apps import apps
57+
from django.contrib.auth.models import AnonymousUser
58+
from django.db.models import Q
59+
60+
# Handle None user as anonymous
61+
if user is None:
62+
user = AnonymousUser()
63+
64+
# Start with current queryset (already has soft-delete filtering)
65+
queryset = self
66+
67+
# Superusers see everything
68+
if hasattr(user, "is_superuser") and user.is_superuser:
69+
return queryset.order_by("created")
70+
71+
# Anonymous users only see public items
72+
if user.is_anonymous:
73+
return queryset.filter(is_public=True)
74+
75+
# Authenticated users: public, created by them, or explicitly shared
76+
model_name = self.model._meta.model_name
77+
app_label = self.model._meta.app_label
78+
79+
try:
80+
permission_model_name = f"{model_name}userobjectpermission"
81+
permission_model_type = apps.get_model(app_label, permission_model_name)
82+
permitted_ids = permission_model_type.objects.filter(
83+
permission__codename=f"read_{model_name}", user_id=user.id
84+
).values_list("content_object_id", flat=True)
85+
86+
return queryset.filter(
87+
Q(creator_id=user.id) | Q(is_public=True) | Q(id__in=permitted_ids)
88+
)
89+
except LookupError:
90+
# Fallback if permission model doesn't exist
91+
return queryset.filter(Q(creator_id=user.id) | Q(is_public=True))
92+
93+
94+
# Custom manager for soft delete functionality
95+
class SoftDeleteManager(BaseVisibilityManager):
96+
"""
97+
Manager that filters out soft-deleted objects by default and implements
98+
user visibility permissions via BaseVisibilityManager.
99+
Use Model.all_objects to access soft-deleted objects.
100+
"""
101+
102+
def get_queryset(self):
103+
# Return our custom queryset, filtered for non-deleted objects
104+
return SoftDeleteQuerySet(self.model, using=self._db).filter(
105+
deleted_at__isnull=True
106+
)
107+
108+
32109
class ConversationUserObjectPermission(UserObjectPermissionBase):
33110
"""
34111
Permissions for Conversation objects at the user level.
@@ -73,6 +150,17 @@ class Conversation(BaseOCModel):
73150
auto_now=True,
74151
help_text="Timestamp when the conversation was last updated",
75152
)
153+
conversation_type = models.CharField(
154+
max_length=32,
155+
choices=ConversationTypeChoices.choices,
156+
default=ConversationTypeChoices.CHAT,
157+
help_text="Type of conversation: chat (agent-based) or thread (discussion)",
158+
)
159+
deleted_at = models.DateTimeField(
160+
null=True,
161+
blank=True,
162+
help_text="Timestamp when the conversation was soft-deleted",
163+
)
76164
chat_with_corpus = models.ForeignKey(
77165
Corpus,
78166
on_delete=models.SET_NULL,
@@ -90,6 +178,10 @@ class Conversation(BaseOCModel):
90178
null=True,
91179
)
92180

181+
# Managers
182+
objects = SoftDeleteManager() # Default manager excludes soft-deleted
183+
all_objects = models.Manager() # Access all objects including soft-deleted
184+
93185
class Meta:
94186
constraints = [
95187
django.db.models.CheckConstraint(
@@ -156,6 +248,21 @@ class Meta:
156248
choices=TYPE_CHOICES,
157249
help_text="The type of message (SYSTEM, HUMAN, or LLM)",
158250
)
251+
agent_type = models.CharField(
252+
max_length=32,
253+
choices=AgentTypeChoices.choices,
254+
blank=True,
255+
null=True,
256+
help_text="The specific agent type that generated this message (for LLM messages)",
257+
)
258+
parent_message = models.ForeignKey(
259+
"self",
260+
on_delete=models.CASCADE,
261+
related_name="replies",
262+
blank=True,
263+
null=True,
264+
help_text="Parent message for threaded replies",
265+
)
159266
content = models.TextField(
160267
help_text="The textual content of the chat message",
161268
)
@@ -169,6 +276,11 @@ class Meta:
169276
auto_now_add=True,
170277
help_text="Timestamp when the chat message was created",
171278
)
279+
deleted_at = models.DateTimeField(
280+
null=True,
281+
blank=True,
282+
help_text="Timestamp when the message was soft-deleted",
283+
)
172284

173285
source_document = models.ForeignKey(
174286
Document,
@@ -198,6 +310,10 @@ class Meta:
198310
help_text="Lifecycle state of the message for quick filtering",
199311
)
200312

313+
# Managers
314+
objects = SoftDeleteManager() # Default manager excludes soft-deleted
315+
all_objects = models.Manager() # Access all objects including soft-deleted
316+
201317
def __str__(self) -> str:
202318
return (
203319
f"ChatMessage {self.pk} - {self.msg_type} "

0 commit comments

Comments
 (0)