diff --git a/llm_be/chat_backend/consumers.py b/llm_be/chat_backend/consumers.py index 20f3328..6346478 100644 --- a/llm_be/chat_backend/consumers.py +++ b/llm_be/chat_backend/consumers.py @@ -143,7 +143,10 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st prompt_instance.file_type = file_type prompt_instance.save() - for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id): + for prompt_obj in ( + Prompt.objects.filter(conversation__id=conversation_id) + .order_by("created", "id") + ): messages.append( { "content": prompt_obj.message, diff --git a/llm_be/chat_backend/consumers_graph.py b/llm_be/chat_backend/consumers_graph.py index 90e4fec..b261689 100644 --- a/llm_be/chat_backend/consumers_graph.py +++ b/llm_be/chat_backend/consumers_graph.py @@ -126,7 +126,10 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st prompt_instance.file_type = file_type prompt_instance.save() - for prompt_obj in Prompt.objects.filter(conversation__id=conversation_id): + for prompt_obj in ( + Prompt.objects.filter(conversation__id=conversation_id) + .order_by("created", "id") + ): messages.append( { "content": prompt_obj.message, diff --git a/llm_be/chat_backend/migrations/0036_enable_conversation_context.py b/llm_be/chat_backend/migrations/0036_enable_conversation_context.py new file mode 100644 index 0000000..eba3a0a --- /dev/null +++ b/llm_be/chat_backend/migrations/0036_enable_conversation_context.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +def enable_same_chat_history(apps, schema_editor): + """Turn on thread history for accounts still on the old default (#84). + + The column defaulted to false, so an explicit opt-out cannot be told apart + from "never touched". Follow-ups were ignoring the open chat. Accounts that + still want one-shot answers can turn the setting off again. + """ + CustomUser = apps.get_model("chat_backend", "CustomUser") + CustomUser.objects.filter(use_conversation_context=False).update( + use_conversation_context=True + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat_backend", "0035_waitlistentry"), + ] + + operations = [ + migrations.AlterField( + model_name="customuser", + name="use_conversation_context", + field=models.BooleanField( + default=True, + help_text=( + "When enabled, earlier turns in the open chat are included " + "when answering a follow-up. Turn off to answer each message alone." + ), + ), + ), + migrations.RunPython(enable_same_chat_history, migrations.RunPython.noop), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index f2ace53..cc0c335 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -74,10 +74,10 @@ class CustomUser(AbstractUser): default=True, help_text="How the conversations should display" ) use_conversation_context = models.BooleanField( - default=False, + default=True, help_text=( - "When enabled, prior turns in the conversation are used as " - "LLM/RAG context for a more tailored experience" + "When enabled, earlier turns in the open chat are included " + "when answering a follow-up. Turn off to answer each message alone." ), ) @@ -525,9 +525,7 @@ class DriveConnection(TimeInfoBase): help_text="Required for company connections; optional for personal (#55).", ) provider = models.CharField(max_length=32, choices=Provider.choices) - kind = models.CharField( - max_length=16, choices=Kind.choices, default=Kind.PERSONAL - ) + kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.PERSONAL) access_token = models.TextField(blank=True, default="") refresh_token = models.TextField(blank=True, default="") token_expires_at = models.DateTimeField(null=True, blank=True) diff --git a/llm_be/chat_backend/services/assistant_identity.py b/llm_be/chat_backend/services/assistant_identity.py index 2196dc9..5ac324d 100644 --- a/llm_be/chat_backend/services/assistant_identity.py +++ b/llm_be/chat_backend/services/assistant_identity.py @@ -13,12 +13,18 @@ ASSISTANT_SYSTEM_PROMPT = ( ) GROUNDED_ANSWER_INSTRUCTIONS = ( - "You have been given numbered live sources. Answer ONLY from those sources. " - "Cite source indexes inline like [1] or [2]. " - "If the sources do not settle the question, say so explicitly — do not fill " - "gaps from memory or training data. " + "You may have numbered live sources and the conversation so far. " + "Use the conversation for follow-ups: explain a previous answer, reformat it " + "(including a table of items already named), and reply to lines such as " + '"I don\'t know, why?". Do not say that list or answer is missing when it ' + "is already in the conversation. " + "Cite source indexes inline like [1] or [2] only for facts taken from live sources. " + "If a new external fact (price, rating, date, news) is in neither the conversation " + "nor the sources, say that specific fact is unavailable, and still complete the " + "part the conversation already answers. " "When sources conflict, prefer the most recent dated source. " - "Never state a date, number, name, or event that does not appear in the sources." + "Never invent a date, number, name, or event that is in neither the conversation " + "nor the sources." ) RETRIEVAL_FAILED_MESSAGE = ( diff --git a/llm_be/chat_backend/services/grounded_chat.py b/llm_be/chat_backend/services/grounded_chat.py index 0c5718b..603c59c 100644 --- a/llm_be/chat_backend/services/grounded_chat.py +++ b/llm_be/chat_backend/services/grounded_chat.py @@ -88,8 +88,8 @@ async def prepare_grounded_chat( ``on_status(stage, detail)`` emits live activity frames (#96) when provided; otherwise uses the context-bound emitter from :mod:`status_context`. - ``use_conversation_context`` gates prior-turn history in the LLM prompt - (#33); default off (opt-in). + ``use_conversation_context`` gates prior-turn history in the LLM prompt. + The account setting defaults on so follow-ups see this chat (#84). """ gen_kwargs = {"use_conversation_context": use_conversation_context} internet = getattr(settings, "ALLOW_INTERNET_ACCESS", False) @@ -104,7 +104,9 @@ async def prepare_grounded_chat( ) await _emit(on_status, "evaluating") - decision = await grounding_decider.decide_async(message) + # Prior turns exist once the current user message is not the only row. + has_history = bool(use_conversation_context) and len(messages) > 1 + decision = await grounding_decider.decide_async(message, has_history=has_history) if not decision.needs_retrieval: await _emit(on_status, "refining") diff --git a/llm_be/chat_backend/services/grounding_decider.py b/llm_be/chat_backend/services/grounding_decider.py index 00fabe3..1512397 100644 --- a/llm_be/chat_backend/services/grounding_decider.py +++ b/llm_be/chat_backend/services/grounding_decider.py @@ -43,6 +43,28 @@ _TEMPORAL_PATTERNS = ( _TEMPORAL_RE = re.compile("|".join(_TEMPORAL_PATTERNS), re.IGNORECASE) _YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b") +# Pure follow-ups should reuse the open chat, not a fresh web search (#84). +_PURE_FOLLOWUP_RE = re.compile( + r"(" + r"\bi don'?t know\b" + r"|^\s*why\b" + r"|\bwhy\s*\??\s*$" + r"|\bwhat do you mean\b" + r"|\bexplain\b" + r"|\b(that|this|the|your) (list|joke|answer|setup|punchline)\b" + r"|\bmake (that|this|it|them)\b" + r"|\bput (that|this|it|them)\b" + r"|\bin a table\b" + r"|\binto a table\b" + r")", + re.IGNORECASE, +) +# These still need live sources even when the sentence points at earlier turns. +_NEW_EXTERNAL_FACT_RE = re.compile( + r"\b(price|prices|rating|ratings|cost|costs|review|reviews|cheapest)\b", + re.IGNORECASE, +) + @dataclass class GroundingDecision: @@ -107,6 +129,28 @@ message). Example: "did Taylor Swift get married" → source="prepass", ) + def followup_prepass( + self, prompt: str, *, has_history: bool + ) -> GroundingDecision | None: + """Skip web search when the line only continues the open chat (#84).""" + if not has_history: + return None + text = (prompt or "").strip() + if not text or not _PURE_FOLLOWUP_RE.search(text): + return None + if _NEW_EXTERNAL_FACT_RE.search(text): + return None + temporal = self.temporal_prepass(text) + if temporal is not None and temporal.needs_retrieval: + return None + return GroundingDecision( + needs_retrieval=False, + reason="follow-up on the current conversation", + queries=[], + temporal=False, + source="prepass", + ) + def _parse(self, raw: str, fallback_query: str) -> GroundingDecision: text = (raw or "").strip() # Strip markdown fences if the small model wraps JSON. @@ -144,7 +188,12 @@ message). Example: "did Taylor Swift get married" → source="model", ) - async def decide_async(self, prompt: str) -> GroundingDecision: + async def decide_async( + self, prompt: str, *, has_history: bool = False + ) -> GroundingDecision: + follow = self.followup_prepass(prompt, has_history=has_history) + if follow is not None: + return follow pre = self.temporal_prepass(prompt) # Even on temporal prepass, ask the model for better queries when possible. try: @@ -173,7 +222,10 @@ message). Example: "did Taylor Swift get married" → ) return decision - def decide(self, prompt: str) -> GroundingDecision: + def decide(self, prompt: str, *, has_history: bool = False) -> GroundingDecision: + follow = self.followup_prepass(prompt, has_history=has_history) + if follow is not None: + return follow pre = self.temporal_prepass(prompt) try: raw = self.chain.invoke({"prompt": prompt}) diff --git a/llm_be/chat_backend/services/llm_service.py b/llm_be/chat_backend/services/llm_service.py index c473a38..9a8cf24 100644 --- a/llm_be/chat_backend/services/llm_service.py +++ b/llm_be/chat_backend/services/llm_service.py @@ -173,16 +173,12 @@ Response:""" block (when grounded) is never trimmed. """ sources = self.sources_block or kwargs.get("sources_block", "") or "" - use_conversation_context = bool( - kwargs.get("use_conversation_context", False) - ) + use_conversation_context = bool(kwargs.get("use_conversation_context", False)) history_text = "" if use_conversation_context: reserved = ( estimate_tokens(ASSISTANT_SYSTEM_PROMPT) - + estimate_tokens( - GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else "" - ) + + estimate_tokens(GROUNDED_ANSWER_INSTRUCTIONS if self.grounded else "") + estimate_tokens(sources) + estimate_tokens(query) + 256 # response headroom / instructions @@ -203,8 +199,9 @@ Response:""" and str(getattr(m, "content", "")).startswith("Live sources:") ) ] - # Exclude the latest user turn from history (it's in {query}). - prior = clean[:-1] if clean else [] + # Current user turn is passed as {query}. Drop that text, not + # whichever row happens to be last. + prior = prior_turns(clean, query) windowed = window_history( prior, budget_tokens=history_budget, reserved_tokens=0 ) @@ -220,6 +217,33 @@ Response:""" yield chunk +def message_text(message) -> str: + text = getattr(message, "content", "") or "" + if not isinstance(text, str): + text = str(text) + return text + + +def prior_turns(messages, query: str) -> list: + """Drop the current user turn from history. + + Match the query text, newest hit first, so an unordered transcript cannot + drop a different turn just because it sits at the end of the list (#84). + If the query is not in the list, keep every turn. + """ + if not messages: + return [] + needle = (query or "").strip() + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if ( + getattr(message, "type", "") == "human" + and message_text(message).strip() == needle + ): + return list(messages[:index]) + list(messages[index + 1 :]) + return list(messages) + + def build_chat_service( *, model_name: str | None, diff --git a/llm_be/chat_backend/tests/test_consumers.py b/llm_be/chat_backend/tests/test_consumers.py index b5cf827..8e1d1cc 100644 --- a/llm_be/chat_backend/tests/test_consumers.py +++ b/llm_be/chat_backend/tests/test_consumers.py @@ -1,9 +1,11 @@ -from unittest import mock import json +from datetime import timedelta +from unittest import mock from asgiref.sync import sync_to_async from channels.testing import WebsocketCommunicator from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone from langchain_core.messages import AIMessage, HumanMessage from parameterized import parameterized @@ -47,9 +49,7 @@ class DatabaseHelperTestCase(TransactionTestCase): @parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)]) async def test_get_workspace(self, _name, module): - workspace = await module.get_workspace( - self.conversation.id, user=self.user - ) + workspace = await module.get_workspace(self.conversation.id, user=self.user) self.assertEqual(workspace.id, self.workspace.id) @@ -99,6 +99,29 @@ class DatabaseHelperTestCase(TransactionTestCase): [type(message) for message in messages], [AIMessage, HumanMessage] ) + @parameterized.expand([("websocket", consumers), ("langgraph", consumers_graph)]) + async def test_get_messages_orders_by_created_not_insert_order(self, _name, module): + now = timezone.now() + await sync_to_async(Prompt.objects.create)( + conversation=self.conversation, + message="newer answer", + user_created=False, + created=now, + ) + await sync_to_async(Prompt.objects.create)( + conversation=self.conversation, + message="older question", + user_created=True, + created=now - timedelta(minutes=5), + ) + + messages, _prompt = await module.get_messages(self.conversation.id, "follow up") + + self.assertEqual( + [message.content for message in messages], + ["older question", "newer answer", "follow up"], + ) + async def test_get_messages_stores_attachment_and_inlines_csv(self): messages, prompt_instance = await consumers.get_messages( self.conversation.id, "analyze this", b"name,sales\nalice,10\n", "csv" @@ -587,13 +610,16 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase): connected, _ = await communicator.connect() self.assertTrue(connected) - with mock.patch( - "chat_backend.consumers.title_generator.generate_async", - new_callable=mock.AsyncMock, - ) as title_chat, mock.patch( - "chat_backend.consumers_graph.title_generator.generate_async", - new_callable=mock.AsyncMock, - ) as title_graph: + with ( + mock.patch( + "chat_backend.consumers.title_generator.generate_async", + new_callable=mock.AsyncMock, + ) as title_chat, + mock.patch( + "chat_backend.consumers_graph.title_generator.generate_async", + new_callable=mock.AsyncMock, + ) as title_graph, + ): await communicator.send_json_to({"type": "ping", "email": "a@b.com"}) # No reply expected; give the event loop a tick. self.assertTrue(await communicator.receive_nothing(timeout=0.2)) @@ -612,13 +638,16 @@ class WebSocketReceiveGuardTestCase(TransactionTestCase): connected, _ = await communicator.connect() self.assertTrue(connected) - with mock.patch( - "chat_backend.consumers.title_generator.generate_async", - new_callable=mock.AsyncMock, - ) as title_chat, mock.patch( - "chat_backend.consumers_graph.title_generator.generate_async", - new_callable=mock.AsyncMock, - ) as title_graph: + with ( + mock.patch( + "chat_backend.consumers.title_generator.generate_async", + new_callable=mock.AsyncMock, + ) as title_chat, + mock.patch( + "chat_backend.consumers_graph.title_generator.generate_async", + new_callable=mock.AsyncMock, + ) as title_graph, + ): await communicator.send_json_to( {"message": " ", "email": "a@b.com", "conversation_id": None} ) diff --git a/llm_be/chat_backend/tests/test_grounding_search.py b/llm_be/chat_backend/tests/test_grounding_search.py index 2ef46e5..aff8e5c 100644 --- a/llm_be/chat_backend/tests/test_grounding_search.py +++ b/llm_be/chat_backend/tests/test_grounding_search.py @@ -86,6 +86,25 @@ class GroundingPrepassTestCase(SimpleTestCase): self.assertEqual(decision.source, "fail_open") self.assertEqual(decision.queries, ["fallback query"]) + def test_pure_followup_skips_retrieval_without_calling_model(self): + self.decider.chain = mock.Mock() + decision = self.decider.decide("I don't know. Why?", has_history=True) + self.assertFalse(decision.needs_retrieval) + self.assertEqual(decision.reason, "follow-up on the current conversation") + self.decider.chain.invoke.assert_not_called() + + def test_followup_without_history_does_not_short_circuit(self): + self.assertIsNone( + self.decider.followup_prepass("I don't know. Why?", has_history=False) + ) + + def test_price_followup_still_allows_retrieval(self): + decision = self.decider.followup_prepass( + "Can you make that list for me and add the prices and ratings in a table", + has_history=True, + ) + self.assertIsNone(decision) + def test_decide_fail_open_on_llm_error(self): self.decider.chain = mock.Mock() self.decider.chain.invoke.side_effect = RuntimeError("ollama down") diff --git a/llm_be/chat_backend/tests/test_migration_graph.py b/llm_be/chat_backend/tests/test_migration_graph.py index 5b9092c..6e52cae 100644 --- a/llm_be/chat_backend/tests/test_migration_graph.py +++ b/llm_be/chat_backend/tests/test_migration_graph.py @@ -6,9 +6,11 @@ from django.test import TestCase class MigrationGraphTests(TestCase): def test_chat_backend_has_a_single_leaf(self): loader = MigrationLoader(connection, ignore_no_migrations=True) - leaves = [name for app, name in loader.graph.leaf_nodes() if app == "chat_backend"] - self.assertEqual(leaves, ["0035_waitlistentry"]) + leaves = [ + name for app, name in loader.graph.leaf_nodes() if app == "chat_backend" + ] + self.assertEqual(leaves, ["0036_enable_conversation_context"]) - node = loader.graph.nodes[("chat_backend", "0035_waitlistentry")] + node = loader.graph.nodes[("chat_backend", "0036_enable_conversation_context")] parents = [name for app, name in node.dependencies if app == "chat_backend"] - self.assertEqual(parents, ["0034_customuser_use_conversation_context"]) + self.assertEqual(parents, ["0035_waitlistentry"]) diff --git a/llm_be/chat_backend/tests/test_models.py b/llm_be/chat_backend/tests/test_models.py index eef159d..7e4a498 100644 --- a/llm_be/chat_backend/tests/test_models.py +++ b/llm_be/chat_backend/tests/test_models.py @@ -81,7 +81,7 @@ class CompanyAndUserTestCase(TestCase): self.assertFalse(user.deleted) self.assertFalse(user.has_signed_tos) self.assertTrue(user.conversation_order) - self.assertFalse(user.use_conversation_context) + self.assertTrue(user.use_conversation_context) class ConversationAndPromptTestCase(TestCase): diff --git a/llm_be/chat_backend/tests/test_services_llm.py b/llm_be/chat_backend/tests/test_services_llm.py index d085ff9..98b009a 100644 --- a/llm_be/chat_backend/tests/test_services_llm.py +++ b/llm_be/chat_backend/tests/test_services_llm.py @@ -1,7 +1,12 @@ from django.test import SimpleTestCase from langchain_core.messages import AIMessage, HumanMessage -from chat_backend.services.llm_service import AsyncLLMService, SyncLLMService +from chat_backend.services.assistant_identity import GROUNDED_ANSWER_INSTRUCTIONS +from chat_backend.services.llm_service import ( + AsyncLLMService, + SyncLLMService, + prior_turns, +) from chat_backend.services.prompt_budget import format_history, window_history from .fakes import FakeChain @@ -36,7 +41,7 @@ class AsyncLLMServiceTestCase(SimpleTestCase): async def test_generate_response_sends_single_history_window(self): self.service.conversation_chain = FakeChain(chunks=["ok"]) - messages = conversation(4) # 8 messages + messages = conversation(4) + [HumanMessage(content="latest")] async for _ in self.service.generate_response( messages, "latest", 1, use_conversation_context=True @@ -47,10 +52,33 @@ class AsyncLLMServiceTestCase(SimpleTestCase): self.assertEqual(payload["query"], "latest") # Latest user turn is in {query}; history is prior turns only, once. self.assertIn("history", payload) + self.assertNotIn("User: latest", payload["history"]) + self.assertIn("AI: answer 3", payload["history"]) self.assertNotIn("recent_conversation", payload) self.assertNotIn("conversation", payload) - # 8 messages → drop last (query) → 7 prior lines max in window. - self.assertLessEqual(len(payload["history"].splitlines()), 7) + self.assertLessEqual(len(payload["history"].splitlines()), 8) + + async def test_history_drops_matching_turn_even_if_it_is_not_last(self): + self.service.conversation_chain = FakeChain(chunks=["ok"]) + # Unordered transcript: current question is first, older turns follow. + messages = [ + HumanMessage(content="I don't know. Why?"), + AIMessage(content="Why did the meditation instructor bring a ladder?"), + HumanMessage(content="Tell me a joke"), + ] + + async for _ in self.service.generate_response( + messages, + "I don't know. Why?", + 1, + use_conversation_context=True, + ): + pass + + history = self.service.conversation_chain.calls[0]["history"] + self.assertNotIn("I don't know", history) + self.assertIn("Tell me a joke", history) + self.assertIn("meditation instructor", history) async def test_generate_response_skips_history_when_context_disabled(self): self.service.conversation_chain = FakeChain(chunks=["ok"]) @@ -73,7 +101,9 @@ class AsyncLLMServiceTestCase(SimpleTestCase): self.assertEqual(self.service.conversation_chain.calls[0]["history"], "") async def test_grounded_service_includes_sources(self): - service = AsyncLLMService(grounded=True, sources_block='[1] "T" — x.com — undated') + service = AsyncLLMService( + grounded=True, sources_block='[1] "T" — x.com — undated' + ) service.conversation_chain = FakeChain(chunks=["ok"]) async for _ in service.generate_response( @@ -90,6 +120,24 @@ class SyncLLMServiceTestCase(SimpleTestCase): self.assertIsNotNone(SyncLLMService()) +class PriorTurnsTestCase(SimpleTestCase): + def test_grounded_instructions_keep_the_conversation(self): + self.assertIn("conversation", GROUNDED_ANSWER_INSTRUCTIONS.lower()) + self.assertNotIn("Answer ONLY from those sources", GROUNDED_ANSWER_INSTRUCTIONS) + + def test_prior_turns_drops_newest_matching_user_text(self): + messages = [ + HumanMessage(content="I don't know. Why?"), + AIMessage(content="the joke"), + HumanMessage(content="Tell me a joke"), + ] + kept = prior_turns(messages, "I don't know. Why?") + self.assertEqual( + [message.content for message in kept], + ["the joke", "Tell me a joke"], + ) + + class PromptBudgetTestCase(SimpleTestCase): def test_window_history_drops_oldest_first(self): messages = [HumanMessage(content="x" * 40) for _ in range(10)] @@ -99,7 +147,5 @@ class PromptBudgetTestCase(SimpleTestCase): self.assertEqual(kept[-1].content, messages[-1].content) def test_format_history_labels_speakers(self): - text = format_history( - [HumanMessage(content="hello"), AIMessage(content="hi")] - ) + text = format_history([HumanMessage(content="hello"), AIMessage(content="hi")]) self.assertEqual(text, "User: hello\nAI: hi") diff --git a/llm_be/chat_backend/tests/test_views_conversations.py b/llm_be/chat_backend/tests/test_views_conversations.py index 90708ac..8612d18 100644 --- a/llm_be/chat_backend/tests/test_views_conversations.py +++ b/llm_be/chat_backend/tests/test_views_conversations.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APITestCase @@ -57,7 +60,7 @@ class ConversationPreferencesTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.data["order"]) - self.assertFalse(response.data["use_conversation_context"]) + self.assertTrue(response.data["use_conversation_context"]) def test_post_toggles_and_persists_order(self): response = self.client.post(self.url) @@ -115,8 +118,33 @@ class ConversationDetailViewTestCase(APITestCase): [("hello", True), ("hi there", False)], ) + def test_get_returns_prompts_oldest_first_even_if_inserted_newest_first(self): + now = timezone.now() + make_prompt( + self.conversation, + message="later", + user_created=False, + created=now, + ) + make_prompt( + self.conversation, + message="earlier", + user_created=True, + created=now - timedelta(minutes=5), + ) + + response = self.client.get(self.url, {"conversation_id": self.conversation.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["message"] for item in response.data], + ["earlier", "later"], + ) + def test_get_rejects_other_users_conversation(self): - other = make_user(email="other-tenant@example.com", company=make_company("Other")) + other = make_user( + email="other-tenant@example.com", company=make_company("Other") + ) foreign = make_conversation(user=other) make_prompt(foreign, message="secret") diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index 9fc21f2..2a98f6c 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -680,7 +680,7 @@ class ConversationDetailView(APIView): prompts = list( Prompt.objects.filter( conversation__id=conversation_id, conversation__user=request.user - ) + ).order_by("created", "id") ) serializer = PromptSerializer( prompts, @@ -734,7 +734,7 @@ class ConversationDetailView(APIView): messages = [] for prompt_obj in Prompt.objects.filter( conversation__id=conversation_id - ): + ).order_by("created", "id"): messages.append( { "content": prompt_obj.message,